From 08caf7b9fc50890a2f99b410051f1df9aee22b70 Mon Sep 17 00:00:00 2001 From: LK Date: Thu, 8 Jan 2026 13:41:32 +0100 Subject: [PATCH 01/13] feat(commands): add /usage alias for /status --- docs/cli/index.md | 2 +- docs/tools/slash-commands.md | 23 ++--------------------- src/auto-reply/command-detection.test.ts | 2 ++ src/auto-reply/commands-registry.test.ts | 2 ++ src/auto-reply/commands-registry.ts | 2 +- 5 files changed, 8 insertions(+), 23 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index 272a3a64f..5abfdf62c 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -377,7 +377,7 @@ Options: Clawdbot can surface provider usage/quota when OAuth/API creds are available. Surfaces: -- `/status` (adds a short usage line when available) +- `/status` (alias: `/usage`; adds a short usage line when available) - `clawdbot status --usage` (prints full provider breakdown) - macOS menu bar (Usage section under Context) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index f35c0db70..d8e29444f 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -35,8 +35,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` -- `/status` -- `/debug show|set|unset|reset` (runtime overrides, owner-only) +- `/status` (alias: `/usage`) - `/cost on|off` (toggle per-response usage line) - `/stop` - `/restart` @@ -47,7 +46,7 @@ Text + native (when enabled): - `/verbose on|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only) - `/elevated on|off` (alias: `/elev`) -- `/model ` (or `/` from `agents.defaults.models.*.alias`) +- `/model ` (or `/` from `agent.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) Text-only: @@ -60,24 +59,6 @@ Notes: - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. -## Debug overrides - -`/debug` lets you set **runtime-only** config overrides (memory, not disk). Owner-only. - -Examples: - -``` -/debug show -/debug set messages.responsePrefix="[clawdbot]" -/debug set whatsapp.allowFrom=["+1555","+4477"] -/debug unset messages.responsePrefix -/debug reset -``` - -Notes: -- Overrides apply immediately to new config reads, but do **not** write to `clawdbot.json`. -- Use `/debug reset` to clear all overrides and return to the on-disk config. - ## Surface notes - **Text commands** run in the normal chat session (DMs share `main`, groups have their own session). diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index e8a14a898..fe8ec2f92 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -44,6 +44,8 @@ describe("control command parsing", () => { expect(hasControlCommand("help")).toBe(false); expect(hasControlCommand("/status")).toBe(true); expect(hasControlCommand("/status:")).toBe(true); + expect(hasControlCommand("/usage")).toBe(true); + expect(hasControlCommand("/usage:")).toBe(true); expect(hasControlCommand("status")).toBe(false); }); diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index fc270ab08..3d0a8eae6 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -24,6 +24,8 @@ describe("commands registry", () => { expect(detection.exact.has("/help")).toBe(true); expect(detection.regex.test("/status")).toBe(true); expect(detection.regex.test("/status:")).toBe(true); + expect(detection.regex.test("/usage")).toBe(true); + expect(detection.regex.test("/usage:")).toBe(true); expect(detection.regex.test("/stop")).toBe(true); expect(detection.regex.test("/send:")).toBe(true); expect(detection.regex.test("/debug set foo=bar")).toBe(true); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 4f3ec4ef4..bbdda249f 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -25,7 +25,7 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [ key: "status", nativeName: "status", description: "Show current status.", - textAliases: ["/status"], + textAliases: ["/status", "/usage"], }, { key: "debug", From 613d866296ef5c1e6e488bb215dadde8b43888af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 16:02:58 +0000 Subject: [PATCH 02/13] chore(docker): pass claude web session env --- docker-compose.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 1f014d640..7970084fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ services: HOME: /home/node TERM: xterm-256color CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN} + CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY} + CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY} + CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE} volumes: - ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot - ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd @@ -30,6 +33,9 @@ services: HOME: /home/node TERM: xterm-256color BROWSER: echo + CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY} + CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY} + CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE} volumes: - ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot - ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd From 68ad27e31c9b0bc66f1091eeffb84a6d6a475863 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:10:53 +0100 Subject: [PATCH 03/13] fix(commands): wire /usage to status (#492) (thanks @lc0rp) --- CHANGELOG.md | 1 + docs/tools/slash-commands.md | 21 +++++++++++++++++++- src/auto-reply/reply.directive.parse.test.ts | 6 ++++++ src/auto-reply/reply.triggers.test.ts | 17 ++++++++++++++++ src/auto-reply/reply/commands.ts | 3 ++- src/auto-reply/reply/directives.ts | 2 +- 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 783575b69..3318ae020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro - Commands: accept /models as an alias for /model. +- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp - Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete - Debugging: add raw model stream logging flags and document gateway watch mode. - Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index d8e29444f..33e3fff6b 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -36,6 +36,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` - `/status` (alias: `/usage`) +- `/debug show|set|unset|reset` (runtime overrides, owner-only) - `/cost on|off` (toggle per-response usage line) - `/stop` - `/restart` @@ -46,7 +47,7 @@ Text + native (when enabled): - `/verbose on|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only) - `/elevated on|off` (alias: `/elev`) -- `/model ` (or `/` from `agent.models.*.alias`) +- `/model ` (or `/` from `agents.defaults.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) Text-only: @@ -59,6 +60,24 @@ Notes: - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. +## Debug overrides + +`/debug` lets you set **runtime-only** config overrides (memory, not disk). Owner-only. + +Examples: + +``` +/debug show +/debug set messages.responsePrefix="[clawdbot]" +/debug set whatsapp.allowFrom=["+1555","+4477"] +/debug unset messages.responsePrefix +/debug reset +``` + +Notes: +- Overrides apply immediately to new config reads, but do **not** write to `clawdbot.json`. +- Use `/debug reset` to clear all overrides and return to the on-disk config. + ## Surface notes - **Text commands** run in the normal chat session (DMs share `main`, groups have their own session). diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index e3a2fecfb..09785796e 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -144,6 +144,12 @@ describe("directive parsing", () => { expect(res.cleaned).toBe("thats not /tmp/hello"); }); + it("preserves spacing when stripping usage directives before paths", () => { + const res = extractStatusDirective("thats not /usage:/tmp/hello"); + expect(res.hasDirective).toBe(true); + expect(res.cleaned).toBe("thats not /tmp/hello"); + }); + it("parses queue options and modes", () => { const res = extractQueueDirective( "please /queue steer+backlog debounce:2s cap:5 drop:summarize now", diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 38f60125d..ae8154278 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -242,6 +242,23 @@ describe("trigger handling", () => { }); }); + it("reports status via /usage without invoking the agent", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/usage", + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("ClawdBot"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("reports active auth profile and key snippet in status", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 7299a846d..1d2ba748b 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -594,7 +594,8 @@ export async function handleCommands(params: { const statusRequested = directives.hasStatusDirective || - command.commandBodyNormalized === "/status"; + command.commandBodyNormalized === "/status" || + command.commandBodyNormalized === "/usage"; if (allowTextCommands && statusRequested) { const reply = await buildStatusReply({ cfg, diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index d04180411..c6f431b30 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -170,7 +170,7 @@ export function extractStatusDirective(body?: string): { hasDirective: boolean; } { if (!body) return { cleaned: "", hasDirective: false }; - return extractSimpleDirective(body, ["status"]); + return extractSimpleDirective(body, ["status", "usage"]); } export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; From f4ec53dcb94fa6129887cc80ff41554aea8fb67d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:13:23 +0100 Subject: [PATCH 04/13] refactor(sessions): dedupe sessions.resolve --- src/gateway/server-bridge.ts | 86 ++------------------ src/gateway/server-methods/sessions.ts | 103 ++---------------------- src/gateway/sessions-resolve.ts | 107 +++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 177 deletions(-) create mode 100644 src/gateway/sessions-resolve.ts diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index b2df674e6..85147d62e 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -80,6 +80,7 @@ import { type SessionsPatchResult, } from "./session-utils.js"; import { applySessionsPatchToStore } from "./sessions-patch.js"; +import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js"; import { formatForLog } from "./ws-log.js"; export type BridgeHandlersContext = { @@ -314,93 +315,20 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { const p = params as SessionsResolveParams; const cfg = loadConfig(); - - const key = typeof p.key === "string" ? p.key.trim() : ""; - const label = typeof p.label === "string" ? p.label.trim() : ""; - const hasKey = key.length > 0; - const hasLabel = label.length > 0; - if (hasKey && hasLabel) { + const resolved = resolveSessionKeyFromResolveParams({ cfg, p }); + if (!resolved.ok) { return { ok: false, error: { - code: ErrorCodes.INVALID_REQUEST, - message: "Provide either key or label (not both)", - }, - }; - } - if (!hasKey && !hasLabel) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "Either key or label is required", - }, - }; - } - - if (hasKey) { - const target = resolveGatewaySessionStoreTarget({ cfg, key }); - const store = loadSessionStore(target.storePath); - const existingKey = target.storeKeys.find( - (candidate) => store[candidate], - ); - if (!existingKey) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `No session found: ${key}`, - }, - }; - } - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - key: target.canonicalKey, - }), - }; - } - - const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); - const list = listSessionsFromStore({ - cfg, - storePath, - store, - opts: { - includeGlobal: p.includeGlobal === true, - includeUnknown: p.includeUnknown === true, - label, - agentId: p.agentId, - spawnedBy: p.spawnedBy, - limit: 2, - }, - }); - if (list.sessions.length === 0) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `No session found with label: ${label}`, - }, - }; - } - if (list.sessions.length > 1) { - const keys = list.sessions.map((s) => s.key).join(", "); - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `Multiple sessions found with label: ${label} (${keys})`, + code: resolved.error.code, + message: resolved.error.message, + details: resolved.error.details, }, }; } return { ok: true, - payloadJSON: JSON.stringify({ - ok: true, - key: list.sessions[0]?.key, - }), + payloadJSON: JSON.stringify({ ok: true, key: resolved.key }), }; } case "sessions.patch": { diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index ffde2e839..ba95cb1e9 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -35,6 +35,7 @@ import { type SessionsPatchResult, } from "../session-utils.js"; import { applySessionsPatchToStore } from "../sessions-patch.js"; +import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; import type { GatewayRequestHandlers } from "./types.js"; export const sessionsHandlers: GatewayRequestHandlers = { @@ -76,106 +77,12 @@ export const sessionsHandlers: GatewayRequestHandlers = { const p = params as import("../protocol/index.js").SessionsResolveParams; const cfg = loadConfig(); - const key = typeof p.key === "string" ? p.key.trim() : ""; - const label = typeof p.label === "string" ? p.label.trim() : ""; - const hasKey = key.length > 0; - const hasLabel = label.length > 0; - if (hasKey && hasLabel) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "Provide either key or label (not both)", - ), - ); + const resolved = resolveSessionKeyFromResolveParams({ cfg, p }); + if (!resolved.ok) { + respond(false, undefined, resolved.error); return; } - if (!hasKey && !hasLabel) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "Either key or label is required", - ), - ); - return; - } - - if (hasKey) { - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - return; - } - const target = resolveGatewaySessionStoreTarget({ cfg, key }); - const store = loadSessionStore(target.storePath); - const existingKey = target.storeKeys.find( - (candidate) => store[candidate], - ); - if (!existingKey) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`), - ); - return; - } - respond(true, { ok: true, key: target.canonicalKey }, undefined); - return; - } - - if (!label) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "label required"), - ); - return; - } - - const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); - const list = listSessionsFromStore({ - cfg, - storePath, - store, - opts: { - includeGlobal: p.includeGlobal === true, - includeUnknown: p.includeUnknown === true, - label, - agentId: p.agentId, - spawnedBy: p.spawnedBy, - limit: 2, - }, - }); - if (list.sessions.length === 0) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `No session found with label: ${label}`, - ), - ); - return; - } - if (list.sessions.length > 1) { - const keys = list.sessions.map((s) => s.key).join(", "); - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Multiple sessions found with label: ${label} (${keys})`, - ), - ); - return; - } - respond(true, { ok: true, key: list.sessions[0]?.key }, undefined); + respond(true, { ok: true, key: resolved.key }, undefined); }, "sessions.patch": async ({ params, respond, context }) => { if (!validateSessionsPatchParams(params)) { diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts new file mode 100644 index 000000000..5f6fa05b7 --- /dev/null +++ b/src/gateway/sessions-resolve.ts @@ -0,0 +1,107 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { loadSessionStore } from "../config/sessions.js"; +import { parseSessionLabel } from "../sessions/session-label.js"; +import { + ErrorCodes, + type ErrorShape, + errorShape, + type SessionsResolveParams, +} from "./protocol/index.js"; +import { + listSessionsFromStore, + loadCombinedSessionStoreForGateway, + resolveGatewaySessionStoreTarget, +} from "./session-utils.js"; + +export type SessionsResolveResult = + | { ok: true; key: string } + | { ok: false; error: ErrorShape }; + +export function resolveSessionKeyFromResolveParams(params: { + cfg: ClawdbotConfig; + p: SessionsResolveParams; +}): SessionsResolveResult { + const { cfg, p } = params; + + const key = typeof p.key === "string" ? p.key.trim() : ""; + const hasKey = key.length > 0; + const hasLabel = typeof p.label === "string" && p.label.trim().length > 0; + if (hasKey && hasLabel) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "Provide either key or label (not both)", + ), + }; + } + if (!hasKey && !hasLabel) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "Either key or label is required", + ), + }; + } + + if (hasKey) { + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const store = loadSessionStore(target.storePath); + const existingKey = target.storeKeys.find((candidate) => store[candidate]); + if (!existingKey) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `No session found: ${key}`, + ), + }; + } + return { ok: true, key: target.canonicalKey }; + } + + const parsedLabel = parseSessionLabel(p.label); + if (!parsedLabel.ok) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, parsedLabel.error), + }; + } + + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const list = listSessionsFromStore({ + cfg, + storePath, + store, + opts: { + includeGlobal: p.includeGlobal === true, + includeUnknown: p.includeUnknown === true, + label: parsedLabel.label, + agentId: p.agentId, + spawnedBy: p.spawnedBy, + limit: 2, + }, + }); + if (list.sessions.length === 0) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `No session found with label: ${parsedLabel.label}`, + ), + }; + } + if (list.sessions.length > 1) { + const keys = list.sessions.map((s) => s.key).join(", "); + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + `Multiple sessions found with label: ${parsedLabel.label} (${keys})`, + ), + }; + } + + return { ok: true, key: String(list.sessions[0]?.key ?? "") }; +} From d372fac9c64c98e68a1a60f068a0c06c22be60b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:14:36 +0100 Subject: [PATCH 05/13] refactor: streamline reply tag parsing --- src/agents/system-prompt.ts | 1 + src/auto-reply/reply.directive.test.ts | 36 +++++++++++++++++++ src/auto-reply/reply/reply-tags.ts | 49 +++++++++++++++----------- 3 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index df003eaf7..9ec850872 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -222,6 +222,7 @@ export function buildAgentSystemPrompt(params: { "To request a native reply/quote on supported surfaces, include one tag in your reply:", "- [[reply_to_current]] replies to the triggering message.", "- [[reply_to:]] replies to a specific message id when you have it.", + "Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).", "Tags are stripped before sending; support depends on the current provider config.", "", "## Messaging", diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index b494c1057..0b555e9bf 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -249,6 +249,42 @@ describe("directive behavior", () => { }); }); + it("strips reply tags with whitespace and maps reply_to_current to MessageSid", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "hello [[ reply_to_current ]]" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "ping", + From: "+1004", + To: "+2000", + MessageSid: "msg-123", + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + }, + whatsapp: { allowFrom: ["*"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const payload = Array.isArray(res) ? res[0] : res; + expect(payload?.text).toBe("hello"); + expect(payload?.replyToId).toBe("msg-123"); + }); + }); + it("prefers explicit reply_to id over reply_to_current", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply/reply-tags.ts b/src/auto-reply/reply/reply-tags.ts index ef2de524a..9e81affcb 100644 --- a/src/auto-reply/reply/reply-tags.ts +++ b/src/auto-reply/reply/reply-tags.ts @@ -1,3 +1,13 @@ +const REPLY_TAG_RE = + /\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi; + +function normalizeReplyText(text: string) { + return text + .replace(/[ \t]+/g, " ") + .replace(/[ \t]*\n[ \t]*/g, "\n") + .trim(); +} + export function extractReplyToTag( text?: string, currentMessageId?: string, @@ -7,29 +17,28 @@ export function extractReplyToTag( hasTag: boolean; } { if (!text) return { cleaned: "", hasTag: false }; - let cleaned = text; - let replyToId: string | undefined; + + let sawCurrent = false; + let lastExplicitId: string | undefined; let hasTag = false; - const currentMatch = cleaned.match(/\[\[\s*reply_to_current\s*\]\]/i); - if (currentMatch) { - cleaned = cleaned.replace(/\[\[\s*reply_to_current\s*\]\]/gi, " "); - hasTag = true; - if (currentMessageId?.trim()) { - replyToId = currentMessageId.trim(); - } - } + const cleaned = normalizeReplyText( + text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => { + hasTag = true; + if (idRaw === undefined) { + sawCurrent = true; + return " "; + } - const idMatch = cleaned.match(/\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]/i); - if (idMatch?.[1]) { - cleaned = cleaned.replace(/\[\[\s*reply_to\s*:\s*[^\]\n]+\s*\]\]/gi, " "); - replyToId = idMatch[1].trim(); - hasTag = true; - } + const id = idRaw.trim(); + if (id) lastExplicitId = id; + return " "; + }), + ); + + const replyToId = + lastExplicitId ?? + (sawCurrent ? currentMessageId?.trim() || undefined : undefined); - cleaned = cleaned - .replace(/[ \t]+/g, " ") - .replace(/[ \t]*\n[ \t]*/g, "\n") - .trim(); return { cleaned, replyToId, hasTag }; } From 1838582546d62773b0cdf8f5174b061b9f59266c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:14:33 +0100 Subject: [PATCH 06/13] refactor(auto-reply): centralize chat command aliases --- src/auto-reply/command-detection.test.ts | 16 +- src/auto-reply/commands-registry.test.ts | 26 ++- src/auto-reply/commands-registry.ts | 258 +++++++++++++---------- 3 files changed, 175 insertions(+), 125 deletions(-) diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index fe8ec2f92..b43bbed1b 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { hasControlCommand } from "./command-detection.js"; +import { listChatCommands } from "./commands-registry.js"; import { parseActivationCommand } from "./group-activation.js"; import { parseSendPolicyCommand } from "./send-policy.js"; @@ -37,16 +38,17 @@ describe("control command parsing", () => { }); it("treats bare commands as non-control", () => { - expect(hasControlCommand("/send")).toBe(true); expect(hasControlCommand("send")).toBe(false); - expect(hasControlCommand("/help")).toBe(true); - expect(hasControlCommand("/help:")).toBe(true); expect(hasControlCommand("help")).toBe(false); - expect(hasControlCommand("/status")).toBe(true); - expect(hasControlCommand("/status:")).toBe(true); - expect(hasControlCommand("/usage")).toBe(true); - expect(hasControlCommand("/usage:")).toBe(true); expect(hasControlCommand("status")).toBe(false); + expect(hasControlCommand("usage")).toBe(false); + + for (const command of listChatCommands()) { + for (const alias of command.textAliases) { + expect(hasControlCommand(alias)).toBe(true); + expect(hasControlCommand(`${alias}:`)).toBe(true); + } + } }); it("requires commands to be the full message", () => { diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 3d0a8eae6..9952af7eb 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildCommandText, getCommandDetection, + listChatCommands, listNativeCommandSpecs, shouldHandleTextCommands, } from "./commands-registry.js"; @@ -21,16 +22,21 @@ describe("commands registry", () => { it("detects known text commands", () => { const detection = getCommandDetection(); - expect(detection.exact.has("/help")).toBe(true); - expect(detection.regex.test("/status")).toBe(true); - expect(detection.regex.test("/status:")).toBe(true); - expect(detection.regex.test("/usage")).toBe(true); - expect(detection.regex.test("/usage:")).toBe(true); - expect(detection.regex.test("/stop")).toBe(true); - expect(detection.regex.test("/send:")).toBe(true); - expect(detection.regex.test("/debug set foo=bar")).toBe(true); - expect(detection.regex.test("/models")).toBe(true); - expect(detection.regex.test("/models list")).toBe(true); + for (const command of listChatCommands()) { + for (const alias of command.textAliases) { + expect(detection.exact.has(alias.toLowerCase())).toBe(true); + expect(detection.regex.test(alias)).toBe(true); + expect(detection.regex.test(`${alias}:`)).toBe(true); + + if (command.acceptsArgs) { + expect(detection.regex.test(`${alias} list`)).toBe(true); + expect(detection.regex.test(`${alias}: list`)).toBe(true); + } else { + expect(detection.regex.test(`${alias} list`)).toBe(false); + expect(detection.regex.test(`${alias}: list`)).toBe(false); + } + } + } expect(detection.regex.test("try /status")).toBe(false); }); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index bbdda249f..27f03c15f 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -14,114 +14,156 @@ export type NativeCommandSpec = { acceptsArgs: boolean; }; -const CHAT_COMMANDS: ChatCommandDefinition[] = [ - { - key: "help", - nativeName: "help", - description: "Show available commands.", - textAliases: ["/help"], - }, - { - key: "status", - nativeName: "status", - description: "Show current status.", - textAliases: ["/status", "/usage"], - }, - { - key: "debug", - nativeName: "debug", - description: "Set runtime debug overrides.", - textAliases: ["/debug"], - acceptsArgs: true, - }, - { - key: "cost", - nativeName: "cost", - description: "Toggle per-response usage line.", - textAliases: ["/cost"], - acceptsArgs: true, - }, - { - key: "stop", - nativeName: "stop", - description: "Stop the current run.", - textAliases: ["/stop"], - }, - { - key: "restart", - nativeName: "restart", - description: "Restart Clawdbot.", - textAliases: ["/restart"], - }, - { - key: "activation", - nativeName: "activation", - description: "Set group activation mode.", - textAliases: ["/activation"], - acceptsArgs: true, - }, - { - key: "send", - nativeName: "send", - description: "Set send policy.", - textAliases: ["/send"], - acceptsArgs: true, - }, - { - key: "reset", - nativeName: "reset", - description: "Reset the current session.", - textAliases: ["/reset"], - }, - { - key: "new", - nativeName: "new", - description: "Start a new session.", - textAliases: ["/new"], - }, - { - key: "think", - nativeName: "think", - description: "Set thinking level.", - textAliases: ["/thinking", "/think", "/t"], - acceptsArgs: true, - }, - { - key: "verbose", - nativeName: "verbose", - description: "Toggle verbose mode.", - textAliases: ["/verbose", "/v"], - acceptsArgs: true, - }, - { - key: "reasoning", - nativeName: "reasoning", - description: "Toggle reasoning visibility.", - textAliases: ["/reasoning", "/reason"], - acceptsArgs: true, - }, - { - key: "elevated", - nativeName: "elevated", - description: "Toggle elevated mode.", - textAliases: ["/elevated", "/elev"], - acceptsArgs: true, - }, - { - key: "model", - nativeName: "model", - description: "Show or set the model.", - textAliases: ["/model", "/models"], - acceptsArgs: true, - }, - { - key: "queue", - nativeName: "queue", - description: "Adjust queue settings.", - textAliases: ["/queue"], - acceptsArgs: true, - }, -]; +function defineChatCommand( + command: Omit & { textAlias: string }, +): ChatCommandDefinition { + return { + key: command.key, + nativeName: command.nativeName, + description: command.description, + acceptsArgs: command.acceptsArgs, + textAliases: [command.textAlias], + }; +} + +function registerAlias( + commands: ChatCommandDefinition[], + key: string, + ...aliases: string[] +): void { + const command = commands.find((entry) => entry.key === key); + if (!command) { + throw new Error(`registerAlias: unknown command key: ${key}`); + } + const existing = new Set(command.textAliases.map((alias) => alias.trim())); + for (const alias of aliases) { + const trimmed = alias.trim(); + if (!trimmed) continue; + if (existing.has(trimmed)) continue; + existing.add(trimmed); + command.textAliases.push(trimmed); + } +} + +export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { + const commands: ChatCommandDefinition[] = [ + defineChatCommand({ + key: "help", + nativeName: "help", + description: "Show available commands.", + textAlias: "/help", + }), + defineChatCommand({ + key: "status", + nativeName: "status", + description: "Show current status.", + textAlias: "/status", + }), + defineChatCommand({ + key: "debug", + nativeName: "debug", + description: "Set runtime debug overrides.", + textAlias: "/debug", + acceptsArgs: true, + }), + defineChatCommand({ + key: "cost", + nativeName: "cost", + description: "Toggle per-response usage line.", + textAlias: "/cost", + acceptsArgs: true, + }), + defineChatCommand({ + key: "stop", + nativeName: "stop", + description: "Stop the current run.", + textAlias: "/stop", + }), + defineChatCommand({ + key: "restart", + nativeName: "restart", + description: "Restart Clawdbot.", + textAlias: "/restart", + }), + defineChatCommand({ + key: "activation", + nativeName: "activation", + description: "Set group activation mode.", + textAlias: "/activation", + acceptsArgs: true, + }), + defineChatCommand({ + key: "send", + nativeName: "send", + description: "Set send policy.", + textAlias: "/send", + acceptsArgs: true, + }), + defineChatCommand({ + key: "reset", + nativeName: "reset", + description: "Reset the current session.", + textAlias: "/reset", + }), + defineChatCommand({ + key: "new", + nativeName: "new", + description: "Start a new session.", + textAlias: "/new", + }), + defineChatCommand({ + key: "think", + nativeName: "think", + description: "Set thinking level.", + textAlias: "/think", + acceptsArgs: true, + }), + defineChatCommand({ + key: "verbose", + nativeName: "verbose", + description: "Toggle verbose mode.", + textAlias: "/verbose", + acceptsArgs: true, + }), + defineChatCommand({ + key: "reasoning", + nativeName: "reasoning", + description: "Toggle reasoning visibility.", + textAlias: "/reasoning", + acceptsArgs: true, + }), + defineChatCommand({ + key: "elevated", + nativeName: "elevated", + description: "Toggle elevated mode.", + textAlias: "/elevated", + acceptsArgs: true, + }), + defineChatCommand({ + key: "model", + nativeName: "model", + description: "Show or set the model.", + textAlias: "/model", + acceptsArgs: true, + }), + defineChatCommand({ + key: "queue", + nativeName: "queue", + description: "Adjust queue settings.", + textAlias: "/queue", + acceptsArgs: true, + }), + ]; + + registerAlias(commands, "status", "/usage"); + registerAlias(commands, "think", "/thinking", "/t"); + registerAlias(commands, "verbose", "/v"); + registerAlias(commands, "reasoning", "/reason"); + registerAlias(commands, "elevated", "/elev"); + registerAlias(commands, "model", "/models"); + + return commands; +})(); const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); From c82ebd3ef3feb9a7b12f75ca46fb2015f5b1f4ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:14:35 +0100 Subject: [PATCH 07/13] docs(commands): document /usage slash command --- docs/tools/slash-commands.md | 6 ++++-- src/docs/slash-commands-doc.test.ts | 32 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/docs/slash-commands-doc.test.ts diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 33e3fff6b..a69bf194d 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -35,7 +35,8 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` -- `/status` (alias: `/usage`) +- `/status` (show current status; includes a short usage line when available) +- `/usage` (alias: `/status`) - `/debug show|set|unset|reset` (runtime overrides, owner-only) - `/cost on|off` (toggle per-response usage line) - `/stop` @@ -47,7 +48,7 @@ Text + native (when enabled): - `/verbose on|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only) - `/elevated on|off` (alias: `/elev`) -- `/model ` (or `/` from `agents.defaults.models.*.alias`) +- `/model ` (alias: `/models`; or `/` from `agents.defaults.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) Text-only: @@ -55,6 +56,7 @@ Text-only: Notes: - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). +- `/status` and `/usage` show the same status output; for full provider usage breakdown, use `clawdbot status --usage`. - `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost). - `/restart` is disabled by default; set `commands.restart: true` to enable it. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. diff --git a/src/docs/slash-commands-doc.test.ts b/src/docs/slash-commands-doc.test.ts new file mode 100644 index 000000000..30a7f620e --- /dev/null +++ b/src/docs/slash-commands-doc.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { listChatCommands } from "../auto-reply/commands-registry.js"; + +function extractDocumentedSlashCommands(markdown: string): Set { + const documented = new Set(); + for (const match of markdown.matchAll(/`\/(?!<)([a-z0-9_-]+)/gi)) { + documented.add(`/${match[1]}`); + } + return documented; +} + +describe("slash commands docs", () => { + it("documents all built-in chat command aliases", async () => { + const docPath = path.join( + process.cwd(), + "docs", + "tools", + "slash-commands.md", + ); + const markdown = await fs.readFile(docPath, "utf8"); + const documented = extractDocumentedSlashCommands(markdown); + + for (const command of listChatCommands()) { + for (const alias of command.textAliases) { + expect(documented.has(alias)).toBe(true); + } + } + }); +}); From 1478473537fe1bf696bd3ff868ff2f3d2bd99ef8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:21:25 +0100 Subject: [PATCH 08/13] refactor(commands): canonicalize text command aliases --- src/auto-reply/commands-registry.ts | 51 ++++++++++++++++++++++++--- src/auto-reply/reply.triggers.test.ts | 1 + src/auto-reply/reply/commands.ts | 3 +- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 27f03c15f..63cf44a7d 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -167,6 +167,27 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); +type TextAliasSpec = { + canonical: string; + acceptsArgs: boolean; +}; + +const TEXT_ALIAS_MAP: Map = (() => { + const map = new Map(); + for (const command of CHAT_COMMANDS) { + const canonical = `/${command.key}`; + const acceptsArgs = Boolean(command.acceptsArgs); + for (const alias of command.textAliases) { + const normalized = alias.trim().toLowerCase(); + if (!normalized) continue; + if (!map.has(normalized)) { + map.set(normalized, { canonical, acceptsArgs }); + } + } + } + return map; +})(); + let cachedDetection: | { exact: Set; @@ -207,11 +228,31 @@ export function buildCommandText(commandName: string, args?: string): string { export function normalizeCommandBody(raw: string): string { const trimmed = raw.trim(); if (!trimmed.startsWith("/")) return trimmed; - const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/); - if (!match) return trimmed; - const [, command, rest] = match; - const normalizedRest = rest.trimStart(); - return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`; + + const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/); + const normalized = colonMatch + ? (() => { + const [, command, rest] = colonMatch; + const normalizedRest = rest.trimStart(); + return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`; + })() + : trimmed; + + const lowered = normalized.toLowerCase(); + const exact = TEXT_ALIAS_MAP.get(lowered); + if (exact) return exact.canonical; + + const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/); + if (!tokenMatch) return normalized; + const [, token, rest] = tokenMatch; + const tokenKey = `/${token.toLowerCase()}`; + const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey); + if (!tokenSpec) return normalized; + if (rest && !tokenSpec.acceptsArgs) return normalized; + const normalizedRest = rest?.trimStart(); + return normalizedRest + ? `${tokenSpec.canonical} ${normalizedRest}` + : tokenSpec.canonical; } export function getCommandDetection(): { exact: Set; regex: RegExp } { diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index ae8154278..9a06628f4 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -1257,6 +1257,7 @@ describe("trigger handling", () => { vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; expect(prompt).toContain("Give me the status"); expect(prompt).not.toContain("/thinking high"); + expect(prompt).not.toContain("/think high"); }); }); diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 1d2ba748b..7299a846d 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -594,8 +594,7 @@ export async function handleCommands(params: { const statusRequested = directives.hasStatusDirective || - command.commandBodyNormalized === "/status" || - command.commandBodyNormalized === "/usage"; + command.commandBodyNormalized === "/status"; if (allowTextCommands && statusRequested) { const reply = await buildStatusReply({ cfg, From ee70a1d1fbd5c39b22d29a7407f24d21d7f51f48 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 16:17:16 +0000 Subject: [PATCH 09/13] fix(status): use claude-cli token for usage --- CHANGELOG.md | 2 +- scripts/debug-claude-usage.ts | 76 +++++++++++++++++++++++------- src/infra/provider-usage.test.ts | 79 ++++++++++++++++++++++++++++++++ src/infra/provider-usage.ts | 12 ++++- 4 files changed, 151 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a19d24c3..569a3f1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,7 +90,7 @@ - Status: show Verbose/Elevated only when enabled. - Status: filter usage summary to the active model provider. - Status: map model providers to usage sources so unrelated usage doesn’t appear. -- Status: allow Claude usage snapshot fallback via claude.ai session cookie (`CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`) when OAuth token lacks `user:profile`. +- Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`. - Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated. - Commands: keep multi-directive messages from clearing directive handling. - Commands: warn when /elevated runs in direct (unsandboxed) runtime. diff --git a/scripts/debug-claude-usage.ts b/scripts/debug-claude-usage.ts index 462465c78..69a951853 100644 --- a/scripts/debug-claude-usage.ts +++ b/scripts/debug-claude-usage.ts @@ -53,16 +53,17 @@ const loadAuthProfiles = (agentId: string) => { return { authPath, store }; }; -const pickAnthropicToken = (store: { +const pickAnthropicTokens = (store: { profiles?: Record; -}): { profileId: string; token: string } | null => { +}): Array<{ profileId: string; token: string }> => { const profiles = store.profiles ?? {}; + const found: Array<{ profileId: string; token: string }> = []; for (const [id, cred] of Object.entries(profiles)) { if (cred?.provider !== "anthropic") continue; const token = cred.type === "token" ? cred.token?.trim() : undefined; - if (token) return { profileId: id, token }; + if (token) found.push({ profileId: id, token }); } - return null; + return found; }; const fetchAnthropicOAuthUsage = async (token: string) => { @@ -79,6 +80,34 @@ const fetchAnthropicOAuthUsage = async (token: string) => { return { status: res.status, contentType: res.headers.get("content-type"), text }; }; +const readClaudeCliKeychain = (): { + accessToken: string; + expiresAt?: number; + scopes?: string[]; +} | null => { + if (process.platform !== "darwin") return null; + try { + const raw = execFileSync( + "security", + ["find-generic-password", "-s", "Claude Code-credentials", "-w"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ); + const parsed = JSON.parse(raw.trim()) as Record; + const oauth = parsed?.claudeAiOauth as Record | undefined; + if (!oauth || typeof oauth !== "object") return null; + const accessToken = oauth.accessToken; + if (typeof accessToken !== "string" || !accessToken.trim()) return null; + const expiresAt = + typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined; + const scopes = Array.isArray(oauth.scopes) + ? oauth.scopes.filter((v): v is string => typeof v === "string") + : undefined; + return { accessToken, expiresAt, scopes }; + } catch { + return null; + } +}; + const chromeServiceNameForPath = (cookiePath: string): string => { if (cookiePath.includes("/Arc/")) return "Arc Safe Storage"; if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage"; @@ -251,19 +280,34 @@ const main = async () => { const { authPath, store } = loadAuthProfiles(opts.agentId); console.log(`Auth file: ${authPath}`); - const anthropic = pickAnthropicToken(store); - if (!anthropic) { - console.log("Anthropic: no token profiles found in auth-profiles.json"); + const keychain = readClaudeCliKeychain(); + if (keychain) { + console.log( + `Claude CLI keychain: accessToken=${opts.reveal ? keychain.accessToken : mask(keychain.accessToken)} scopes=${keychain.scopes?.join(",") ?? "(unknown)"}`, + ); + const oauth = await fetchAnthropicOAuthUsage(keychain.accessToken); + console.log( + `OAuth usage (keychain): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, + ); + console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim()); } else { - console.log( - `Anthropic: ${anthropic.profileId} token=${opts.reveal ? anthropic.token : mask(anthropic.token)}`, - ); - const oauth = await fetchAnthropicOAuthUsage(anthropic.token); - console.log( - `OAuth usage: HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, - ); - console.log(oauth.text.slice(0, 400).replace(/\s+/g, " ").trim()); - console.log(""); + console.log("Claude CLI keychain: missing/unreadable"); + } + + const anthropic = pickAnthropicTokens(store); + if (anthropic.length === 0) { + console.log("Auth profiles: no Anthropic token profiles found"); + } else { + for (const entry of anthropic) { + console.log( + `Auth profiles: ${entry.profileId} token=${opts.reveal ? entry.token : mask(entry.token)}`, + ); + const oauth = await fetchAnthropicOAuthUsage(entry.token); + console.log( + `OAuth usage (${entry.profileId}): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, + ); + console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim()); + } } const sessionKey = diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index 8c0719b84..91ef4d675 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -227,6 +227,85 @@ describe("provider usage loading", () => { ); }); + it("prefers claude-cli token for Anthropic usage snapshots", async () => { + await withTempHome( + async () => { + const stateDir = process.env.CLAWDBOT_STATE_DIR; + if (!stateDir) throw new Error("Missing CLAWDBOT_STATE_DIR"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "token-default", + expires: Date.UTC(2100, 0, 1, 0, 0, 0), + }, + "anthropic:claude-cli": { + type: "token", + provider: "anthropic", + token: "token-cli", + expires: Date.UTC(2100, 0, 1, 0, 0, 0), + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const makeResponse = (status: number, body: unknown): Response => { + const payload = + typeof body === "string" ? body : JSON.stringify(body); + const headers = + typeof body === "string" + ? undefined + : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; + + const mockFetch = vi.fn< + Parameters, + ReturnType + >(async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.includes("api.anthropic.com/api/oauth/usage")) { + const headers = (init?.headers ?? {}) as Record; + expect(headers.Authorization).toBe("Bearer token-cli"); + return makeResponse(200, { + five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + providers: ["anthropic"], + agentDir, + fetch: mockFetch, + }); + + expect(summary.providers).toHaveLength(1); + expect(summary.providers[0]?.provider).toBe("anthropic"); + expect(summary.providers[0]?.windows[0]?.label).toBe("5h"); + expect(mockFetch).toHaveBeenCalled(); + }, + { prefix: "clawdbot-provider-usage-" }, + ); + }); + it("falls back to claude.ai web usage when OAuth scope is missing", async () => { const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY; process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1"; diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index dfef8ab36..9e081dbd9 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { + CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, resolveApiKeyForProfile, @@ -802,7 +803,16 @@ async function resolveOAuthToken(params: { provider: params.provider, }); - for (const profileId of order) { + // Claude CLI creds are the only Anthropic tokens that reliably include the + // `user:profile` scope required for the OAuth usage endpoint. + const candidates = + params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order; + const deduped: string[] = []; + for (const entry of candidates) { + if (!deduped.includes(entry)) deduped.push(entry); + } + + for (const profileId of deduped) { const cred = store.profiles[profileId]; if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue; try { From 6ef9fc64d7cc97df809fcb4e4a0e272365431864 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 9 Jan 2026 09:52:04 -0600 Subject: [PATCH 10/13] Discord: fix forum thread starters --- src/discord/monitor.tool-result.test.ts | 107 ++++++++++++++++++++++++ src/discord/monitor.ts | 55 ++++++++++-- 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 55317fb28..e85e8c4f0 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -1,6 +1,7 @@ import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Routes } from "discord-api-types/v10"; const sendMock = vi.fn(); const reactMock = vi.fn(); @@ -381,6 +382,112 @@ describe("discord tool result dispatch", () => { expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general"); }); + it("treats forum threads as distinct sessions without channel payloads", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + let capturedCtx: + | { + SessionKey?: string; + ParentSessionKey?: string; + ThreadStarterBody?: string; + ThreadLabel?: string; + } + | undefined; + dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { + capturedCtx = ctx; + dispatcher.sendFinalReply({ text: "hi" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + + const cfg = { + agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + session: { store: "/tmp/clawdbot-sessions.json" }, + discord: { + dm: { enabled: true, policy: "open" }, + guilds: { "*": { requireMention: false } }, + }, + routing: { allowFrom: [] }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: false } }, + }); + + const fetchChannel = vi + .fn() + .mockResolvedValueOnce({ + type: ChannelType.PublicThread, + name: "topic-1", + parentId: "forum-1", + }) + .mockResolvedValueOnce({ + type: ChannelType.GuildForum, + name: "support", + }); + const restGet = vi.fn().mockResolvedValue({ + content: "starter message", + author: { id: "u1", username: "Alice", discriminator: "0001" }, + timestamp: new Date().toISOString(), + }); + const client = { + fetchChannel, + rest: { + get: restGet, + }, + } as unknown as Client; + + await handler( + { + message: { + id: "m6", + content: "thread reply", + channelId: "t1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, + }, + author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, + member: { displayName: "Bob" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }, + client, + ); + + expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1"); + expect(capturedCtx?.ParentSessionKey).toBe( + "agent:main:discord:channel:forum-1", + ); + expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); + expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support"); + expect(restGet).toHaveBeenCalledWith( + Routes.channelMessage("t1", "t1"), + ); + }); + it("scopes thread sessions to the routed agent", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 052fd4839..bee9b5e38 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -139,14 +139,22 @@ async function resolveDiscordThreadStarter(params: { channel: DiscordThreadChannel; client: Client; parentId?: string; + parentType?: ChannelType; }): Promise { const cacheKey = params.channel.id; const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey); if (cached) return cached; try { - if (!params.parentId) return null; + const parentType = params.parentType; + const isForumParent = + parentType === ChannelType.GuildForum || + parentType === ChannelType.GuildMedia; + const messageChannelId = isForumParent + ? params.channel.id + : params.parentId; + if (!messageChannelId) return null; const starter = (await params.client.rest.get( - Routes.channelMessage(params.parentId, params.channel.id), + Routes.channelMessage(messageChannelId, params.channel.id), )) as { content?: string | null; embeds?: Array<{ description?: string | null }>; @@ -226,6 +234,14 @@ export type DiscordMessageHandler = ( client: Client, ) => Promise; +function isDiscordThreadType(type: ChannelType | undefined): boolean { + return ( + type === ChannelType.PublicThread || + type === ChannelType.PrivateThread || + type === ChannelType.AnnouncementThread + ); +} + export function resolveDiscordReplyTarget(opts: { replyToMode: ReplyToMode; replyToId?: string; @@ -666,12 +682,32 @@ export function createDiscordMessageHandler(params: { message.channel && "isThread" in message.channel && message.channel.isThread(); + const isThreadByType = + isGuildMessage && isDiscordThreadType(channelInfo?.type); const threadChannel = isThreadChannel ? (message.channel as DiscordThreadChannel) - : null; + : isThreadByType + ? { + id: message.channelId, + name: channelInfo?.name ?? undefined, + parentId: channelInfo?.parentId ?? undefined, + } + : null; const threadParentId = - threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined; - const threadParentName = threadChannel?.parent?.name; + threadChannel?.parentId ?? + threadChannel?.parent?.id ?? + channelInfo?.parentId ?? + undefined; + let threadParentName = threadChannel?.parent?.name; + let threadParentType: ChannelType | undefined; + if (threadChannel && threadParentId) { + const parentInfo = await resolveDiscordChannelInfo( + client, + threadParentId, + ); + threadParentName = threadParentName ?? parentInfo?.name; + threadParentType = parentInfo?.type; + } const threadName = threadChannel?.name; const configChannelName = threadParentName ?? channelName; const configChannelSlug = configChannelName @@ -935,6 +971,7 @@ export function createDiscordMessageHandler(params: { channel: threadChannel, client, parentId: threadParentId, + parentType: threadParentType, }); if (starter?.text) { const starterEnvelope = formatThreadStarterEnvelope({ @@ -1684,13 +1721,17 @@ async function deliverDiscordReply(params: { async function resolveDiscordChannelInfo( client: Client, channelId: string, -): Promise<{ type: ChannelType; name?: string; topic?: string } | null> { +): Promise< + { type: ChannelType; name?: string; topic?: string; parentId?: string } | null +> { try { const channel = await client.fetchChannel(channelId); if (!channel) return null; const name = "name" in channel ? (channel.name ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; - return { type: channel.type, name, topic }; + const parentId = + "parentId" in channel ? (channel.parentId ?? undefined) : undefined; + return { type: channel.type, name, topic, parentId }; } catch (err) { logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); return null; From cac467a2df312bed1d6f44daffc9bb9e57362152 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 9 Jan 2026 13:28:18 +1300 Subject: [PATCH 11/13] Hooks: default agent delivery to true --- docs/automation/webhook.md | 4 ++-- src/gateway/hooks.ts | 2 +- src/gateway/server-http.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 1535bae4c..17e47432a 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -57,7 +57,7 @@ Payload: "name": "Email", "sessionKey": "hook:email:msg-123", "wakeMode": "now", - "deliver": false, + "deliver": true, "provider": "last", "to": "+15551234567", "model": "openai/gpt-5.2-mini", @@ -70,7 +70,7 @@ Payload: - `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries. - `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:`. Using a consistent key allows for a multi-turn conversation within the hook context. - `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. -- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `false`. Responses that are only heartbeat acknowledgments are automatically skipped. +- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. - `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`. Defaults to `last`. - `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session. - `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted. diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index b0cd50708..7f8d23045 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -210,7 +210,7 @@ export function normalizeAgentPayload( if (modelRaw !== undefined && !model) { return { ok: false, error: "model required" }; } - const deliver = payload.deliver === true; + const deliver = payload.deliver !== false; const thinkingRaw = payload.thinking; const thinking = typeof thinkingRaw === "string" && thinkingRaw.trim() diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 4b81261cd..7a7212368 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -176,7 +176,7 @@ export function createHooksRequestHandler( name: mapped.action.name ?? "Hook", wakeMode: mapped.action.wakeMode, sessionKey: mapped.action.sessionKey ?? "", - deliver: mapped.action.deliver === true, + deliver: mapped.action.deliver !== false, provider: mapped.action.provider ?? "last", to: mapped.action.to, model: mapped.action.model, From 40fcfc947981de4731fc91d6efd47fc3d3f55963 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:34:22 +0100 Subject: [PATCH 12/13] test: cover hook deliver default (#533) (thanks @mcinteerj) --- CHANGELOG.md | 1 + src/gateway/hooks.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 569a3f1ee..cb5789ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled). - CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured. - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. +- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 09f9e66a3..c54606184 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -66,6 +66,16 @@ describe("gateway hooks helpers", () => { expect(ok.value.sessionKey).toBe("hook:fixed"); expect(ok.value.provider).toBe("last"); expect(ok.value.name).toBe("Hook"); + expect(ok.value.deliver).toBe(true); + } + + const explicitNoDeliver = normalizeAgentPayload( + { message: "hello", deliver: false }, + { idFactory: () => "fixed" }, + ); + expect(explicitNoDeliver.ok).toBe(true); + if (explicitNoDeliver.ok) { + expect(explicitNoDeliver.value.deliver).toBe(false); } const imsg = normalizeAgentPayload( From abfd5719d6e61d7127cf86a0be9500da119440e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:35:16 +0100 Subject: [PATCH 13/13] fix: cache discord channel lookups for thread starters (#585) (thanks @thewilloftheshadow) --- CHANGELOG.md | 1 + src/discord/monitor.tool-result.test.ts | 79 +++++++++++++++++++++++-- src/discord/monitor.ts | 49 +++++++++++++-- 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 569a3f1ee..449555623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro +- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow - Commands: accept /models as an alias for /model. - Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp - Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index e85e8c4f0..952bfda15 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -1,7 +1,7 @@ import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; -import { beforeEach, describe, expect, it, vi } from "vitest"; import { Routes } from "discord-api-types/v10"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const sendMock = vi.fn(); const reactMock = vi.fn(); @@ -120,6 +120,79 @@ describe("discord tool result dispatch", () => { expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /); }, 10000); + it("caches channel info lookups between messages", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, + session: { store: "/tmp/clawdbot-sessions.json" }, + discord: { dm: { enabled: true, policy: "open" } }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + }); + + const fetchChannel = vi.fn().mockResolvedValue({ + type: ChannelType.DM, + name: "dm", + }); + const client = { fetchChannel } as unknown as Client; + const baseMessage = { + content: "hello", + channelId: "cache-channel-1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u-cache", bot: false, username: "Ada" }, + }; + + await handler( + { + message: { ...baseMessage, id: "m-cache-1" }, + author: baseMessage.author, + guild_id: null, + }, + client, + ); + await handler( + { + message: { ...baseMessage, id: "m-cache-2" }, + author: baseMessage.author, + guild_id: null, + }, + client, + ); + + expect(fetchChannel).toHaveBeenCalledTimes(1); + }); + it("replies with pairing code and sender id when dmPolicy is pairing", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { @@ -483,9 +556,7 @@ describe("discord tool result dispatch", () => { ); expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support"); - expect(restGet).toHaveBeenCalledWith( - Routes.channelMessage("t1", "t1"), - ); + expect(restGet).toHaveBeenCalledWith(Routes.channelMessage("t1", "t1")); }); it("scopes thread sessions to the routed agent", async () => { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index bee9b5e38..59ef8e910 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -113,7 +113,20 @@ type DiscordThreadStarter = { timestamp?: number; }; +type DiscordChannelInfo = { + type: ChannelType; + name?: string; + topic?: string; + parentId?: string; +}; + const DISCORD_THREAD_STARTER_CACHE = new Map(); +const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000; +const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000; +const DISCORD_CHANNEL_INFO_CACHE = new Map< + string, + { value: DiscordChannelInfo | null; expiresAt: number } +>(); const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000; function logSlowDiscordListener(params: { @@ -684,13 +697,14 @@ export function createDiscordMessageHandler(params: { message.channel.isThread(); const isThreadByType = isGuildMessage && isDiscordThreadType(channelInfo?.type); - const threadChannel = isThreadChannel + const threadChannel: DiscordThreadChannel | null = isThreadChannel ? (message.channel as DiscordThreadChannel) : isThreadByType ? { id: message.channelId, name: channelInfo?.name ?? undefined, parentId: channelInfo?.parentId ?? undefined, + parent: undefined, } : null; const threadParentId = @@ -1721,19 +1735,42 @@ async function deliverDiscordReply(params: { async function resolveDiscordChannelInfo( client: Client, channelId: string, -): Promise< - { type: ChannelType; name?: string; topic?: string; parentId?: string } | null -> { +): Promise { + const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId); + if (cached) { + if (cached.expiresAt > Date.now()) return cached.value; + DISCORD_CHANNEL_INFO_CACHE.delete(channelId); + } try { const channel = await client.fetchChannel(channelId); - if (!channel) return null; + if (!channel) { + DISCORD_CHANNEL_INFO_CACHE.set(channelId, { + value: null, + expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, + }); + return null; + } const name = "name" in channel ? (channel.name ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; - return { type: channel.type, name, topic, parentId }; + const payload: DiscordChannelInfo = { + type: channel.type, + name, + topic, + parentId, + }; + DISCORD_CHANNEL_INFO_CACHE.set(channelId, { + value: payload, + expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS, + }); + return payload; } catch (err) { logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); + DISCORD_CHANNEL_INFO_CACHE.set(channelId, { + value: null, + expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, + }); return null; } }