/** * Pace and budget guards. * * Two separate concerns, both about not getting the account into trouble and not * burning more of the (independent) web quota than intended: * * 1. Pacing and caps. The bridge sends messages far faster and far more * regularly than a human. That is the one behavioural signal a page cannot * observe but an account-level system can. A jittered minimum gap, plus * hourly/daily caps, keeps the pattern plausible and bounded. * * 2. Blocker detection. If the page shows a challenge, a usage notice, or has * been logged out, the loop must stop immediately instead of hammering it. * * Counters persist to disk so restarting pi does not reset the budget. * * Zero pi dependencies. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { estimateTokens } from "../core/protocol.ts"; export interface GuardConfig { /** Minimum gap between two sends, before jitter. */ minGapMs: number; /** Maximum extra jitter added to the gap. */ jitterMs: number; /** Rolling-window send cap. */ maxPerHour: number; /** Daily send cap. */ maxPerDay: number; /** Total prompt+reply tokens allowed for one run; 0 = unlimited. */ sessionTokenBudget: number; statePath: string; } export const DEFAULT_GUARD_CONFIG: Omit = { minGapMs: 20_000, jitterMs: 25_000, maxPerHour: 20, maxPerDay: 120, sessionTokenBudget: 200_000, }; interface GuardState { sends: number[]; totalTokens: number; } export interface GuardStatus { sendsLastHour: number; sendsToday: number; remainingHour: number; remainingDay: number; tokensThisSession: number; sessionTokenBudget: number; nextAllowedInMs: number; lastSendAt: number | null; } function dayKey(epochMs: number): string { const date = new Date(epochMs); return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; } export class Guard { private state: GuardState = { sends: [], totalTokens: 0 }; private sessionTokens = 0; private lastSendAt: number | null = null; constructor(private config: GuardConfig) { this.load(); } private load(): void { try { if (!existsSync(this.config.statePath)) return; const parsed = JSON.parse(readFileSync(this.config.statePath, "utf8")) as Partial; this.state = { sends: Array.isArray(parsed.sends) ? parsed.sends.filter((n) => typeof n === "number") : [], totalTokens: typeof parsed.totalTokens === "number" ? parsed.totalTokens : 0, }; const last = this.state.sends[this.state.sends.length - 1]; this.lastSendAt = typeof last === "number" ? last : null; } catch { this.state = { sends: [], totalTokens: 0 }; } } private save(): void { try { mkdirSync(dirname(this.config.statePath), { recursive: true }); writeFileSync(this.config.statePath, `${JSON.stringify(this.state, null, 2)}\n`, "utf8"); } catch { // never let bookkeeping break the bridge } } private prune(now = Date.now()): void { // keep 24h of history at most this.state.sends = this.state.sends.filter((ts) => now - ts < 24 * 60 * 60 * 1000); } /** How long we must still wait before the next send is allowed. */ nextAllowedInMs(now = Date.now()): number { if (this.lastSendAt === null) return 0; const elapsed = now - this.lastSendAt; const required = this.config.minGapMs; return Math.max(0, required - elapsed); } /** * Why the next send is not allowed yet, or null when it is. * Returns a human-readable reason suitable for a notification. */ blockedReason(now = Date.now()): string | null { this.prune(now); const hourAgo = now - 60 * 60 * 1000; const sendsLastHour = this.state.sends.filter((ts) => ts >= hourAgo).length; const today = dayKey(now); const sendsToday = this.state.sends.filter((ts) => dayKey(ts) === today).length; if (sendsLastHour >= this.config.maxPerHour) { return `已达每小时上限 ${this.config.maxPerHour} 次(近一小时已发 ${sendsLastHour} 次)`; } if (sendsToday >= this.config.maxPerDay) { return `已达每日上限 ${this.config.maxPerDay} 次(今天已发 ${sendsToday} 次)`; } if (this.config.sessionTokenBudget > 0 && this.sessionTokens >= this.config.sessionTokenBudget) { return `已达本次运行的 token 预算 ${this.config.sessionTokenBudget}(已用约 ${this.sessionTokens})`; } return null; } /** Sleep out the remaining gap plus fresh jitter, then report the wait. */ async waitForSlot(onWait?: (waitMs: number, totalMs: number) => void): Promise { const remaining = this.nextAllowedInMs(); const jitter = Math.floor(Math.random() * this.config.jitterMs); const total = remaining + jitter; if (total <= 0) return 0; onWait?.(remaining, total); await new Promise((resolve) => setTimeout(resolve, total)); return total; } recordSend(): void { const now = Date.now(); this.state.sends.push(now); this.lastSendAt = now; this.prune(now); this.save(); } /** Count one round's prompt + reply against the session budget. */ recordTokens(promptText: string, replyText: string): number { const used = estimateTokens(promptText) + estimateTokens(replyText); this.sessionTokens += used; this.state.totalTokens += used; this.save(); return used; } status(now = Date.now()): GuardStatus { this.prune(now); const hourAgo = now - 60 * 60 * 1000; const sendsLastHour = this.state.sends.filter((ts) => ts >= hourAgo).length; const today = dayKey(now); const sendsToday = this.state.sends.filter((ts) => dayKey(ts) === today).length; return { sendsLastHour, sendsToday, remainingHour: Math.max(0, this.config.maxPerHour - sendsLastHour), remainingDay: Math.max(0, this.config.maxPerDay - sendsToday), tokensThisSession: this.sessionTokens, sessionTokenBudget: this.config.sessionTokenBudget, nextAllowedInMs: this.nextAllowedInMs(now), lastSendAt: this.lastSendAt, }; } } /* ------------------------------------------------------------------ */ /* Blocker detection */ /* ------------------------------------------------------------------ */ export interface BlockerInput { url: string; title: string; /** Visible text of the page, already lowercased is fine. */ bodyText: string; loggedOut: boolean; } /** * Signals that must stop the loop rather than be retried. * * Deliberately conservative: it only matches phrases that indicate the account * or the page is in a state where continuing would make things worse. */ /** * Platform-blocker categories (C 轮合规整改 C4). * * One union, one pattern table: detectBlocker() answers “should we stop, and why” as a * human string; detectPlatformBlock() answers “which boundary was hit” for code that has * to branch (and for user-facing wording). Two views of the SAME table, not two parsers. */ export type PlatformBlocker = | "CAPTCHA" | "SECURITY_CHALLENGE" | "RATE_LIMIT" | "USAGE_LIMIT" | "AUTH_REQUIRED"; const BLOCKER_PATTERNS: Array<{ pattern: RegExp; reason: string; block: PlatformBlocker }> = [ { pattern: /verify you are human|确认您是真人|人机验证|captcha|cf-challenge/i, reason: "页面要求人机验证", block: "CAPTCHA" }, { pattern: /unusual activity|异常活动|suspicious activity/i, reason: "账号被标记异常活动", block: "SECURITY_CHALLENGE" }, { pattern: /reached (?:the )?(?:limit|maximum)|达到上限|已达上限|使用上限|too many requests/i, reason: "命中使用上限", block: "USAGE_LIMIT" }, { pattern: /you'?ve hit (?:the )?(?:current )?limit|已达当前限制/i, reason: "命中当前限额", block: "USAGE_LIMIT" }, { pattern: /temporarily (?:unavailable|blocked)|暂时不可用|暂时受限/i, reason: "服务暂时受限", block: "RATE_LIMIT" }, { pattern: /rate limit|请求过于频繁|please try again later|稍后再试/i, reason: "被限流", block: "RATE_LIMIT" }, ]; export function detectBlocker(input: BlockerInput): string | null { if (input.loggedOut) return "自动化浏览器已登出 ChatGPT,需要重新登录"; if (/\/auth\//.test(input.url) || input.url.includes("auth.openai.com")) { return "页面被重定向到登录页,会话已失效"; } const haystack = `${input.title}\n${input.bodyText}`; for (const { pattern, reason } of BLOCKER_PATTERNS) { if (pattern.test(haystack)) return `${reason}(匹配 ${pattern})`; } return null; } /** Which platform boundary this page state hits, if any. Same table as detectBlocker. */ export function detectPlatformBlock(input: BlockerInput): PlatformBlocker | null { if (input.loggedOut) return "AUTH_REQUIRED"; if (/\/auth\//.test(input.url) || input.url.includes("auth.openai.com")) return "AUTH_REQUIRED"; const haystack = `${input.title}\n${input.bodyText}`; for (const { pattern, block } of BLOCKER_PATTERNS) { if (pattern.test(haystack)) return block; } return null; } /** * User-facing wording for a stop (C16): fact + what BRB stopped + what the user can do. * * Never "正在尝试其他方法", never a hint that BRB will work around the boundary: the * boundary is the reason the operation stopped, not an obstacle to route around. */ export function platformBlockMessage(block: PlatformBlocker, action: string): string { const facts: Record = { CAPTCHA: "页面要求人机验证(CAPTCHA)", SECURITY_CHALLENGE: "账号被标记异常活动或要求安全验证", RATE_LIMIT: "当前请求过于频繁或服务暂时受限", USAGE_LIMIT: "当前使用额度已达上限", AUTH_REQUIRED: "登录态已失效,需要重新登录", }; return ( `ChatGPT 当前状态:${facts[block]}。` + `BRB 已停止自动${action},也不会自动重试或绕过该限制。` + "请在浏览器中处理页面提示(等待限制解除 / 完成验证 / 重新登录)后,重新运行 /brb status 确认状态。" ); } /** One-line status for /brb guard. */ export function describeGuard(status: GuardStatus): string { const next = status.nextAllowedInMs > 0 ? `,还需等 ${Math.round(status.nextAllowedInMs / 1000)}s` : ",可立即发送"; return ( `近一小时 ${status.sendsLastHour} 次(余 ${status.remainingHour})· ` + `今天 ${status.sendsToday} 次(余 ${status.remainingDay})· ` + `本次运行约 ${status.tokensThisSession} tokens(预算 ${status.sessionTokenBudget || "无限"})${next}` ); }