Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -8,13 +8,16 @@
|
|||||||
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
|
- 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
|
- 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
|
- 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: 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
|
- 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.
|
- 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
|
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
|
||||||
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
|
- 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: 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.
|
- 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
|
- 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
|
- 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
|
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
|
||||||
@@ -89,7 +92,7 @@
|
|||||||
- Status: show Verbose/Elevated only when enabled.
|
- Status: show Verbose/Elevated only when enabled.
|
||||||
- Status: filter usage summary to the active model provider.
|
- Status: filter usage summary to the active model provider.
|
||||||
- Status: map model providers to usage sources so unrelated usage doesn’t appear.
|
- 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: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
|
||||||
- Commands: keep multi-directive messages from clearing directive handling.
|
- Commands: keep multi-directive messages from clearing directive handling.
|
||||||
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
|
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ services:
|
|||||||
HOME: /home/node
|
HOME: /home/node
|
||||||
TERM: xterm-256color
|
TERM: xterm-256color
|
||||||
CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN}
|
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:
|
volumes:
|
||||||
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
||||||
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
||||||
@@ -30,6 +33,9 @@ services:
|
|||||||
HOME: /home/node
|
HOME: /home/node
|
||||||
TERM: xterm-256color
|
TERM: xterm-256color
|
||||||
BROWSER: echo
|
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:
|
volumes:
|
||||||
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
||||||
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ Payload:
|
|||||||
"name": "Email",
|
"name": "Email",
|
||||||
"sessionKey": "hook:email:msg-123",
|
"sessionKey": "hook:email:msg-123",
|
||||||
"wakeMode": "now",
|
"wakeMode": "now",
|
||||||
"deliver": false,
|
"deliver": true,
|
||||||
"provider": "last",
|
"provider": "last",
|
||||||
"to": "+15551234567",
|
"to": "+15551234567",
|
||||||
"model": "openai/gpt-5.2-mini",
|
"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.
|
- `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:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. 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.
|
- `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`.
|
- `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.
|
- `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.
|
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ Options:
|
|||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
|
|
||||||
Surfaces:
|
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)
|
- `clawdbot status --usage` (prints full provider breakdown)
|
||||||
- macOS menu bar (Usage section under Context)
|
- macOS menu bar (Usage section under Context)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ Text + native (when enabled):
|
|||||||
- `/help`
|
- `/help`
|
||||||
- `/commands`
|
- `/commands`
|
||||||
- `/status`
|
- `/status`
|
||||||
|
- `/status` (show current status; includes a short usage line when available)
|
||||||
|
- `/usage` (alias: `/status`)
|
||||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
|
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
|
||||||
- `/cost on|off` (toggle per-response usage line)
|
- `/cost on|off` (toggle per-response usage line)
|
||||||
- `/stop`
|
- `/stop`
|
||||||
@@ -48,7 +50,7 @@ Text + native (when enabled):
|
|||||||
- `/verbose on|off` (alias: `/v`)
|
- `/verbose on|off` (alias: `/v`)
|
||||||
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
|
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
|
||||||
- `/elevated on|off` (alias: `/elev`)
|
- `/elevated on|off` (alias: `/elev`)
|
||||||
- `/model <name>` (or `/<alias>` from `agents.defaults.models.*.alias`)
|
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
|
||||||
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
|
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
|
||||||
|
|
||||||
Text-only:
|
Text-only:
|
||||||
@@ -56,6 +58,7 @@ Text-only:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
- 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).
|
- `/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.
|
- `/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.
|
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||||
|
|||||||
@@ -53,16 +53,17 @@ const loadAuthProfiles = (agentId: string) => {
|
|||||||
return { authPath, store };
|
return { authPath, store };
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickAnthropicToken = (store: {
|
const pickAnthropicTokens = (store: {
|
||||||
profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>;
|
profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>;
|
||||||
}): { profileId: string; token: string } | null => {
|
}): Array<{ profileId: string; token: string }> => {
|
||||||
const profiles = store.profiles ?? {};
|
const profiles = store.profiles ?? {};
|
||||||
|
const found: Array<{ profileId: string; token: string }> = [];
|
||||||
for (const [id, cred] of Object.entries(profiles)) {
|
for (const [id, cred] of Object.entries(profiles)) {
|
||||||
if (cred?.provider !== "anthropic") continue;
|
if (cred?.provider !== "anthropic") continue;
|
||||||
const token = cred.type === "token" ? cred.token?.trim() : undefined;
|
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) => {
|
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 };
|
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<string, unknown>;
|
||||||
|
const oauth = parsed?.claudeAiOauth as Record<string, unknown> | 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 => {
|
const chromeServiceNameForPath = (cookiePath: string): string => {
|
||||||
if (cookiePath.includes("/Arc/")) return "Arc Safe Storage";
|
if (cookiePath.includes("/Arc/")) return "Arc Safe Storage";
|
||||||
if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage";
|
if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage";
|
||||||
@@ -251,19 +280,34 @@ const main = async () => {
|
|||||||
const { authPath, store } = loadAuthProfiles(opts.agentId);
|
const { authPath, store } = loadAuthProfiles(opts.agentId);
|
||||||
console.log(`Auth file: ${authPath}`);
|
console.log(`Auth file: ${authPath}`);
|
||||||
|
|
||||||
const anthropic = pickAnthropicToken(store);
|
const keychain = readClaudeCliKeychain();
|
||||||
if (!anthropic) {
|
if (keychain) {
|
||||||
console.log("Anthropic: no token profiles found in auth-profiles.json");
|
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 {
|
} else {
|
||||||
console.log(
|
console.log("Claude CLI keychain: missing/unreadable");
|
||||||
`Anthropic: ${anthropic.profileId} token=${opts.reveal ? anthropic.token : mask(anthropic.token)}`,
|
}
|
||||||
);
|
|
||||||
const oauth = await fetchAnthropicOAuthUsage(anthropic.token);
|
const anthropic = pickAnthropicTokens(store);
|
||||||
console.log(
|
if (anthropic.length === 0) {
|
||||||
`OAuth usage: HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`,
|
console.log("Auth profiles: no Anthropic token profiles found");
|
||||||
);
|
} else {
|
||||||
console.log(oauth.text.slice(0, 400).replace(/\s+/g, " ").trim());
|
for (const entry of anthropic) {
|
||||||
console.log("");
|
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 =
|
const sessionKey =
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
"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_current]] replies to the triggering message.",
|
||||||
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
|
"- [[reply_to:<id>]] 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.",
|
"Tags are stripped before sending; support depends on the current provider config.",
|
||||||
"",
|
"",
|
||||||
"## Messaging",
|
"## Messaging",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { hasControlCommand } from "./command-detection.js";
|
import { hasControlCommand } from "./command-detection.js";
|
||||||
|
import { listChatCommands } from "./commands-registry.js";
|
||||||
import { parseActivationCommand } from "./group-activation.js";
|
import { parseActivationCommand } from "./group-activation.js";
|
||||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||||
|
|
||||||
@@ -37,17 +38,20 @@ describe("control command parsing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("treats bare commands as non-control", () => {
|
it("treats bare commands as non-control", () => {
|
||||||
expect(hasControlCommand("/send")).toBe(true);
|
|
||||||
expect(hasControlCommand("send")).toBe(false);
|
expect(hasControlCommand("send")).toBe(false);
|
||||||
expect(hasControlCommand("/help")).toBe(true);
|
|
||||||
expect(hasControlCommand("/help:")).toBe(true);
|
|
||||||
expect(hasControlCommand("help")).toBe(false);
|
expect(hasControlCommand("help")).toBe(false);
|
||||||
expect(hasControlCommand("/commands")).toBe(true);
|
expect(hasControlCommand("/commands")).toBe(true);
|
||||||
expect(hasControlCommand("/commands:")).toBe(true);
|
expect(hasControlCommand("/commands:")).toBe(true);
|
||||||
expect(hasControlCommand("commands")).toBe(false);
|
expect(hasControlCommand("commands")).toBe(false);
|
||||||
expect(hasControlCommand("/status")).toBe(true);
|
|
||||||
expect(hasControlCommand("/status:")).toBe(true);
|
|
||||||
expect(hasControlCommand("status")).toBe(false);
|
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", () => {
|
it("requires commands to be the full message", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildCommandText,
|
buildCommandText,
|
||||||
getCommandDetection,
|
getCommandDetection,
|
||||||
|
listChatCommands,
|
||||||
listNativeCommandSpecs,
|
listNativeCommandSpecs,
|
||||||
shouldHandleTextCommands,
|
shouldHandleTextCommands,
|
||||||
} from "./commands-registry.js";
|
} from "./commands-registry.js";
|
||||||
@@ -21,15 +22,21 @@ describe("commands registry", () => {
|
|||||||
|
|
||||||
it("detects known text commands", () => {
|
it("detects known text commands", () => {
|
||||||
const detection = getCommandDetection();
|
const detection = getCommandDetection();
|
||||||
expect(detection.exact.has("/help")).toBe(true);
|
for (const command of listChatCommands()) {
|
||||||
expect(detection.exact.has("/commands")).toBe(true);
|
for (const alias of command.textAliases) {
|
||||||
expect(detection.regex.test("/status")).toBe(true);
|
expect(detection.exact.has(alias.toLowerCase())).toBe(true);
|
||||||
expect(detection.regex.test("/status:")).toBe(true);
|
expect(detection.regex.test(alias)).toBe(true);
|
||||||
expect(detection.regex.test("/stop")).toBe(true);
|
expect(detection.regex.test(`${alias}:`)).toBe(true);
|
||||||
expect(detection.regex.test("/send:")).toBe(true);
|
|
||||||
expect(detection.regex.test("/debug set foo=bar")).toBe(true);
|
if (command.acceptsArgs) {
|
||||||
expect(detection.regex.test("/models")).toBe(true);
|
expect(detection.regex.test(`${alias} list`)).toBe(true);
|
||||||
expect(detection.regex.test("/models 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);
|
expect(detection.regex.test("try /status")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,123 +14,186 @@ export type NativeCommandSpec = {
|
|||||||
acceptsArgs: boolean;
|
acceptsArgs: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
function defineChatCommand(
|
||||||
{
|
command: Omit<ChatCommandDefinition, "textAliases"> & { textAlias: string },
|
||||||
key: "help",
|
): ChatCommandDefinition {
|
||||||
nativeName: "help",
|
return {
|
||||||
description: "Show available commands.",
|
key: command.key,
|
||||||
textAliases: ["/help"],
|
nativeName: command.nativeName,
|
||||||
},
|
description: command.description,
|
||||||
{
|
acceptsArgs: command.acceptsArgs,
|
||||||
key: "commands",
|
textAliases: [command.textAlias],
|
||||||
nativeName: "commands",
|
};
|
||||||
description: "List all slash commands.",
|
}
|
||||||
textAliases: ["/commands"],
|
|
||||||
},
|
function registerAlias(
|
||||||
{
|
commands: ChatCommandDefinition[],
|
||||||
key: "status",
|
key: string,
|
||||||
nativeName: "status",
|
...aliases: string[]
|
||||||
description: "Show current status.",
|
): void {
|
||||||
textAliases: ["/status"],
|
const command = commands.find((entry) => entry.key === key);
|
||||||
},
|
if (!command) {
|
||||||
{
|
throw new Error(`registerAlias: unknown command key: ${key}`);
|
||||||
key: "debug",
|
}
|
||||||
nativeName: "debug",
|
const existing = new Set(command.textAliases.map((alias) => alias.trim()));
|
||||||
description: "Set runtime debug overrides.",
|
for (const alias of aliases) {
|
||||||
textAliases: ["/debug"],
|
const trimmed = alias.trim();
|
||||||
acceptsArgs: true,
|
if (!trimmed) continue;
|
||||||
},
|
if (existing.has(trimmed)) continue;
|
||||||
{
|
existing.add(trimmed);
|
||||||
key: "cost",
|
command.textAliases.push(trimmed);
|
||||||
nativeName: "cost",
|
}
|
||||||
description: "Toggle per-response usage line.",
|
}
|
||||||
textAliases: ["/cost"],
|
|
||||||
acceptsArgs: true,
|
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||||
},
|
const commands: ChatCommandDefinition[] = [
|
||||||
{
|
defineChatCommand({
|
||||||
key: "stop",
|
key: "help",
|
||||||
nativeName: "stop",
|
nativeName: "help",
|
||||||
description: "Stop the current run.",
|
description: "Show available commands.",
|
||||||
textAliases: ["/stop"],
|
textAlias: "/help",
|
||||||
},
|
}),
|
||||||
{
|
defineChatCommand({
|
||||||
key: "restart",
|
key: "commands",
|
||||||
nativeName: "restart",
|
nativeName: "commands",
|
||||||
description: "Restart Clawdbot.",
|
description: "List all slash commands.",
|
||||||
textAliases: ["/restart"],
|
textAlias: "/commands",
|
||||||
},
|
}),
|
||||||
{
|
defineChatCommand({
|
||||||
key: "activation",
|
key: "status",
|
||||||
nativeName: "activation",
|
nativeName: "status",
|
||||||
description: "Set group activation mode.",
|
description: "Show current status.",
|
||||||
textAliases: ["/activation"],
|
textAlias: "/status",
|
||||||
acceptsArgs: true,
|
}),
|
||||||
},
|
defineChatCommand({
|
||||||
{
|
key: "debug",
|
||||||
key: "send",
|
nativeName: "debug",
|
||||||
nativeName: "send",
|
description: "Set runtime debug overrides.",
|
||||||
description: "Set send policy.",
|
textAlias: "/debug",
|
||||||
textAliases: ["/send"],
|
acceptsArgs: true,
|
||||||
acceptsArgs: true,
|
}),
|
||||||
},
|
defineChatCommand({
|
||||||
{
|
key: "cost",
|
||||||
key: "reset",
|
nativeName: "cost",
|
||||||
nativeName: "reset",
|
description: "Toggle per-response usage line.",
|
||||||
description: "Reset the current session.",
|
textAlias: "/cost",
|
||||||
textAliases: ["/reset"],
|
acceptsArgs: true,
|
||||||
},
|
}),
|
||||||
{
|
defineChatCommand({
|
||||||
key: "new",
|
key: "stop",
|
||||||
nativeName: "new",
|
nativeName: "stop",
|
||||||
description: "Start a new session.",
|
description: "Stop the current run.",
|
||||||
textAliases: ["/new"],
|
textAlias: "/stop",
|
||||||
},
|
}),
|
||||||
{
|
defineChatCommand({
|
||||||
key: "think",
|
key: "restart",
|
||||||
nativeName: "think",
|
nativeName: "restart",
|
||||||
description: "Set thinking level.",
|
description: "Restart Clawdbot.",
|
||||||
textAliases: ["/thinking", "/think", "/t"],
|
textAlias: "/restart",
|
||||||
acceptsArgs: true,
|
}),
|
||||||
},
|
defineChatCommand({
|
||||||
{
|
key: "activation",
|
||||||
key: "verbose",
|
nativeName: "activation",
|
||||||
nativeName: "verbose",
|
description: "Set group activation mode.",
|
||||||
description: "Toggle verbose mode.",
|
textAlias: "/activation",
|
||||||
textAliases: ["/verbose", "/v"],
|
acceptsArgs: true,
|
||||||
acceptsArgs: true,
|
}),
|
||||||
},
|
defineChatCommand({
|
||||||
{
|
key: "send",
|
||||||
key: "reasoning",
|
nativeName: "send",
|
||||||
nativeName: "reasoning",
|
description: "Set send policy.",
|
||||||
description: "Toggle reasoning visibility.",
|
textAlias: "/send",
|
||||||
textAliases: ["/reasoning", "/reason"],
|
acceptsArgs: true,
|
||||||
acceptsArgs: true,
|
}),
|
||||||
},
|
defineChatCommand({
|
||||||
{
|
key: "reset",
|
||||||
key: "elevated",
|
nativeName: "reset",
|
||||||
nativeName: "elevated",
|
description: "Reset the current session.",
|
||||||
description: "Toggle elevated mode.",
|
textAlias: "/reset",
|
||||||
textAliases: ["/elevated", "/elev"],
|
}),
|
||||||
acceptsArgs: true,
|
defineChatCommand({
|
||||||
},
|
key: "new",
|
||||||
{
|
nativeName: "new",
|
||||||
key: "model",
|
description: "Start a new session.",
|
||||||
nativeName: "model",
|
textAlias: "/new",
|
||||||
description: "Show or set the model.",
|
}),
|
||||||
textAliases: ["/model", "/models"],
|
defineChatCommand({
|
||||||
acceptsArgs: true,
|
key: "think",
|
||||||
},
|
nativeName: "think",
|
||||||
{
|
description: "Set thinking level.",
|
||||||
key: "queue",
|
textAlias: "/think",
|
||||||
nativeName: "queue",
|
acceptsArgs: true,
|
||||||
description: "Adjust queue settings.",
|
}),
|
||||||
textAliases: ["/queue"],
|
defineChatCommand({
|
||||||
acceptsArgs: true,
|
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"]);
|
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
|
||||||
|
|
||||||
|
type TextAliasSpec = {
|
||||||
|
canonical: string;
|
||||||
|
acceptsArgs: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
|
||||||
|
const map = new Map<string, TextAliasSpec>();
|
||||||
|
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:
|
let cachedDetection:
|
||||||
| {
|
| {
|
||||||
exact: Set<string>;
|
exact: Set<string>;
|
||||||
@@ -171,11 +234,31 @@ export function buildCommandText(commandName: string, args?: string): string {
|
|||||||
export function normalizeCommandBody(raw: string): string {
|
export function normalizeCommandBody(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed.startsWith("/")) return trimmed;
|
if (!trimmed.startsWith("/")) return trimmed;
|
||||||
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
|
||||||
if (!match) return trimmed;
|
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
||||||
const [, command, rest] = match;
|
const normalized = colonMatch
|
||||||
const normalizedRest = rest.trimStart();
|
? (() => {
|
||||||
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
|
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<string>; regex: RegExp } {
|
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||||
|
|||||||
@@ -144,6 +144,12 @@ describe("directive parsing", () => {
|
|||||||
expect(res.cleaned).toBe("thats not /tmp/hello");
|
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", () => {
|
it("parses queue options and modes", () => {
|
||||||
const res = extractQueueDirective(
|
const res = extractQueueDirective(
|
||||||
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
|
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
|
||||||
|
|||||||
@@ -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 () => {
|
it("prefers explicit reply_to id over reply_to_current", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
|
|||||||
@@ -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 () => {
|
it("reports active auth profile and key snippet in status", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
@@ -1240,6 +1257,7 @@ describe("trigger handling", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
||||||
expect(prompt).toContain("Give me the status");
|
expect(prompt).toContain("Give me the status");
|
||||||
expect(prompt).not.toContain("/thinking high");
|
expect(prompt).not.toContain("/thinking high");
|
||||||
|
expect(prompt).not.toContain("/think high");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function extractStatusDirective(body?: string): {
|
|||||||
hasDirective: boolean;
|
hasDirective: boolean;
|
||||||
} {
|
} {
|
||||||
if (!body) return { cleaned: "", hasDirective: false };
|
if (!body) return { cleaned: "", hasDirective: false };
|
||||||
return extractSimpleDirective(body, ["status"]);
|
return extractSimpleDirective(body, ["status", "usage"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||||
|
|||||||
@@ -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(
|
export function extractReplyToTag(
|
||||||
text?: string,
|
text?: string,
|
||||||
currentMessageId?: string,
|
currentMessageId?: string,
|
||||||
@@ -7,29 +17,28 @@ export function extractReplyToTag(
|
|||||||
hasTag: boolean;
|
hasTag: boolean;
|
||||||
} {
|
} {
|
||||||
if (!text) return { cleaned: "", hasTag: false };
|
if (!text) return { cleaned: "", hasTag: false };
|
||||||
let cleaned = text;
|
|
||||||
let replyToId: string | undefined;
|
let sawCurrent = false;
|
||||||
|
let lastExplicitId: string | undefined;
|
||||||
let hasTag = false;
|
let hasTag = false;
|
||||||
|
|
||||||
const currentMatch = cleaned.match(/\[\[\s*reply_to_current\s*\]\]/i);
|
const cleaned = normalizeReplyText(
|
||||||
if (currentMatch) {
|
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
|
||||||
cleaned = cleaned.replace(/\[\[\s*reply_to_current\s*\]\]/gi, " ");
|
hasTag = true;
|
||||||
hasTag = true;
|
if (idRaw === undefined) {
|
||||||
if (currentMessageId?.trim()) {
|
sawCurrent = true;
|
||||||
replyToId = currentMessageId.trim();
|
return " ";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const idMatch = cleaned.match(/\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]/i);
|
const id = idRaw.trim();
|
||||||
if (idMatch?.[1]) {
|
if (id) lastExplicitId = id;
|
||||||
cleaned = cleaned.replace(/\[\[\s*reply_to\s*:\s*[^\]\n]+\s*\]\]/gi, " ");
|
return " ";
|
||||||
replyToId = idMatch[1].trim();
|
}),
|
||||||
hasTag = true;
|
);
|
||||||
}
|
|
||||||
|
const replyToId =
|
||||||
|
lastExplicitId ??
|
||||||
|
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
|
||||||
|
|
||||||
cleaned = cleaned
|
|
||||||
.replace(/[ \t]+/g, " ")
|
|
||||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
|
||||||
.trim();
|
|
||||||
return { cleaned, replyToId, hasTag };
|
return { cleaned, replyToId, hasTag };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Client } from "@buape/carbon";
|
import type { Client } from "@buape/carbon";
|
||||||
import { ChannelType, MessageType } from "@buape/carbon";
|
import { ChannelType, MessageType } from "@buape/carbon";
|
||||||
|
import { Routes } from "discord-api-types/v10";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
const sendMock = vi.fn();
|
||||||
@@ -119,6 +120,79 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
|
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
|
||||||
}, 10000);
|
}, 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<typeof import("../config/config.js").loadConfig>;
|
||||||
|
|
||||||
|
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 () => {
|
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
const cfg = {
|
const cfg = {
|
||||||
@@ -381,6 +455,110 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
|
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<typeof import("../config/config.js").loadConfig>;
|
||||||
|
|
||||||
|
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 () => {
|
it("scopes thread sessions to the routed agent", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,20 @@ type DiscordThreadStarter = {
|
|||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DiscordChannelInfo = {
|
||||||
|
type: ChannelType;
|
||||||
|
name?: string;
|
||||||
|
topic?: string;
|
||||||
|
parentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
||||||
|
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;
|
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
|
||||||
|
|
||||||
function logSlowDiscordListener(params: {
|
function logSlowDiscordListener(params: {
|
||||||
@@ -139,14 +152,22 @@ async function resolveDiscordThreadStarter(params: {
|
|||||||
channel: DiscordThreadChannel;
|
channel: DiscordThreadChannel;
|
||||||
client: Client;
|
client: Client;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
parentType?: ChannelType;
|
||||||
}): Promise<DiscordThreadStarter | null> {
|
}): Promise<DiscordThreadStarter | null> {
|
||||||
const cacheKey = params.channel.id;
|
const cacheKey = params.channel.id;
|
||||||
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
|
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
try {
|
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(
|
const starter = (await params.client.rest.get(
|
||||||
Routes.channelMessage(params.parentId, params.channel.id),
|
Routes.channelMessage(messageChannelId, params.channel.id),
|
||||||
)) as {
|
)) as {
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
embeds?: Array<{ description?: string | null }>;
|
embeds?: Array<{ description?: string | null }>;
|
||||||
@@ -226,6 +247,14 @@ export type DiscordMessageHandler = (
|
|||||||
client: Client,
|
client: Client,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
|
function isDiscordThreadType(type: ChannelType | undefined): boolean {
|
||||||
|
return (
|
||||||
|
type === ChannelType.PublicThread ||
|
||||||
|
type === ChannelType.PrivateThread ||
|
||||||
|
type === ChannelType.AnnouncementThread
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveDiscordReplyTarget(opts: {
|
export function resolveDiscordReplyTarget(opts: {
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
@@ -666,12 +695,33 @@ export function createDiscordMessageHandler(params: {
|
|||||||
message.channel &&
|
message.channel &&
|
||||||
"isThread" in message.channel &&
|
"isThread" in message.channel &&
|
||||||
message.channel.isThread();
|
message.channel.isThread();
|
||||||
const threadChannel = isThreadChannel
|
const isThreadByType =
|
||||||
|
isGuildMessage && isDiscordThreadType(channelInfo?.type);
|
||||||
|
const threadChannel: DiscordThreadChannel | null = isThreadChannel
|
||||||
? (message.channel as DiscordThreadChannel)
|
? (message.channel as DiscordThreadChannel)
|
||||||
: null;
|
: isThreadByType
|
||||||
|
? {
|
||||||
|
id: message.channelId,
|
||||||
|
name: channelInfo?.name ?? undefined,
|
||||||
|
parentId: channelInfo?.parentId ?? undefined,
|
||||||
|
parent: undefined,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const threadParentId =
|
const threadParentId =
|
||||||
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
|
threadChannel?.parentId ??
|
||||||
const threadParentName = threadChannel?.parent?.name;
|
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 threadName = threadChannel?.name;
|
||||||
const configChannelName = threadParentName ?? channelName;
|
const configChannelName = threadParentName ?? channelName;
|
||||||
const configChannelSlug = configChannelName
|
const configChannelSlug = configChannelName
|
||||||
@@ -935,6 +985,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
channel: threadChannel,
|
channel: threadChannel,
|
||||||
client,
|
client,
|
||||||
parentId: threadParentId,
|
parentId: threadParentId,
|
||||||
|
parentType: threadParentType,
|
||||||
});
|
});
|
||||||
if (starter?.text) {
|
if (starter?.text) {
|
||||||
const starterEnvelope = formatThreadStarterEnvelope({
|
const starterEnvelope = formatThreadStarterEnvelope({
|
||||||
@@ -1684,15 +1735,42 @@ async function deliverDiscordReply(params: {
|
|||||||
async function resolveDiscordChannelInfo(
|
async function resolveDiscordChannelInfo(
|
||||||
client: Client,
|
client: Client,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
): Promise<{ type: ChannelType; name?: string; topic?: string } | null> {
|
): Promise<DiscordChannelInfo | null> {
|
||||||
|
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 {
|
try {
|
||||||
const channel = await client.fetchChannel(channelId);
|
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 name = "name" in channel ? (channel.name ?? undefined) : undefined;
|
||||||
const topic = "topic" in channel ? (channel.topic ?? 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;
|
||||||
|
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) {
|
} catch (err) {
|
||||||
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/docs/slash-commands-doc.test.ts
Normal file
32
src/docs/slash-commands-doc.test.ts
Normal file
@@ -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<string> {
|
||||||
|
const documented = new Set<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -66,6 +66,16 @@ describe("gateway hooks helpers", () => {
|
|||||||
expect(ok.value.sessionKey).toBe("hook:fixed");
|
expect(ok.value.sessionKey).toBe("hook:fixed");
|
||||||
expect(ok.value.provider).toBe("last");
|
expect(ok.value.provider).toBe("last");
|
||||||
expect(ok.value.name).toBe("Hook");
|
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(
|
const imsg = normalizeAgentPayload(
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function normalizeAgentPayload(
|
|||||||
if (modelRaw !== undefined && !model) {
|
if (modelRaw !== undefined && !model) {
|
||||||
return { ok: false, error: "model required" };
|
return { ok: false, error: "model required" };
|
||||||
}
|
}
|
||||||
const deliver = payload.deliver === true;
|
const deliver = payload.deliver !== false;
|
||||||
const thinkingRaw = payload.thinking;
|
const thinkingRaw = payload.thinking;
|
||||||
const thinking =
|
const thinking =
|
||||||
typeof thinkingRaw === "string" && thinkingRaw.trim()
|
typeof thinkingRaw === "string" && thinkingRaw.trim()
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
type SessionsPatchResult,
|
type SessionsPatchResult,
|
||||||
} from "./session-utils.js";
|
} from "./session-utils.js";
|
||||||
import { applySessionsPatchToStore } from "./sessions-patch.js";
|
import { applySessionsPatchToStore } from "./sessions-patch.js";
|
||||||
|
import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js";
|
||||||
import { formatForLog } from "./ws-log.js";
|
import { formatForLog } from "./ws-log.js";
|
||||||
|
|
||||||
export type BridgeHandlersContext = {
|
export type BridgeHandlersContext = {
|
||||||
@@ -314,93 +315,20 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
|
|
||||||
const p = params as SessionsResolveParams;
|
const p = params as SessionsResolveParams;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
|
||||||
const key = typeof p.key === "string" ? p.key.trim() : "";
|
if (!resolved.ok) {
|
||||||
const label = typeof p.label === "string" ? p.label.trim() : "";
|
|
||||||
const hasKey = key.length > 0;
|
|
||||||
const hasLabel = label.length > 0;
|
|
||||||
if (hasKey && hasLabel) {
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
code: ErrorCodes.INVALID_REQUEST,
|
code: resolved.error.code,
|
||||||
message: "Provide either key or label (not both)",
|
message: resolved.error.message,
|
||||||
},
|
details: resolved.error.details,
|
||||||
};
|
|
||||||
}
|
|
||||||
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})`,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
payloadJSON: JSON.stringify({
|
payloadJSON: JSON.stringify({ ok: true, key: resolved.key }),
|
||||||
ok: true,
|
|
||||||
key: list.sessions[0]?.key,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "sessions.patch": {
|
case "sessions.patch": {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function createHooksRequestHandler(
|
|||||||
name: mapped.action.name ?? "Hook",
|
name: mapped.action.name ?? "Hook",
|
||||||
wakeMode: mapped.action.wakeMode,
|
wakeMode: mapped.action.wakeMode,
|
||||||
sessionKey: mapped.action.sessionKey ?? "",
|
sessionKey: mapped.action.sessionKey ?? "",
|
||||||
deliver: mapped.action.deliver === true,
|
deliver: mapped.action.deliver !== false,
|
||||||
provider: mapped.action.provider ?? "last",
|
provider: mapped.action.provider ?? "last",
|
||||||
to: mapped.action.to,
|
to: mapped.action.to,
|
||||||
model: mapped.action.model,
|
model: mapped.action.model,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type SessionsPatchResult,
|
type SessionsPatchResult,
|
||||||
} from "../session-utils.js";
|
} from "../session-utils.js";
|
||||||
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
||||||
|
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
export const sessionsHandlers: GatewayRequestHandlers = {
|
export const sessionsHandlers: GatewayRequestHandlers = {
|
||||||
@@ -76,106 +77,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
const p = params as import("../protocol/index.js").SessionsResolveParams;
|
const p = params as import("../protocol/index.js").SessionsResolveParams;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
|
||||||
const key = typeof p.key === "string" ? p.key.trim() : "";
|
const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
|
||||||
const label = typeof p.label === "string" ? p.label.trim() : "";
|
if (!resolved.ok) {
|
||||||
const hasKey = key.length > 0;
|
respond(false, undefined, resolved.error);
|
||||||
const hasLabel = label.length > 0;
|
|
||||||
if (hasKey && hasLabel) {
|
|
||||||
respond(
|
|
||||||
false,
|
|
||||||
undefined,
|
|
||||||
errorShape(
|
|
||||||
ErrorCodes.INVALID_REQUEST,
|
|
||||||
"Provide either key or label (not both)",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasKey && !hasLabel) {
|
respond(true, { ok: true, key: resolved.key }, undefined);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
"sessions.patch": async ({ params, respond, context }) => {
|
"sessions.patch": async ({ params, respond, context }) => {
|
||||||
if (!validateSessionsPatchParams(params)) {
|
if (!validateSessionsPatchParams(params)) {
|
||||||
|
|||||||
107
src/gateway/sessions-resolve.ts
Normal file
107
src/gateway/sessions-resolve.ts
Normal file
@@ -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 ?? "") };
|
||||||
|
}
|
||||||
@@ -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<typeof fetch>,
|
||||||
|
ReturnType<typeof fetch>
|
||||||
|
>(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<string, string>;
|
||||||
|
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 () => {
|
it("falls back to claude.ai web usage when OAuth scope is missing", async () => {
|
||||||
const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY;
|
const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY;
|
||||||
process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1";
|
process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CLAUDE_CLI_PROFILE_ID,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
listProfilesForProvider,
|
listProfilesForProvider,
|
||||||
resolveApiKeyForProfile,
|
resolveApiKeyForProfile,
|
||||||
@@ -802,7 +803,16 @@ async function resolveOAuthToken(params: {
|
|||||||
provider: params.provider,
|
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];
|
const cred = store.profiles[profileId];
|
||||||
if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue;
|
if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user