Merge branch 'main' into commands-list-clean
This commit is contained in:
216 files changed
+6174
-2822
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -276,10 +277,23 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Anthropic OAuth credentials from Claude CLI's credential file.
|
||||
* Claude CLI stores credentials at ~/.claude/.credentials.json
|
||||
* Read Anthropic OAuth credentials from Claude CLI's keychain entry (macOS)
|
||||
* or credential file (Linux/Windows).
|
||||
*
|
||||
* On macOS, Claude Code stores credentials in keychain "Claude Code-credentials".
|
||||
* On Linux/Windows, it uses ~/.claude/.credentials.json
|
||||
*/
|
||||
function readClaudeCliCredentials(): OAuthCredential | null {
|
||||
function readClaudeCliCredentials(options?: {
|
||||
allowKeychainPrompt?: boolean;
|
||||
}): OAuthCredential | null {
|
||||
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||
if (keychainCreds) {
|
||||
log.info("read anthropic credentials from claude cli keychain");
|
||||
return keychainCreds;
|
||||
}
|
||||
}
|
||||
|
||||
const credPath = path.join(
|
||||
resolveUserPath("~"),
|
||||
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
|
||||
@@ -308,6 +322,41 @@ function readClaudeCliCredentials(): OAuthCredential | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Claude Code credentials from macOS keychain.
|
||||
* Uses the `security` CLI to access keychain without native dependencies.
|
||||
*/
|
||||
function readClaudeCliKeychainCredentials(): OAuthCredential | null {
|
||||
try {
|
||||
const result = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w',
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
const data = JSON.parse(result.trim());
|
||||
const claudeOauth = data?.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read OpenAI Codex OAuth credentials from Codex CLI's auth file.
|
||||
* Codex CLI stores credentials at ~/.codex/auth.json
|
||||
@@ -374,12 +423,15 @@ function shallowEqualOAuthCredentials(
|
||||
*
|
||||
* Returns true if any credentials were updated.
|
||||
*/
|
||||
function syncExternalCliCredentials(store: AuthProfileStore): boolean {
|
||||
function syncExternalCliCredentials(
|
||||
store: AuthProfileStore,
|
||||
options?: { allowKeychainPrompt?: boolean },
|
||||
): boolean {
|
||||
let mutated = false;
|
||||
const now = Date.now();
|
||||
|
||||
// Sync from Claude CLI
|
||||
const claudeCreds = readClaudeCliCredentials();
|
||||
const claudeCreds = readClaudeCliCredentials(options);
|
||||
if (claudeCreds) {
|
||||
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
@@ -486,13 +538,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
return store;
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
|
||||
export function ensureAuthProfileStore(
|
||||
agentDir?: string,
|
||||
options?: { allowKeychainPrompt?: boolean },
|
||||
): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) {
|
||||
// Sync from external CLI tools on every load
|
||||
const synced = syncExternalCliCredentials(asStore);
|
||||
const synced = syncExternalCliCredentials(asStore, options);
|
||||
if (synced) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
}
|
||||
@@ -532,7 +587,7 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
|
||||
}
|
||||
|
||||
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
||||
const syncedCli = syncExternalCliCredentials(store);
|
||||
const syncedCli = syncExternalCliCredentials(store, options);
|
||||
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
|
||||
if (shouldWrite) {
|
||||
saveJsonFile(authPath, store);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const shortDelayCmd = isWin ? "ping -n 2 127.0.0.1 > nul" : "sleep 0.05";
|
||||
const yieldDelayCmd = isWin ? "ping -n 3 127.0.0.1 > nul" : "sleep 0.2";
|
||||
const longDelayCmd = isWin ? "ping -n 4 127.0.0.1 > nul" : "sleep 2";
|
||||
const joinCommands = (commands: string[]) =>
|
||||
commands.join(isWin ? " & " : "; ");
|
||||
@@ -51,7 +52,7 @@ beforeEach(() => {
|
||||
describe("bash tool backgrounding", () => {
|
||||
it("backgrounds after yield and can be polled", async () => {
|
||||
const result = await bashTool.execute("call1", {
|
||||
command: echoAfterDelay("done"),
|
||||
command: joinCommands([yieldDelayCmd, "echo done"]),
|
||||
yieldMs: 10,
|
||||
});
|
||||
|
||||
|
||||
@@ -1180,6 +1180,76 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("includes browser action metadata in tool summaries", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onToolResult = vi.fn();
|
||||
|
||||
subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<
|
||||
typeof subscribeEmbeddedPiSession
|
||||
>[0]["session"],
|
||||
runId: "run-browser-tool",
|
||||
verboseLevel: "on",
|
||||
onToolResult,
|
||||
});
|
||||
|
||||
handler?.({
|
||||
type: "tool_execution_start",
|
||||
toolName: "browser",
|
||||
toolCallId: "tool-browser-1",
|
||||
args: { action: "snapshot", targetUrl: "https://example.com" },
|
||||
});
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
const payload = onToolResult.mock.calls[0][0];
|
||||
expect(payload.text).toContain("🌐");
|
||||
expect(payload.text).toContain("browser");
|
||||
expect(payload.text).toContain("snapshot");
|
||||
expect(payload.text).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("includes canvas action metadata in tool summaries", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onToolResult = vi.fn();
|
||||
|
||||
subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<
|
||||
typeof subscribeEmbeddedPiSession
|
||||
>[0]["session"],
|
||||
runId: "run-canvas-tool",
|
||||
verboseLevel: "on",
|
||||
onToolResult,
|
||||
});
|
||||
|
||||
handler?.({
|
||||
type: "tool_execution_start",
|
||||
toolName: "canvas",
|
||||
toolCallId: "tool-canvas-1",
|
||||
args: { action: "a2ui_push", jsonlPath: "/tmp/a2ui.jsonl" },
|
||||
});
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
const payload = onToolResult.mock.calls[0][0];
|
||||
expect(payload.text).toContain("🖼️");
|
||||
expect(payload.text).toContain("canvas");
|
||||
expect(payload.text).toContain("A2UI push");
|
||||
expect(payload.text).toContain("/tmp/a2ui.jsonl");
|
||||
});
|
||||
|
||||
it("skips tool summaries when shouldEmitToolResult is false", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { STATE_DIR_CLAWDBOT } from "../config/config.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
|
||||
import { syncSkillsToWorkspace } from "./skills.js";
|
||||
import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
@@ -1048,6 +1049,19 @@ export async function resolveSandboxContext(params: {
|
||||
agentWorkspaceDir,
|
||||
params.config?.agent?.skipBootstrap,
|
||||
);
|
||||
if (cfg.workspaceAccess === "none") {
|
||||
try {
|
||||
await syncSkillsToWorkspace({
|
||||
sourceWorkspaceDir: agentWorkspaceDir,
|
||||
targetWorkspaceDir: sandboxWorkspaceDir,
|
||||
config: params.config,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : JSON.stringify(error);
|
||||
defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
}
|
||||
@@ -1109,6 +1123,19 @@ export async function ensureSandboxWorkspaceForSession(params: {
|
||||
agentWorkspaceDir,
|
||||
params.config?.agent?.skipBootstrap,
|
||||
);
|
||||
if (cfg.workspaceAccess === "none") {
|
||||
try {
|
||||
await syncSkillsToWorkspace({
|
||||
sourceWorkspaceDir: agentWorkspaceDir,
|
||||
targetWorkspaceDir: sandboxWorkspaceDir,
|
||||
config: params.config,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : JSON.stringify(error);
|
||||
defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildWorkspaceSkillSnapshot,
|
||||
buildWorkspaceSkillsPrompt,
|
||||
loadWorkspaceSkillEntries,
|
||||
syncSkillsToWorkspace,
|
||||
} from "./skills.js";
|
||||
import { buildWorkspaceSkillStatus } from "./skills-status.js";
|
||||
|
||||
@@ -130,6 +131,60 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
||||
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
|
||||
});
|
||||
|
||||
it("syncs merged skills into a target workspace", async () => {
|
||||
const sourceWorkspace = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-"),
|
||||
);
|
||||
const targetWorkspace = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-"),
|
||||
);
|
||||
const extraDir = path.join(sourceWorkspace, ".extra");
|
||||
const bundledDir = path.join(sourceWorkspace, ".bundled");
|
||||
const managedDir = path.join(sourceWorkspace, ".managed");
|
||||
|
||||
await writeSkill({
|
||||
dir: path.join(extraDir, "demo-skill"),
|
||||
name: "demo-skill",
|
||||
description: "Extra version",
|
||||
});
|
||||
await writeSkill({
|
||||
dir: path.join(bundledDir, "demo-skill"),
|
||||
name: "demo-skill",
|
||||
description: "Bundled version",
|
||||
});
|
||||
await writeSkill({
|
||||
dir: path.join(managedDir, "demo-skill"),
|
||||
name: "demo-skill",
|
||||
description: "Managed version",
|
||||
});
|
||||
await writeSkill({
|
||||
dir: path.join(sourceWorkspace, "skills", "demo-skill"),
|
||||
name: "demo-skill",
|
||||
description: "Workspace version",
|
||||
});
|
||||
|
||||
await syncSkillsToWorkspace({
|
||||
sourceWorkspaceDir: sourceWorkspace,
|
||||
targetWorkspaceDir: targetWorkspace,
|
||||
config: { skills: { load: { extraDirs: [extraDir] } } },
|
||||
bundledSkillsDir: bundledDir,
|
||||
managedSkillsDir: managedDir,
|
||||
});
|
||||
|
||||
const prompt = buildWorkspaceSkillsPrompt(targetWorkspace, {
|
||||
bundledSkillsDir: path.join(targetWorkspace, ".bundled"),
|
||||
managedSkillsDir: path.join(targetWorkspace, ".managed"),
|
||||
});
|
||||
|
||||
expect(prompt).toContain("Workspace version");
|
||||
expect(prompt).not.toContain("Managed version");
|
||||
expect(prompt).not.toContain("Bundled version");
|
||||
expect(prompt).not.toContain("Extra version");
|
||||
expect(prompt).toContain(
|
||||
path.join(targetWorkspace, "skills", "demo-skill", "SKILL.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters skills based on env/config gates", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
|
||||
const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro");
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import type { ClawdbotConfig, SkillConfig } from "../config/config.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
|
||||
const fsp = fs.promises;
|
||||
|
||||
export type SkillInstallSpec = {
|
||||
id?: string;
|
||||
kind: "brew" | "node" | "go" | "uv";
|
||||
@@ -619,6 +621,41 @@ export function loadWorkspaceSkillEntries(
|
||||
return loadSkillEntries(workspaceDir, opts);
|
||||
}
|
||||
|
||||
export async function syncSkillsToWorkspace(params: {
|
||||
sourceWorkspaceDir: string;
|
||||
targetWorkspaceDir: string;
|
||||
config?: ClawdbotConfig;
|
||||
managedSkillsDir?: string;
|
||||
bundledSkillsDir?: string;
|
||||
}) {
|
||||
const sourceDir = resolveUserPath(params.sourceWorkspaceDir);
|
||||
const targetDir = resolveUserPath(params.targetWorkspaceDir);
|
||||
if (sourceDir === targetDir) return;
|
||||
const targetSkillsDir = path.join(targetDir, "skills");
|
||||
|
||||
const entries = loadSkillEntries(sourceDir, {
|
||||
config: params.config,
|
||||
managedSkillsDir: params.managedSkillsDir,
|
||||
bundledSkillsDir: params.bundledSkillsDir,
|
||||
});
|
||||
|
||||
await fsp.rm(targetSkillsDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(targetSkillsDir, { recursive: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const dest = path.join(targetSkillsDir, entry.skill.name);
|
||||
try {
|
||||
await fsp.cp(entry.skill.baseDir, dest, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : JSON.stringify(error);
|
||||
console.warn(
|
||||
`[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterWorkspaceSkillEntries(
|
||||
entries: SkillEntry[],
|
||||
config?: ClawdbotConfig,
|
||||
|
||||
@@ -25,6 +25,45 @@ function expectFencesBalanced(chunks: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
type ChunkCase = {
|
||||
name: string;
|
||||
text: string;
|
||||
limit: number;
|
||||
expected: string[];
|
||||
};
|
||||
|
||||
function runChunkCases(
|
||||
chunker: (text: string, limit: number) => string[],
|
||||
cases: ChunkCase[],
|
||||
) {
|
||||
for (const { name, text, limit, expected } of cases) {
|
||||
it(name, () => {
|
||||
expect(chunker(text, limit)).toEqual(expected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parentheticalCases: ChunkCase[] = [
|
||||
{
|
||||
name: "keeps parenthetical phrases together",
|
||||
text: "Heads up now (Though now I'm curious)ok",
|
||||
limit: 35,
|
||||
expected: ["Heads up now", "(Though now I'm curious)ok"],
|
||||
},
|
||||
{
|
||||
name: "handles nested parentheses",
|
||||
text: "Hello (outer (inner) end) world",
|
||||
limit: 26,
|
||||
expected: ["Hello (outer (inner) end)", "world"],
|
||||
},
|
||||
{
|
||||
name: "ignores unmatched closing parentheses",
|
||||
text: "Hello) world (ok)",
|
||||
limit: 12,
|
||||
expected: ["Hello)", "world (ok)"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("chunkText", () => {
|
||||
it("keeps multi-line text in one chunk when under limit", () => {
|
||||
const text = "Line one\n\nLine two\n\nLine three";
|
||||
@@ -68,11 +107,7 @@ describe("chunkText", () => {
|
||||
expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]);
|
||||
});
|
||||
|
||||
it("keeps parenthetical phrases together", () => {
|
||||
const text = "Heads up now (Though now I'm curious)ok";
|
||||
const chunks = chunkText(text, 35);
|
||||
expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
|
||||
});
|
||||
runChunkCases(chunkText, [parentheticalCases[0]]);
|
||||
});
|
||||
|
||||
describe("resolveTextChunkLimit", () => {
|
||||
@@ -191,17 +226,7 @@ describe("chunkMarkdownText", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps parenthetical phrases together", () => {
|
||||
const text = "Heads up now (Though now I'm curious)ok";
|
||||
const chunks = chunkMarkdownText(text, 35);
|
||||
expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
|
||||
});
|
||||
|
||||
it("handles nested parentheses", () => {
|
||||
const text = "Hello (outer (inner) end) world";
|
||||
const chunks = chunkMarkdownText(text, 26);
|
||||
expect(chunks).toEqual(["Hello (outer (inner) end)", "world"]);
|
||||
});
|
||||
runChunkCases(chunkMarkdownText, parentheticalCases);
|
||||
|
||||
it("hard-breaks when a parenthetical exceeds the limit", () => {
|
||||
const text = `(${"a".repeat(80)})`;
|
||||
@@ -209,10 +234,4 @@ describe("chunkMarkdownText", () => {
|
||||
expect(chunks[0]?.length).toBe(20);
|
||||
expect(chunks.join("")).toBe(text);
|
||||
});
|
||||
|
||||
it("ignores unmatched closing parentheses", () => {
|
||||
const text = "Hello) world (ok)";
|
||||
const chunks = chunkMarkdownText(text, 12);
|
||||
expect(chunks).toEqual(["Hello)", "world (ok)"]);
|
||||
});
|
||||
});
|
||||
+17
-21
@@ -91,23 +91,7 @@ export function chunkText(text: string, limit: number): string[] {
|
||||
const window = remaining.slice(0, limit);
|
||||
|
||||
// 1) Prefer a newline break inside the window (outside parentheses).
|
||||
let lastNewline = -1;
|
||||
let lastWhitespace = -1;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
const char = window[i];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ")" && depth > 0) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
if (depth !== 0) continue;
|
||||
if (char === "\n") lastNewline = i;
|
||||
else if (/\s/.test(char)) lastWhitespace = i;
|
||||
}
|
||||
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
|
||||
|
||||
// 2) Otherwise prefer the last whitespace (word boundary) inside the window.
|
||||
let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace;
|
||||
@@ -243,12 +227,26 @@ function pickSafeBreakIndex(
|
||||
window: string,
|
||||
spans: ReturnType<typeof parseFenceSpans>,
|
||||
): number {
|
||||
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(
|
||||
window,
|
||||
(index) => isSafeFenceBreak(spans, index),
|
||||
);
|
||||
|
||||
if (lastNewline > 0) return lastNewline;
|
||||
if (lastWhitespace > 0) return lastWhitespace;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function scanParenAwareBreakpoints(
|
||||
window: string,
|
||||
isAllowed: (index: number) => boolean = () => true,
|
||||
): { lastNewline: number; lastWhitespace: number } {
|
||||
let lastNewline = -1;
|
||||
let lastWhitespace = -1;
|
||||
let depth = 0;
|
||||
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
if (!isSafeFenceBreak(spans, i)) continue;
|
||||
if (!isAllowed(i)) continue;
|
||||
const char = window[i];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
@@ -263,7 +261,5 @@ function pickSafeBreakIndex(
|
||||
else if (/\s/.test(char)) lastWhitespace = i;
|
||||
}
|
||||
|
||||
if (lastNewline > 0) return lastNewline;
|
||||
if (lastWhitespace > 0) return lastWhitespace;
|
||||
return -1;
|
||||
return { lastNewline, lastWhitespace };
|
||||
}
|
||||
@@ -107,6 +107,12 @@ describe("extractModelDirective", () => {
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("preserves spacing when /model is followed by a path segment", () => {
|
||||
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
|
||||
expect(result.hasDirective).toBe(true);
|
||||
expect(result.cleaned).toBe("thats not /hello");
|
||||
});
|
||||
|
||||
it("handles alias with special regex characters", () => {
|
||||
const result = extractModelDirective("/test.alias", {
|
||||
aliases: ["test.alias"],
|
||||
|
||||
@@ -42,7 +42,7 @@ export function extractModelDirective(
|
||||
}
|
||||
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractStatusDirective } from "./reply/directives.js";
|
||||
import {
|
||||
extractElevatedDirective,
|
||||
extractQueueDirective,
|
||||
@@ -119,6 +120,30 @@ describe("directive parsing", () => {
|
||||
expect(res.cleaned).toBe("please now");
|
||||
});
|
||||
|
||||
it("preserves spacing when stripping think directives before paths", () => {
|
||||
const res = extractThinkDirective("thats not /think high/tmp/hello");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.cleaned).toBe("thats not /tmp/hello");
|
||||
});
|
||||
|
||||
it("preserves spacing when stripping verbose directives before paths", () => {
|
||||
const res = extractVerboseDirective("thats not /verbose on/tmp/hello");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.cleaned).toBe("thats not /tmp/hello");
|
||||
});
|
||||
|
||||
it("preserves spacing when stripping reasoning directives before paths", () => {
|
||||
const res = extractReasoningDirective("thats not /reasoning on/tmp/hello");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.cleaned).toBe("thats not /tmp/hello");
|
||||
});
|
||||
|
||||
it("preserves spacing when stripping status directives before paths", () => {
|
||||
const res = extractStatusDirective("thats not /status:/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",
|
||||
|
||||
@@ -340,6 +340,132 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores elevated directive in groups when not mentioned", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated on",
|
||||
From: "group:123@g.us",
|
||||
To: "whatsapp:+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
ChatType: "group",
|
||||
WasMentioned: false,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("ok");
|
||||
expect(text).not.toContain("Elevated mode enabled");
|
||||
});
|
||||
});
|
||||
|
||||
it("allows elevated directive in groups when mentioned", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated on",
|
||||
From: "group:123@g.us",
|
||||
To: "whatsapp:+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
ChatType: "group",
|
||||
WasMentioned: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode enabled");
|
||||
|
||||
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
|
||||
const store = JSON.parse(storeRaw) as Record<
|
||||
string,
|
||||
{ elevatedLevel?: string }
|
||||
>;
|
||||
expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe(
|
||||
"on",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("allows elevated directive in direct chats without mentions", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated on",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode enabled");
|
||||
|
||||
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
|
||||
const store = JSON.parse(storeRaw) as Record<
|
||||
string,
|
||||
{ elevatedLevel?: string }
|
||||
>;
|
||||
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores inline elevated directive for unapproved sender", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
|
||||
@@ -329,8 +329,10 @@ export async function getReplyFromConfig(
|
||||
.map((entry) => entry.alias?.trim())
|
||||
.filter((alias): alias is string => Boolean(alias))
|
||||
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
|
||||
const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true;
|
||||
let parsedDirectives = parseInlineDirectives(rawBody, {
|
||||
modelAliases: configuredAliases,
|
||||
disableElevated: disableElevatedInGroup,
|
||||
});
|
||||
const hasDirective =
|
||||
parsedDirectives.hasThinkDirective ||
|
||||
@@ -342,7 +344,9 @@ export async function getReplyFromConfig(
|
||||
parsedDirectives.hasQueueDirective;
|
||||
if (hasDirective) {
|
||||
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||
const noMentions = isGroup
|
||||
? stripMentions(stripped, ctx, cfg, agentId)
|
||||
: stripped;
|
||||
if (noMentions.trim().length > 0) {
|
||||
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
|
||||
}
|
||||
@@ -467,6 +471,7 @@ export async function getReplyFromConfig(
|
||||
cleanedBody: directives.cleaned,
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
isGroup,
|
||||
})
|
||||
) {
|
||||
@@ -549,6 +554,7 @@ export async function getReplyFromConfig(
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
@@ -579,6 +585,7 @@ export async function getReplyFromConfig(
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
agentId,
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
|
||||
@@ -126,11 +126,12 @@ function extractCompactInstructions(params: {
|
||||
rawBody?: string;
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
isGroup: boolean;
|
||||
}): string | undefined {
|
||||
const raw = stripStructuralPrefixes(params.rawBody ?? "");
|
||||
const stripped = params.isGroup
|
||||
? stripMentions(raw, params.ctx, params.cfg)
|
||||
? stripMentions(raw, params.ctx, params.cfg, params.agentId)
|
||||
: raw;
|
||||
const trimmed = stripped.trim();
|
||||
if (!trimmed) return undefined;
|
||||
@@ -145,12 +146,14 @@ function extractCompactInstructions(params: {
|
||||
export function buildCommandContext(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
isGroup: boolean;
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandContext {
|
||||
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
|
||||
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } =
|
||||
params;
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
@@ -162,7 +165,9 @@ export function buildCommandContext(params: {
|
||||
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = normalizeCommandBody(
|
||||
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized,
|
||||
isGroup
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg, agentId)
|
||||
: rawBodyNormalized,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -207,6 +212,7 @@ export async function handleCommands(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
command: CommandContext;
|
||||
agentId?: string;
|
||||
directives: InlineDirectives;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
@@ -542,6 +548,7 @@ export async function handleCommands(params: {
|
||||
rawBody: ctx.Body,
|
||||
ctx,
|
||||
cfg,
|
||||
agentId: params.agentId,
|
||||
isGroup,
|
||||
});
|
||||
const result = await compactEmbeddedPiSession({
|
||||
|
||||
@@ -184,7 +184,7 @@ export type InlineDirectives = {
|
||||
|
||||
export function parseInlineDirectives(
|
||||
body: string,
|
||||
options?: { modelAliases?: string[] },
|
||||
options?: { modelAliases?: string[]; disableElevated?: boolean },
|
||||
): InlineDirectives {
|
||||
const {
|
||||
cleaned: thinkCleaned,
|
||||
@@ -209,7 +209,14 @@ export function parseInlineDirectives(
|
||||
elevatedLevel,
|
||||
rawLevel: rawElevatedLevel,
|
||||
hasDirective: hasElevatedDirective,
|
||||
} = extractElevatedDirective(reasoningCleaned);
|
||||
} = options?.disableElevated
|
||||
? {
|
||||
cleaned: reasoningCleaned,
|
||||
elevatedLevel: undefined,
|
||||
rawLevel: undefined,
|
||||
hasDirective: false,
|
||||
}
|
||||
: extractElevatedDirective(reasoningCleaned);
|
||||
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
|
||||
extractStatusDirective(elevatedCleaned);
|
||||
const {
|
||||
@@ -272,9 +279,10 @@ export function isDirectiveOnly(params: {
|
||||
cleanedBody: string;
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
isGroup: boolean;
|
||||
}): boolean {
|
||||
const { directives, cleanedBody, ctx, cfg, isGroup } = params;
|
||||
const { directives, cleanedBody, ctx, cfg, agentId, isGroup } = params;
|
||||
if (
|
||||
!directives.hasThinkDirective &&
|
||||
!directives.hasVerboseDirective &&
|
||||
@@ -285,7 +293,9 @@ export function isDirectiveOnly(params: {
|
||||
)
|
||||
return false;
|
||||
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||
const noMentions = isGroup
|
||||
? stripMentions(stripped, ctx, cfg, agentId)
|
||||
: stripped;
|
||||
return noMentions.length === 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ const extractLevelDirective = <T>(
|
||||
const level = normalize(rawLevel);
|
||||
const cleaned = body
|
||||
.slice(0, match.start)
|
||||
.concat(" ")
|
||||
.concat(body.slice(match.end))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
@@ -76,7 +77,7 @@ const extractSimpleDirective = (
|
||||
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"),
|
||||
);
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
return {
|
||||
cleaned,
|
||||
|
||||
@@ -27,4 +27,20 @@ describe("mention helpers", () => {
|
||||
});
|
||||
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns when configured", () => {
|
||||
const regexes = buildMentionRegexes(
|
||||
{
|
||||
routing: {
|
||||
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
||||
agents: {
|
||||
work: { mentionPatterns: ["\\bworkbot\\b"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
"work",
|
||||
);
|
||||
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
||||
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,23 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] {
|
||||
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
|
||||
function resolveMentionPatterns(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
agentId?: string,
|
||||
): string[] {
|
||||
if (!cfg) return [];
|
||||
const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined;
|
||||
if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) {
|
||||
return agentConfig.mentionPatterns ?? [];
|
||||
}
|
||||
return cfg.routing?.groupChat?.mentionPatterns ?? [];
|
||||
}
|
||||
|
||||
export function buildMentionRegexes(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
agentId?: string,
|
||||
): RegExp[] {
|
||||
const patterns = resolveMentionPatterns(cfg, agentId);
|
||||
return patterns
|
||||
.map((pattern) => {
|
||||
try {
|
||||
@@ -48,9 +63,10 @@ export function stripMentions(
|
||||
text: string,
|
||||
ctx: MsgContext,
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
agentId?: string,
|
||||
): string {
|
||||
let result = text;
|
||||
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
|
||||
const patterns = resolveMentionPatterns(cfg, agentId);
|
||||
for (const p of patterns) {
|
||||
try {
|
||||
const re = new RegExp(p, "gi");
|
||||
|
||||
@@ -271,8 +271,9 @@ export function extractQueueDirective(body?: string): {
|
||||
const argsStart = start + "/queue".length;
|
||||
const args = body.slice(argsStart);
|
||||
const parsed = parseQueueDirectiveArgs(args);
|
||||
const cleanedRaw =
|
||||
body.slice(0, start) + body.slice(argsStart + parsed.consumed);
|
||||
const cleanedRaw = `${body.slice(0, start)} ${body.slice(
|
||||
argsStart + parsed.consumed,
|
||||
)}`;
|
||||
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
|
||||
return {
|
||||
cleaned,
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function initSessionState(params: {
|
||||
// web inbox before we get here. They prevented reset triggers like "/new"
|
||||
// from matching, so strip structural wrappers when checking for resets.
|
||||
const strippedForReset = isGroup
|
||||
? stripMentions(triggerBodyNormalized, ctx, cfg)
|
||||
? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
|
||||
: triggerBodyNormalized;
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
|
||||
+13
-9
@@ -32,9 +32,11 @@ import {
|
||||
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
||||
import { findLegacyGatewayServices } from "../daemon/legacy.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
|
||||
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { resolveGatewayBindHost } from "../gateway/net.js";
|
||||
import {
|
||||
@@ -807,25 +809,27 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: runtimeRaw,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: runtimeRaw,
|
||||
nodePath,
|
||||
});
|
||||
const environment: Record<string, string | undefined> = {
|
||||
PATH: process.env.PATH,
|
||||
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
|
||||
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
|
||||
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
|
||||
CLAWDBOT_GATEWAY_PORT: String(port),
|
||||
CLAWDBOT_GATEWAY_TOKEN:
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token:
|
||||
opts.token ||
|
||||
cfg.gateway?.auth?.token ||
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
CLAWDBOT_LAUNCHD_LABEL:
|
||||
launchdLabel:
|
||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await service.install({
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listProviderPairingRequests = vi.fn();
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
listProviderPairingRequests,
|
||||
approveProviderPairingCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telegram/send.js", () => ({
|
||||
sendMessageTelegram: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../discord/send.js", () => ({
|
||||
sendMessageDiscord: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../slack/send.js", () => ({
|
||||
sendMessageSlack: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../signal/send.js", () => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../imessage/send.js", () => ({
|
||||
sendMessageIMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../telegram/token.js", () => ({
|
||||
resolveTelegramToken: vi.fn().mockReturnValue({ token: "t" }),
|
||||
}));
|
||||
|
||||
describe("pairing cli", () => {
|
||||
it("labels Telegram ids as telegramUserId", async () => {
|
||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
||||
listProviderPairingRequests.mockResolvedValueOnce([
|
||||
{
|
||||
id: "123",
|
||||
code: "ABC123",
|
||||
createdAt: "2026-01-08T00:00:00Z",
|
||||
lastSeenAt: "2026-01-08T00:00:00Z",
|
||||
meta: { username: "peter" },
|
||||
},
|
||||
]);
|
||||
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerPairingCli(program);
|
||||
await program.parseAsync(["pairing", "list", "--provider", "telegram"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("telegramUserId=123"),
|
||||
);
|
||||
});
|
||||
|
||||
it("labels Discord ids as discordUserId", async () => {
|
||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
||||
listProviderPairingRequests.mockResolvedValueOnce([
|
||||
{
|
||||
id: "999",
|
||||
code: "DEF456",
|
||||
createdAt: "2026-01-08T00:00:00Z",
|
||||
lastSeenAt: "2026-01-08T00:00:00Z",
|
||||
meta: { tag: "Ada#0001" },
|
||||
},
|
||||
]);
|
||||
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerPairingCli(program);
|
||||
await program.parseAsync(["pairing", "list", "--provider", "discord"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("discordUserId=999"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { Command } from "commander";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { sendMessageDiscord } from "../discord/send.js";
|
||||
import { sendMessageIMessage } from "../imessage/send.js";
|
||||
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
|
||||
import {
|
||||
approveProviderPairingCode,
|
||||
listProviderPairingRequests,
|
||||
@@ -93,8 +94,9 @@ export function registerPairingCli(program: Command) {
|
||||
}
|
||||
for (const r of requests) {
|
||||
const meta = r.meta ? JSON.stringify(r.meta) : "";
|
||||
const idLabel = PROVIDER_ID_LABELS[provider];
|
||||
console.log(
|
||||
`${r.code} id=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
|
||||
`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -955,12 +955,15 @@ export async function agentsAddCommand(
|
||||
initialValue: false,
|
||||
});
|
||||
if (wantsAuth) {
|
||||
const authStore = ensureAuthProfileStore(agentDir);
|
||||
const authStore = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const authChoice = (await prompter.select({
|
||||
message: "Model/auth choice",
|
||||
options: buildAuthChoiceOptions({
|
||||
store: authStore,
|
||||
includeSkip: true,
|
||||
includeClaudeCliIfMissing: true,
|
||||
}),
|
||||
})) as AuthChoice;
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
|
||||
|
||||
describe("buildAuthChoiceOptions", () => {
|
||||
it("includes Claude CLI option on macOS even when missing", () => {
|
||||
const store: AuthProfileStore = { version: 1, profiles: {} };
|
||||
const options = buildAuthChoiceOptions({
|
||||
store,
|
||||
includeSkip: false,
|
||||
includeClaudeCliIfMissing: true,
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
const claudeCli = options.find((opt) => opt.value === "claude-cli");
|
||||
expect(claudeCli).toBeDefined();
|
||||
expect(claudeCli?.hint).toBe("requires Keychain access");
|
||||
});
|
||||
|
||||
it("skips missing Claude CLI option off macOS", () => {
|
||||
const store: AuthProfileStore = { version: 1, profiles: {} };
|
||||
const options = buildAuthChoiceOptions({
|
||||
store,
|
||||
includeSkip: false,
|
||||
includeClaudeCliIfMissing: true,
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
expect(options.find((opt) => opt.value === "claude-cli")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses token hint when Claude CLI credentials exist", () => {
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[CLAUDE_CLI_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const options = buildAuthChoiceOptions({
|
||||
store,
|
||||
includeSkip: false,
|
||||
includeClaudeCliIfMissing: true,
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
const claudeCli = options.find((opt) => opt.value === "claude-cli");
|
||||
expect(claudeCli?.hint).toContain("token ok");
|
||||
});
|
||||
});
|
||||
@@ -45,8 +45,11 @@ function formatOAuthHint(
|
||||
export function buildAuthChoiceOptions(params: {
|
||||
store: AuthProfileStore;
|
||||
includeSkip: boolean;
|
||||
includeClaudeCliIfMissing?: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
}): AuthChoiceOption[] {
|
||||
const options: AuthChoiceOption[] = [];
|
||||
const platform = params.platform ?? process.platform;
|
||||
|
||||
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
|
||||
if (codexCli?.type === "oauth") {
|
||||
@@ -64,6 +67,12 @@ export function buildAuthChoiceOptions(params: {
|
||||
label: "Anthropic OAuth (Claude CLI)",
|
||||
hint: formatOAuthHint(claudeCli.expires),
|
||||
});
|
||||
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
|
||||
options.push({
|
||||
value: "claude-cli",
|
||||
label: "Anthropic OAuth (Claude CLI)",
|
||||
hint: "requires Keychain access",
|
||||
});
|
||||
}
|
||||
|
||||
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
|
||||
|
||||
@@ -168,10 +168,39 @@ export async function applyAuthChoice(params: {
|
||||
);
|
||||
}
|
||||
} else if (params.authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
|
||||
if (!hasClaudeCli && process.platform === "darwin") {
|
||||
await params.prompter.note(
|
||||
"No Claude CLI credentials found at ~/.claude/.credentials.json.",
|
||||
[
|
||||
"macOS will show a Keychain prompt next.",
|
||||
'Choose "Always Allow" so the launchd gateway can start without prompts.',
|
||||
'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.',
|
||||
].join("\n"),
|
||||
"Claude CLI Keychain",
|
||||
);
|
||||
const proceed = await params.prompter.confirm({
|
||||
message: "Check Keychain for Claude CLI credentials now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!proceed) {
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
}
|
||||
|
||||
const storeWithKeychain = hasClaudeCli
|
||||
? store
|
||||
: ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: true,
|
||||
});
|
||||
|
||||
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
process.platform === "darwin"
|
||||
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
|
||||
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
|
||||
"Claude CLI OAuth",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
} from "../config/config.js";
|
||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -284,8 +286,11 @@ async function promptAuthConfig(
|
||||
await select({
|
||||
message: "Model/auth choice",
|
||||
options: buildAuthChoiceOptions({
|
||||
store: ensureAuthProfileStore(),
|
||||
store: ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
}),
|
||||
includeSkip: true,
|
||||
includeClaudeCliIfMissing: true,
|
||||
}),
|
||||
}),
|
||||
runtime,
|
||||
@@ -611,18 +616,24 @@ async function maybeInstallDaemon(params: {
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port: params.port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
const environment: Record<string, string | undefined> = {
|
||||
PATH: process.env.PATH,
|
||||
CLAWDBOT_GATEWAY_TOKEN: params.gatewayToken,
|
||||
CLAWDBOT_LAUNCHD_LABEL:
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port: params.port,
|
||||
token: params.gatewayToken,
|
||||
launchdLabel:
|
||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||
};
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
|
||||
@@ -10,7 +10,7 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
|
||||
{
|
||||
value: "node",
|
||||
label: "Node (recommended)",
|
||||
hint: "Required for WhatsApp (Baileys WebSocket). Bun can corrupt memory on reconnect.",
|
||||
hint: "Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -14,8 +14,16 @@ import {
|
||||
uninstallLegacyGatewayServices,
|
||||
} from "../daemon/legacy.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodePath,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
|
||||
import {
|
||||
auditGatewayServiceConfig,
|
||||
needsNodeRuntimeMigration,
|
||||
} from "../daemon/service-audit.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
@@ -103,19 +111,24 @@ export async function maybeMigrateLegacyGatewayService(
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const port = resolveGatewayPort(cfg, process.env);
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
const environment: Record<string, string | undefined> = {
|
||||
PATH: process.env.PATH,
|
||||
CLAWDBOT_GATEWAY_TOKEN:
|
||||
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
CLAWDBOT_LAUNCHD_LABEL:
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||
};
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
@@ -170,9 +183,6 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
const aggressiveIssues = audit.issues.filter(
|
||||
(issue) => issue.level === "aggressive",
|
||||
);
|
||||
const _recommendedIssues = audit.issues.filter(
|
||||
(issue) => issue.level !== "aggressive",
|
||||
);
|
||||
const needsAggressive = aggressiveIssues.length > 0;
|
||||
|
||||
if (needsAggressive && !prompter.shouldForce) {
|
||||
@@ -194,6 +204,17 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
});
|
||||
if (!repair) return;
|
||||
|
||||
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
|
||||
const systemNodePath = needsNodeRuntime
|
||||
? await resolveSystemNodePath(process.env)
|
||||
: null;
|
||||
if (needsNodeRuntime && !systemNodePath) {
|
||||
note(
|
||||
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
|
||||
"Gateway runtime",
|
||||
);
|
||||
}
|
||||
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
@@ -203,19 +224,16 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: runtimeChoice,
|
||||
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
||||
nodePath: systemNodePath ?? undefined,
|
||||
});
|
||||
const environment: Record<string, string | undefined> = {
|
||||
PATH: process.env.PATH,
|
||||
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
|
||||
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
|
||||
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
|
||||
CLAWDBOT_GATEWAY_PORT: String(port),
|
||||
CLAWDBOT_GATEWAY_TOKEN:
|
||||
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
CLAWDBOT_LAUNCHD_LABEL:
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await service.install({
|
||||
|
||||
@@ -94,6 +94,7 @@ const serviceIsLoaded = vi.fn().mockResolvedValue(false);
|
||||
const serviceStop = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
||||
const callGateway = vi.fn().mockRejectedValue(new Error("gateway closed"));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
confirm,
|
||||
@@ -133,6 +134,14 @@ vi.mock("../daemon/program-args.js", () => ({
|
||||
resolveGatewayProgramArguments,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/call.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../gateway/call.js")>();
|
||||
return {
|
||||
...actual,
|
||||
callGateway,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runExec,
|
||||
runCommandWithTimeout,
|
||||
|
||||
+39
-10
@@ -12,9 +12,12 @@ import {
|
||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
@@ -237,6 +240,30 @@ export async function doctorCommand(
|
||||
}
|
||||
}
|
||||
|
||||
if (healthOk) {
|
||||
try {
|
||||
const status = await callGateway<Record<string, unknown>>({
|
||||
method: "providers.status",
|
||||
params: { probe: true, timeoutMs: 5000 },
|
||||
timeoutMs: 6000,
|
||||
});
|
||||
const issues = collectProvidersStatusIssues(status);
|
||||
if (issues.length > 0) {
|
||||
note(
|
||||
issues
|
||||
.map(
|
||||
(issue) =>
|
||||
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
|
||||
)
|
||||
.join("\n"),
|
||||
"Provider warnings",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore: doctor already reported gateway health
|
||||
}
|
||||
}
|
||||
|
||||
if (!healthOk) {
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({ env: process.env });
|
||||
@@ -280,25 +307,27 @@ export async function doctorCommand(
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const port = resolveGatewayPort(cfg, process.env);
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
const environment: Record<string, string | undefined> = {
|
||||
PATH: process.env.PATH,
|
||||
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
|
||||
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
|
||||
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
|
||||
CLAWDBOT_GATEWAY_PORT: String(port),
|
||||
CLAWDBOT_GATEWAY_TOKEN:
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token:
|
||||
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
CLAWDBOT_LAUNCHD_LABEL:
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? GATEWAY_LAUNCH_AGENT_LABEL
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
} from "../config/config.js";
|
||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
@@ -118,10 +120,14 @@ export async function runNonInteractiveOnboarding(
|
||||
mode: "api_key",
|
||||
});
|
||||
} else if (authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore();
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
runtime.error(
|
||||
"No Claude CLI credentials found at ~/.claude/.credentials.json",
|
||||
process.platform === "darwin"
|
||||
? 'No Claude CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".'
|
||||
: "No Claude CLI credentials found at ~/.claude/.credentials.json",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
@@ -272,18 +278,24 @@ export async function runNonInteractiveOnboarding(
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntimeRaw,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntimeRaw,
|
||||
nodePath,
|
||||
});
|
||||
const environment: Record<string, string | undefined> = {
|
||||
PATH: process.env.PATH,
|
||||
CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
|
||||
CLAWDBOT_LAUNCHD_LABEL:
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token: gatewayToken,
|
||||
launchdLabel:
|
||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||
};
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
|
||||
@@ -323,4 +323,112 @@ describe("providers command", () => {
|
||||
expect(whatsappIndex).toBeGreaterThan(-1);
|
||||
expect(telegramIndex).toBeLessThan(whatsappIndex);
|
||||
});
|
||||
|
||||
it("surfaces Discord privileged intent issues in providers status output", () => {
|
||||
const lines = formatGatewayProvidersStatusLines({
|
||||
discordAccounts: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
application: { intents: { messageContent: "limited" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/Message Content Intent is limited/i);
|
||||
expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/);
|
||||
});
|
||||
|
||||
it("surfaces Discord permission audit issues in providers status output", () => {
|
||||
const lines = formatGatewayProvidersStatusLines({
|
||||
discordAccounts: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
audit: {
|
||||
unresolvedChannels: 1,
|
||||
channels: [
|
||||
{
|
||||
channelId: "111",
|
||||
ok: false,
|
||||
missing: ["ViewChannel", "SendMessages"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/permission audit/i);
|
||||
expect(lines.join("\n")).toMatch(/Channel 111/i);
|
||||
});
|
||||
|
||||
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
|
||||
const lines = formatGatewayProvidersStatusLines({
|
||||
telegramAccounts: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
allowUnmentionedGroups: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
||||
});
|
||||
|
||||
it("surfaces Telegram group membership audit issues in providers status output", () => {
|
||||
const lines = formatGatewayProvidersStatusLines({
|
||||
telegramAccounts: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
audit: {
|
||||
hasWildcardUnmentionedGroups: true,
|
||||
unresolvedGroups: 1,
|
||||
groups: [
|
||||
{
|
||||
chatId: "-1001",
|
||||
ok: false,
|
||||
status: "left",
|
||||
error: "not in group",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
||||
expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
|
||||
expect(lines.join("\n")).toMatch(/Group -1001/i);
|
||||
});
|
||||
|
||||
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
|
||||
const unlinked = formatGatewayProvidersStatusLines({
|
||||
whatsappAccounts: [
|
||||
{ accountId: "default", enabled: true, linked: false },
|
||||
],
|
||||
});
|
||||
expect(unlinked.join("\n")).toMatch(/WhatsApp/i);
|
||||
expect(unlinked.join("\n")).toMatch(/Not linked/i);
|
||||
|
||||
const disconnected = formatGatewayProvidersStatusLines({
|
||||
whatsappAccounts: [
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
linked: true,
|
||||
running: true,
|
||||
connected: false,
|
||||
reconnectAttempts: 5,
|
||||
lastError: "connection closed",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(disconnected.join("\n")).toMatch(/disconnected/i);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveIMessageAccount,
|
||||
} from "../../imessage/accounts.js";
|
||||
import { formatAge } from "../../infra/provider-summary.js";
|
||||
import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js";
|
||||
import { listChatProviders } from "../../providers/registry.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import {
|
||||
@@ -74,6 +75,21 @@ export function formatGatewayProvidersStatusLines(
|
||||
if (typeof account.running === "boolean") {
|
||||
bits.push(account.running ? "running" : "stopped");
|
||||
}
|
||||
if (typeof account.connected === "boolean") {
|
||||
bits.push(account.connected ? "connected" : "disconnected");
|
||||
}
|
||||
const inboundAt =
|
||||
typeof account.lastInboundAt === "number" &&
|
||||
Number.isFinite(account.lastInboundAt)
|
||||
? account.lastInboundAt
|
||||
: null;
|
||||
const outboundAt =
|
||||
typeof account.lastOutboundAt === "number" &&
|
||||
Number.isFinite(account.lastOutboundAt)
|
||||
? account.lastOutboundAt
|
||||
: null;
|
||||
if (inboundAt) bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
|
||||
if (outboundAt) bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
|
||||
if (typeof account.mode === "string" && account.mode.length > 0) {
|
||||
bits.push(`mode:${account.mode}`);
|
||||
}
|
||||
@@ -98,6 +114,20 @@ export function formatGatewayProvidersStatusLines(
|
||||
) {
|
||||
bits.push(`app:${account.appTokenSource}`);
|
||||
}
|
||||
const application = account.application as
|
||||
| { intents?: { messageContent?: string } }
|
||||
| undefined;
|
||||
const messageContent = application?.intents?.messageContent;
|
||||
if (
|
||||
typeof messageContent === "string" &&
|
||||
messageContent.length > 0 &&
|
||||
messageContent !== "enabled"
|
||||
) {
|
||||
bits.push(`intents:content=${messageContent}`);
|
||||
}
|
||||
if (account.allowUnmentionedGroups === true) {
|
||||
bits.push("groups:unmentioned");
|
||||
}
|
||||
if (typeof account.baseUrl === "string" && account.baseUrl) {
|
||||
bits.push(`url:${account.baseUrl}`);
|
||||
}
|
||||
@@ -105,6 +135,10 @@ export function formatGatewayProvidersStatusLines(
|
||||
if (probe && typeof probe.ok === "boolean") {
|
||||
bits.push(probe.ok ? "works" : "probe failed");
|
||||
}
|
||||
const audit = account.audit as { ok?: boolean } | undefined;
|
||||
if (audit && typeof audit.ok === "boolean") {
|
||||
bits.push(audit.ok ? "audit ok" : "audit failed");
|
||||
}
|
||||
if (typeof account.lastError === "string" && account.lastError) {
|
||||
bits.push(`error:${account.lastError}`);
|
||||
}
|
||||
@@ -150,6 +184,17 @@ export function formatGatewayProvidersStatusLines(
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
const issues = collectProvidersStatusIssues(payload);
|
||||
if (issues.length > 0) {
|
||||
lines.push(theme.warn("Warnings:"));
|
||||
for (const issue of issues) {
|
||||
lines.push(
|
||||
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
|
||||
);
|
||||
}
|
||||
lines.push(`- Run: clawdbot doctor`);
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(
|
||||
`Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
|
||||
);
|
||||
@@ -329,10 +374,15 @@ export async function providersStatusCommand(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const timeoutMs = Number(opts.timeout ?? 10_000);
|
||||
const statusLabel = opts.probe
|
||||
? "Checking provider status (probe)…"
|
||||
: "Checking provider status…";
|
||||
const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
|
||||
if (shouldLogStatus) runtime.log(statusLabel);
|
||||
try {
|
||||
const payload = await withProgress(
|
||||
{
|
||||
label: "Checking provider status…",
|
||||
label: statusLabel,
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
|
||||
@@ -269,7 +269,7 @@ describe("config identity defaults", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not synthesize session when absent", async () => {
|
||||
it("does not synthesize agent/session when absent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
@@ -295,7 +295,7 @@ describe("config identity defaults", () => {
|
||||
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
|
||||
"\\b@?Samantha\\b",
|
||||
]);
|
||||
expect(cfg.agent?.contextPruning?.mode).toBe("adaptive");
|
||||
expect(cfg.agent).toBeUndefined();
|
||||
expect(cfg.session).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -327,6 +327,83 @@ describe("config identity defaults", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("config env vars", () => {
|
||||
it("applies env vars from env block when missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: { OPENROUTER_API_KEY: "config-key" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => {
|
||||
const { loadConfig } = await import("./config.js");
|
||||
loadConfig();
|
||||
expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override existing env vars", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: { OPENROUTER_API_KEY: "config-key" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await withEnvOverride(
|
||||
{ OPENROUTER_API_KEY: "existing-key" },
|
||||
async () => {
|
||||
const { loadConfig } = await import("./config.js");
|
||||
loadConfig();
|
||||
expect(process.env.OPENROUTER_API_KEY).toBe("existing-key");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies env vars from env.vars when missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: { vars: { GROQ_API_KEY: "gsk-config" } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await withEnvOverride({ GROQ_API_KEY: undefined }, async () => {
|
||||
const { loadConfig } = await import("./config.js");
|
||||
loadConfig();
|
||||
expect(process.env.GROQ_API_KEY).toBe("gsk-config");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("config pruning defaults", () => {
|
||||
it("defaults contextPruning mode to adaptive", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
@@ -352,11 +429,7 @@ describe("config pruning defaults", () => {
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{ agent: { contextPruning: { mode: "off" } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
JSON.stringify({ agent: { contextPruning: { mode: "off" } } }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ export function applyContextPruningDefaults(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const agent = cfg.agent;
|
||||
if (!agent) return cfg;
|
||||
const contextPruning = agent?.contextPruning;
|
||||
if (contextPruning?.mode) return cfg;
|
||||
|
||||
|
||||
+29
-1
@@ -13,11 +13,11 @@ import {
|
||||
findDuplicateAgentDirs,
|
||||
} from "./agent-dirs.js";
|
||||
import {
|
||||
applyContextPruningDefaults,
|
||||
applyIdentityDefaults,
|
||||
applyLoggingDefaults,
|
||||
applyMessageDefaults,
|
||||
applyModelDefaults,
|
||||
applyContextPruningDefaults,
|
||||
applySessionDefaults,
|
||||
applyTalkApiKey,
|
||||
} from "./defaults.js";
|
||||
@@ -41,6 +41,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
|
||||
"ANTHROPIC_OAUTH_TOKEN",
|
||||
"GEMINI_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"ELEVENLABS_API_KEY",
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
@@ -78,6 +79,31 @@ function warnOnConfigMiskeys(
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void {
|
||||
const envConfig = cfg.env;
|
||||
if (!envConfig) return;
|
||||
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
if (envConfig.vars) {
|
||||
for (const [key, value] of Object.entries(envConfig.vars)) {
|
||||
if (!value) continue;
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (key === "shellEnv" || key === "vars") continue;
|
||||
if (typeof value !== "string" || !value.trim()) continue;
|
||||
entries[key] = value;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (env[key]?.trim()) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
|
||||
if (deps.configPath) return deps.configPath;
|
||||
return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
|
||||
@@ -155,6 +181,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
throw new DuplicateAgentDirError(duplicates);
|
||||
}
|
||||
|
||||
applyConfigEnv(cfg, deps.env);
|
||||
|
||||
const enabled =
|
||||
shouldEnableShellEnvFallback(deps.env) ||
|
||||
cfg.env?.shellEnv?.enabled === true;
|
||||
|
||||
@@ -723,6 +723,8 @@ export type RoutingConfig = {
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string;
|
||||
/** Per-agent override for group mention patterns. */
|
||||
mentionPatterns?: string[];
|
||||
subagents?: {
|
||||
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
|
||||
allowAgents?: string[];
|
||||
@@ -1031,6 +1033,14 @@ export type ClawdbotConfig = {
|
||||
/** Timeout for the login shell exec (ms). Default: 15000. */
|
||||
timeoutMs?: number;
|
||||
};
|
||||
/** Inline env vars to apply when not already present in the process env. */
|
||||
vars?: Record<string, string>;
|
||||
/** Sugar: allow env vars directly under env (string values only). */
|
||||
[key: string]:
|
||||
| string
|
||||
| Record<string, string>
|
||||
| { enabled?: boolean; timeoutMs?: number }
|
||||
| undefined;
|
||||
};
|
||||
identity?: {
|
||||
name?: string;
|
||||
|
||||
@@ -641,6 +641,7 @@ const RoutingSchema = z
|
||||
workspace: z.string().optional(),
|
||||
agentDir: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
mentionPatterns: z.array(z.string()).optional(),
|
||||
subagents: z
|
||||
.object({
|
||||
allowAgents: z.array(z.string()).optional(),
|
||||
@@ -793,7 +794,9 @@ export const ClawdbotSchema = z.object({
|
||||
timeoutMs: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
vars: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.catchall(z.string())
|
||||
.optional(),
|
||||
identity: z
|
||||
.object({
|
||||
|
||||
@@ -450,6 +450,51 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram shorthand topic suffixes with messageThreadId", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "-1001234567890",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
provider: "telegram",
|
||||
to: "-1001234567890:321",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"-1001234567890",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ messageThreadId: 321 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers via discord when configured", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
|
||||
+131
-136
@@ -57,6 +57,12 @@ export type RunCronAgentTurnResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type DeliveryPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
function pickSummaryFromOutput(text: string | undefined) {
|
||||
const clean = (text ?? "").trim();
|
||||
if (!clean) return undefined;
|
||||
@@ -79,7 +85,7 @@ function pickSummaryFromPayloads(
|
||||
* Returns true if delivery should be skipped because there's no real content.
|
||||
*/
|
||||
function isHeartbeatOnlyResponse(
|
||||
payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>,
|
||||
payloads: DeliveryPayload[],
|
||||
ackMaxChars: number,
|
||||
) {
|
||||
if (payloads.length === 0) return true;
|
||||
@@ -96,6 +102,53 @@ function isHeartbeatOnlyResponse(
|
||||
return result.shouldSkip;
|
||||
});
|
||||
}
|
||||
|
||||
function getMediaList(payload: DeliveryPayload) {
|
||||
return payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
}
|
||||
|
||||
async function deliverPayloadsWithMedia(params: {
|
||||
payloads: DeliveryPayload[];
|
||||
sendText: (text: string) => Promise<unknown>;
|
||||
sendMedia: (caption: string, mediaUrl: string) => Promise<unknown>;
|
||||
}) {
|
||||
for (const payload of params.payloads) {
|
||||
const mediaList = getMediaList(payload);
|
||||
if (mediaList.length === 0) {
|
||||
await params.sendText(payload.text ?? "");
|
||||
continue;
|
||||
}
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.sendMedia(caption, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverChunkedPayloads(params: {
|
||||
payloads: DeliveryPayload[];
|
||||
chunkText: (text: string) => string[];
|
||||
sendText: (text: string) => Promise<unknown>;
|
||||
sendMedia: (caption: string, mediaUrl: string) => Promise<unknown>;
|
||||
}) {
|
||||
for (const payload of params.payloads) {
|
||||
const mediaList = getMediaList(payload);
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of params.chunkText(payload.text ?? "")) {
|
||||
await params.sendText(chunk);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.sendMedia(caption, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
function resolveDeliveryTarget(
|
||||
cfg: ClawdbotConfig,
|
||||
jobPayload: {
|
||||
@@ -143,28 +196,34 @@ function resolveDeliveryTarget(
|
||||
return lastProvider ?? "whatsapp";
|
||||
})();
|
||||
|
||||
const to = (() => {
|
||||
if (explicitTo) return explicitTo;
|
||||
return lastTo || undefined;
|
||||
})();
|
||||
const rawTo = explicitTo ?? (lastTo || undefined);
|
||||
const telegramTarget =
|
||||
provider === "telegram" && rawTo ? parseTelegramTarget(rawTo) : undefined;
|
||||
|
||||
const sanitizedWhatsappTo = (() => {
|
||||
if (provider !== "whatsapp") return to;
|
||||
if (provider !== "whatsapp") return rawTo;
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return to;
|
||||
if (rawAllow.includes("*")) return rawTo;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
.filter((val) => val.length > 1);
|
||||
if (allowFrom.length === 0) return to;
|
||||
if (!to) return allowFrom[0];
|
||||
const normalized = normalizeE164(to);
|
||||
if (allowFrom.length === 0) return rawTo;
|
||||
if (!rawTo) return allowFrom[0];
|
||||
const normalized = normalizeE164(rawTo);
|
||||
if (allowFrom.includes(normalized)) return normalized;
|
||||
return allowFrom[0];
|
||||
})();
|
||||
|
||||
const to = (() => {
|
||||
if (provider === "telegram" && telegramTarget) return telegramTarget.chatId;
|
||||
if (provider === "whatsapp") return sanitizedWhatsappTo;
|
||||
return rawTo;
|
||||
})();
|
||||
|
||||
return {
|
||||
provider,
|
||||
to: provider === "whatsapp" ? sanitizedWhatsappTo : to,
|
||||
to,
|
||||
messageThreadId: telegramTarget?.messageThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -455,21 +514,16 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
}
|
||||
const to = normalizeE164(resolvedDelivery.to);
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const primaryMedia = mediaList[0];
|
||||
await params.deps.sendMessageWhatsApp(to, payload.text ?? "", {
|
||||
verbose: false,
|
||||
mediaUrl: primaryMedia,
|
||||
});
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
await params.deps.sendMessageWhatsApp(to, "", {
|
||||
await deliverPayloadsWithMedia({
|
||||
payloads,
|
||||
sendText: (text) =>
|
||||
params.deps.sendMessageWhatsApp(to, text, { verbose: false }),
|
||||
sendMedia: (caption, mediaUrl) =>
|
||||
params.deps.sendMessageWhatsApp(to, caption, {
|
||||
verbose: false,
|
||||
mediaUrl: extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
mediaUrl,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver)
|
||||
return { status: "error", summary, error: String(err) };
|
||||
@@ -488,39 +542,27 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
summary: "Delivery skipped (no Telegram chatId).",
|
||||
};
|
||||
}
|
||||
const telegramTarget = parseTelegramTarget(resolvedDelivery.to);
|
||||
const chatId = telegramTarget.chatId;
|
||||
const messageThreadId = telegramTarget.messageThreadId;
|
||||
const chatId = resolvedDelivery.to;
|
||||
const messageThreadId = resolvedDelivery.messageThreadId;
|
||||
const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkMarkdownText(
|
||||
payload.text ?? "",
|
||||
textLimit,
|
||||
)) {
|
||||
await params.deps.sendMessageTelegram(chatId, chunk, {
|
||||
verbose: false,
|
||||
token: telegramToken || undefined,
|
||||
messageThreadId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.deps.sendMessageTelegram(chatId, caption, {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
token: telegramToken || undefined,
|
||||
messageThreadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await deliverChunkedPayloads({
|
||||
payloads,
|
||||
chunkText: (text) => chunkMarkdownText(text, textLimit),
|
||||
sendText: (text) =>
|
||||
params.deps.sendMessageTelegram(chatId, text, {
|
||||
verbose: false,
|
||||
token: telegramToken || undefined,
|
||||
messageThreadId,
|
||||
}),
|
||||
sendMedia: (caption, mediaUrl) =>
|
||||
params.deps.sendMessageTelegram(chatId, caption, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
token: telegramToken || undefined,
|
||||
messageThreadId,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver)
|
||||
return { status: "error", summary, error: String(err) };
|
||||
@@ -542,29 +584,18 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
}
|
||||
const discordTarget = resolvedDelivery.to;
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
if (mediaList.length === 0) {
|
||||
await params.deps.sendMessageDiscord(
|
||||
discordTarget,
|
||||
payload.text ?? "",
|
||||
{
|
||||
token: process.env.DISCORD_BOT_TOKEN,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.deps.sendMessageDiscord(discordTarget, caption, {
|
||||
token: process.env.DISCORD_BOT_TOKEN,
|
||||
mediaUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await deliverPayloadsWithMedia({
|
||||
payloads,
|
||||
sendText: (text) =>
|
||||
params.deps.sendMessageDiscord(discordTarget, text, {
|
||||
token: process.env.DISCORD_BOT_TOKEN,
|
||||
}),
|
||||
sendMedia: (caption, mediaUrl) =>
|
||||
params.deps.sendMessageDiscord(discordTarget, caption, {
|
||||
token: process.env.DISCORD_BOT_TOKEN,
|
||||
mediaUrl,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver)
|
||||
return { status: "error", summary, error: String(err) };
|
||||
@@ -587,27 +618,13 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
const slackTarget = resolvedDelivery.to;
|
||||
const textLimit = resolveTextChunkLimit(params.cfg, "slack");
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkMarkdownText(
|
||||
payload.text ?? "",
|
||||
textLimit,
|
||||
)) {
|
||||
await params.deps.sendMessageSlack(slackTarget, chunk);
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.deps.sendMessageSlack(slackTarget, caption, {
|
||||
mediaUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await deliverChunkedPayloads({
|
||||
payloads,
|
||||
chunkText: (text) => chunkMarkdownText(text, textLimit),
|
||||
sendText: (text) => params.deps.sendMessageSlack(slackTarget, text),
|
||||
sendMedia: (caption, mediaUrl) =>
|
||||
params.deps.sendMessageSlack(slackTarget, caption, { mediaUrl }),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver)
|
||||
return { status: "error", summary, error: String(err) };
|
||||
@@ -629,24 +646,13 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
const to = resolvedDelivery.to;
|
||||
const textLimit = resolveTextChunkLimit(params.cfg, "signal");
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(payload.text ?? "", textLimit)) {
|
||||
await params.deps.sendMessageSignal(to, chunk);
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.deps.sendMessageSignal(to, caption, {
|
||||
mediaUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await deliverChunkedPayloads({
|
||||
payloads,
|
||||
chunkText: (text) => chunkText(text, textLimit),
|
||||
sendText: (text) => params.deps.sendMessageSignal(to, text),
|
||||
sendMedia: (caption, mediaUrl) =>
|
||||
params.deps.sendMessageSignal(to, caption, { mediaUrl }),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver)
|
||||
return { status: "error", summary, error: String(err) };
|
||||
@@ -668,24 +674,13 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
const to = resolvedDelivery.to;
|
||||
const textLimit = resolveTextChunkLimit(params.cfg, "imessage");
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(payload.text ?? "", textLimit)) {
|
||||
await params.deps.sendMessageIMessage(to, chunk);
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.deps.sendMessageIMessage(to, caption, {
|
||||
mediaUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await deliverChunkedPayloads({
|
||||
payloads,
|
||||
chunkText: (text) => chunkText(text, textLimit),
|
||||
sendText: (text) => params.deps.sendMessageIMessage(to, text),
|
||||
sendMedia: (caption, mediaUrl) =>
|
||||
params.deps.sendMessageIMessage(to, caption, { mediaUrl }),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver)
|
||||
return { status: "error", summary, error: String(err) };
|
||||
|
||||
@@ -146,15 +146,16 @@ export async function resolveGatewayProgramArguments(params: {
|
||||
port: number;
|
||||
dev?: boolean;
|
||||
runtime?: GatewayRuntimePreference;
|
||||
nodePath?: string;
|
||||
}): Promise<GatewayProgramArgs> {
|
||||
const gatewayArgs = ["gateway", "--port", String(params.port)];
|
||||
const execPath = process.execPath;
|
||||
const runtime = params.runtime ?? "auto";
|
||||
|
||||
if (runtime === "node") {
|
||||
const nodePath = isNodeRuntime(execPath)
|
||||
? execPath
|
||||
: await resolveNodePath();
|
||||
const nodePath =
|
||||
params.nodePath ??
|
||||
(isNodeRuntime(execPath) ? execPath : await resolveNodePath());
|
||||
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
||||
return {
|
||||
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const VERSION_MANAGER_MARKERS = [
|
||||
"/.nvm/",
|
||||
"/.fnm/",
|
||||
"/.volta/",
|
||||
"/.asdf/",
|
||||
"/.n/",
|
||||
"/.nodenv/",
|
||||
"/.nodebrew/",
|
||||
"/nvs/",
|
||||
];
|
||||
|
||||
function getPathModule(platform: NodeJS.Platform) {
|
||||
return platform === "win32" ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function normalizeForCompare(input: string, platform: NodeJS.Platform): string {
|
||||
const pathModule = getPathModule(platform);
|
||||
const normalized = pathModule.normalize(input).replaceAll("\\", "/");
|
||||
if (platform === "win32") {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildSystemNodeCandidates(
|
||||
env: Record<string, string | undefined>,
|
||||
platform: NodeJS.Platform,
|
||||
): string[] {
|
||||
if (platform === "darwin") {
|
||||
return ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
|
||||
}
|
||||
if (platform === "linux") {
|
||||
return ["/usr/local/bin/node", "/usr/bin/node"];
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const pathModule = getPathModule(platform);
|
||||
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
|
||||
const programFilesX86 =
|
||||
env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
||||
return [
|
||||
pathModule.join(programFiles, "nodejs", "node.exe"),
|
||||
pathModule.join(programFilesX86, "nodejs", "node.exe"),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function isVersionManagedNodePath(
|
||||
nodePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
const normalized = normalizeForCompare(nodePath, platform);
|
||||
return VERSION_MANAGER_MARKERS.some((marker) => normalized.includes(marker));
|
||||
}
|
||||
|
||||
export function isSystemNodePath(
|
||||
nodePath: string,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
const normalized = normalizeForCompare(nodePath, platform);
|
||||
return buildSystemNodeCandidates(env, platform).some((candidate) => {
|
||||
const normalizedCandidate = normalizeForCompare(candidate, platform);
|
||||
return normalized === normalizedCandidate;
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveSystemNodePath(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<string | null> {
|
||||
const candidates = buildSystemNodeCandidates(env, platform);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// keep going
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolvePreferredNodePath(params: {
|
||||
env?: Record<string, string | undefined>;
|
||||
runtime?: string;
|
||||
}): Promise<string | undefined> {
|
||||
if (params.runtime !== "node") return undefined;
|
||||
const systemNode = await resolveSystemNodePath(params.env);
|
||||
return systemNode ?? undefined;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
auditGatewayServiceConfig,
|
||||
SERVICE_AUDIT_CODES,
|
||||
} from "./service-audit.js";
|
||||
|
||||
describe("auditGatewayServiceConfig", () => {
|
||||
it("flags bun runtime", async () => {
|
||||
const audit = await auditGatewayServiceConfig({
|
||||
env: { HOME: "/tmp" },
|
||||
platform: "darwin",
|
||||
command: {
|
||||
programArguments: ["/opt/homebrew/bin/bun", "gateway"],
|
||||
environment: { PATH: "/usr/bin:/bin" },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
audit.issues.some(
|
||||
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags version-managed node paths", async () => {
|
||||
const audit = await auditGatewayServiceConfig({
|
||||
env: { HOME: "/tmp" },
|
||||
platform: "darwin",
|
||||
command: {
|
||||
programArguments: [
|
||||
"/Users/test/.nvm/versions/node/v22.0.0/bin/node",
|
||||
"gateway",
|
||||
],
|
||||
environment: {
|
||||
PATH: "/usr/bin:/bin:/Users/test/.nvm/versions/node/v22.0.0/bin",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
audit.issues.some(
|
||||
(issue) =>
|
||||
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
audit.issues.some(
|
||||
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathNonMinimal,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
audit.issues.some(
|
||||
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+171
-6
@@ -1,5 +1,12 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveLaunchAgentPlistPath } from "./launchd.js";
|
||||
import {
|
||||
isSystemNodePath,
|
||||
isVersionManagedNodePath,
|
||||
resolveSystemNodePath,
|
||||
} from "./runtime-paths.js";
|
||||
import { getMinimalServicePathParts } from "./service-env.js";
|
||||
import { resolveSystemdUserUnitPath } from "./systemd.js";
|
||||
|
||||
export type GatewayServiceCommand = {
|
||||
@@ -21,6 +28,31 @@ export type ServiceConfigAudit = {
|
||||
issues: ServiceConfigIssue[];
|
||||
};
|
||||
|
||||
export const SERVICE_AUDIT_CODES = {
|
||||
gatewayCommandMissing: "gateway-command-missing",
|
||||
gatewayPathMissing: "gateway-path-missing",
|
||||
gatewayPathMissingDirs: "gateway-path-missing-dirs",
|
||||
gatewayPathNonMinimal: "gateway-path-nonminimal",
|
||||
gatewayRuntimeBun: "gateway-runtime-bun",
|
||||
gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
|
||||
gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing",
|
||||
launchdKeepAlive: "launchd-keep-alive",
|
||||
launchdRunAtLoad: "launchd-run-at-load",
|
||||
systemdAfterNetworkOnline: "systemd-after-network-online",
|
||||
systemdRestartSec: "systemd-restart-sec",
|
||||
systemdWantsNetworkOnline: "systemd-wants-network-online",
|
||||
} as const;
|
||||
|
||||
export function needsNodeRuntimeMigration(
|
||||
issues: ServiceConfigIssue[],
|
||||
): boolean {
|
||||
return issues.some(
|
||||
(issue) =>
|
||||
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun ||
|
||||
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
|
||||
);
|
||||
}
|
||||
|
||||
function hasGatewaySubcommand(programArguments?: string[]): boolean {
|
||||
return Boolean(programArguments?.some((arg) => arg === "gateway"));
|
||||
}
|
||||
@@ -82,7 +114,7 @@ async function auditSystemdUnit(
|
||||
const parsed = parseSystemdUnit(content);
|
||||
if (!parsed.after.has("network-online.target")) {
|
||||
issues.push({
|
||||
code: "systemd-after-network-online",
|
||||
code: SERVICE_AUDIT_CODES.systemdAfterNetworkOnline,
|
||||
message: "Missing systemd After=network-online.target",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
@@ -90,7 +122,7 @@ async function auditSystemdUnit(
|
||||
}
|
||||
if (!parsed.wants.has("network-online.target")) {
|
||||
issues.push({
|
||||
code: "systemd-wants-network-online",
|
||||
code: SERVICE_AUDIT_CODES.systemdWantsNetworkOnline,
|
||||
message: "Missing systemd Wants=network-online.target",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
@@ -98,7 +130,7 @@ async function auditSystemdUnit(
|
||||
}
|
||||
if (!isRestartSecPreferred(parsed.restartSec)) {
|
||||
issues.push({
|
||||
code: "systemd-restart-sec",
|
||||
code: SERVICE_AUDIT_CODES.systemdRestartSec,
|
||||
message: "RestartSec does not match the recommended 5s",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
@@ -122,7 +154,7 @@ async function auditLaunchdPlist(
|
||||
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
|
||||
if (!hasRunAtLoad) {
|
||||
issues.push({
|
||||
code: "launchd-run-at-load",
|
||||
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
|
||||
message: "LaunchAgent is missing RunAtLoad=true",
|
||||
detail: plistPath,
|
||||
level: "recommended",
|
||||
@@ -130,7 +162,7 @@ async function auditLaunchdPlist(
|
||||
}
|
||||
if (!hasKeepAlive) {
|
||||
issues.push({
|
||||
code: "launchd-keep-alive",
|
||||
code: SERVICE_AUDIT_CODES.launchdKeepAlive,
|
||||
message: "LaunchAgent is missing KeepAlive=true",
|
||||
detail: plistPath,
|
||||
level: "recommended",
|
||||
@@ -145,13 +177,144 @@ function auditGatewayCommand(
|
||||
if (!programArguments || programArguments.length === 0) return;
|
||||
if (!hasGatewaySubcommand(programArguments)) {
|
||||
issues.push({
|
||||
code: "gateway-command-missing",
|
||||
code: SERVICE_AUDIT_CODES.gatewayCommandMissing,
|
||||
message: "Service command does not include the gateway subcommand",
|
||||
level: "aggressive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeRuntime(execPath: string): boolean {
|
||||
const base = path.basename(execPath).toLowerCase();
|
||||
return base === "node" || base === "node.exe";
|
||||
}
|
||||
|
||||
function isBunRuntime(execPath: string): boolean {
|
||||
const base = path.basename(execPath).toLowerCase();
|
||||
return base === "bun" || base === "bun.exe";
|
||||
}
|
||||
|
||||
function getPathModule(platform: NodeJS.Platform) {
|
||||
return platform === "win32" ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function normalizePathEntry(entry: string, platform: NodeJS.Platform): string {
|
||||
const pathModule = getPathModule(platform);
|
||||
const normalized = pathModule.normalize(entry).replaceAll("\\", "/");
|
||||
if (platform === "win32") {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function auditGatewayServicePath(
|
||||
command: GatewayServiceCommand,
|
||||
issues: ServiceConfigIssue[],
|
||||
platform: NodeJS.Platform,
|
||||
) {
|
||||
if (platform === "win32") return;
|
||||
const servicePath = command?.environment?.PATH;
|
||||
if (!servicePath) {
|
||||
issues.push({
|
||||
code: SERVICE_AUDIT_CODES.gatewayPathMissing,
|
||||
message:
|
||||
"Gateway service PATH is not set; the daemon should use a minimal PATH.",
|
||||
level: "recommended",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = getMinimalServicePathParts({ platform });
|
||||
const parts = servicePath
|
||||
.split(getPathModule(platform).delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const normalizedParts = parts.map((entry) =>
|
||||
normalizePathEntry(entry, platform),
|
||||
);
|
||||
const missing = expected.filter((entry) => {
|
||||
const normalized = normalizePathEntry(entry, platform);
|
||||
return !normalizedParts.includes(normalized);
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
issues.push({
|
||||
code: SERVICE_AUDIT_CODES.gatewayPathMissingDirs,
|
||||
message: `Gateway service PATH missing required dirs: ${missing.join(", ")}`,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
|
||||
const nonMinimal = parts.filter((entry) => {
|
||||
const normalized = normalizePathEntry(entry, platform);
|
||||
return (
|
||||
normalized.includes("/.nvm/") ||
|
||||
normalized.includes("/.fnm/") ||
|
||||
normalized.includes("/.volta/") ||
|
||||
normalized.includes("/.asdf/") ||
|
||||
normalized.includes("/.n/") ||
|
||||
normalized.includes("/.nodenv/") ||
|
||||
normalized.includes("/.nodebrew/") ||
|
||||
normalized.includes("/nvs/") ||
|
||||
normalized.includes("/.local/share/pnpm/") ||
|
||||
normalized.includes("/pnpm/") ||
|
||||
normalized.endsWith("/pnpm")
|
||||
);
|
||||
});
|
||||
if (nonMinimal.length > 0) {
|
||||
issues.push({
|
||||
code: SERVICE_AUDIT_CODES.gatewayPathNonMinimal,
|
||||
message:
|
||||
"Gateway service PATH includes version managers or package managers; recommend a minimal PATH.",
|
||||
detail: nonMinimal.join(", "),
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function auditGatewayRuntime(
|
||||
env: Record<string, string | undefined>,
|
||||
command: GatewayServiceCommand,
|
||||
issues: ServiceConfigIssue[],
|
||||
platform: NodeJS.Platform,
|
||||
) {
|
||||
const execPath = command?.programArguments?.[0];
|
||||
if (!execPath) return;
|
||||
|
||||
if (isBunRuntime(execPath)) {
|
||||
issues.push({
|
||||
code: SERVICE_AUDIT_CODES.gatewayRuntimeBun,
|
||||
message:
|
||||
"Gateway service uses Bun; Bun is incompatible with WhatsApp + Telegram providers.",
|
||||
detail: execPath,
|
||||
level: "recommended",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNodeRuntime(execPath)) return;
|
||||
|
||||
if (isVersionManagedNodePath(execPath, platform)) {
|
||||
issues.push({
|
||||
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
|
||||
message:
|
||||
"Gateway service uses Node from a version manager; it can break after upgrades.",
|
||||
detail: execPath,
|
||||
level: "recommended",
|
||||
});
|
||||
if (!isSystemNodePath(execPath, env, platform)) {
|
||||
const systemNode = await resolveSystemNodePath(env, platform);
|
||||
if (!systemNode) {
|
||||
issues.push({
|
||||
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing,
|
||||
message:
|
||||
"System Node 22+ not found; install it before migrating away from version managers.",
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function auditGatewayServiceConfig(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
command: GatewayServiceCommand;
|
||||
@@ -161,6 +324,8 @@ export async function auditGatewayServiceConfig(params: {
|
||||
const platform = params.platform ?? process.platform;
|
||||
|
||||
auditGatewayCommand(params.command?.programArguments, issues);
|
||||
auditGatewayServicePath(params.command, issues, platform);
|
||||
await auditGatewayRuntime(params.env, params.command, issues, platform);
|
||||
|
||||
if (platform === "linux") {
|
||||
await auditSystemdUnit(params.env, issues);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMinimalServicePath,
|
||||
buildServiceEnvironment,
|
||||
} from "./service-env.js";
|
||||
|
||||
describe("buildMinimalServicePath", () => {
|
||||
it("includes Homebrew + system dirs on macOS", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
platform: "darwin",
|
||||
});
|
||||
const parts = result.split(path.delimiter);
|
||||
expect(parts).toContain("/opt/homebrew/bin");
|
||||
expect(parts).toContain("/usr/local/bin");
|
||||
expect(parts).toContain("/usr/bin");
|
||||
expect(parts).toContain("/bin");
|
||||
});
|
||||
|
||||
it("returns PATH as-is on Windows", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
env: { PATH: "C:\\\\Windows\\\\System32" },
|
||||
platform: "win32",
|
||||
});
|
||||
expect(result).toBe("C:\\\\Windows\\\\System32");
|
||||
});
|
||||
|
||||
it("includes extra directories when provided", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
platform: "linux",
|
||||
extraDirs: ["/custom/tools"],
|
||||
});
|
||||
expect(result.split(path.delimiter)).toContain("/custom/tools");
|
||||
});
|
||||
|
||||
it("deduplicates directories", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
platform: "linux",
|
||||
extraDirs: ["/usr/bin"],
|
||||
});
|
||||
const parts = result.split(path.delimiter);
|
||||
const unique = [...new Set(parts)];
|
||||
expect(parts.length).toBe(unique.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildServiceEnvironment", () => {
|
||||
it("sets minimal PATH and gateway vars", () => {
|
||||
const env = buildServiceEnvironment({
|
||||
env: { HOME: "/home/user" },
|
||||
port: 18789,
|
||||
token: "secret",
|
||||
});
|
||||
if (process.platform === "win32") {
|
||||
expect(env.PATH).toBe("");
|
||||
} else {
|
||||
expect(env.PATH).toContain("/usr/bin");
|
||||
}
|
||||
expect(env.CLAWDBOT_GATEWAY_PORT).toBe("18789");
|
||||
expect(env.CLAWDBOT_GATEWAY_TOKEN).toBe("secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import path from "node:path";
|
||||
|
||||
export type MinimalServicePathOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
extraDirs?: string[];
|
||||
};
|
||||
|
||||
type BuildServicePathOptions = MinimalServicePathOptions & {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
function resolveSystemPathDirs(platform: NodeJS.Platform): string[] {
|
||||
if (platform === "darwin") {
|
||||
return ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
|
||||
}
|
||||
if (platform === "linux") {
|
||||
return ["/usr/local/bin", "/usr/bin", "/bin"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getMinimalServicePathParts(
|
||||
options: MinimalServicePathOptions = {},
|
||||
): string[] {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === "win32") return [];
|
||||
|
||||
const parts: string[] = [];
|
||||
const extraDirs = options.extraDirs ?? [];
|
||||
const systemDirs = resolveSystemPathDirs(platform);
|
||||
|
||||
const add = (dir: string) => {
|
||||
if (!dir) return;
|
||||
if (!parts.includes(dir)) parts.push(dir);
|
||||
};
|
||||
|
||||
for (const dir of extraDirs) add(dir);
|
||||
for (const dir of systemDirs) add(dir);
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function buildMinimalServicePath(
|
||||
options: BuildServicePathOptions = {},
|
||||
): string {
|
||||
const env = options.env ?? process.env;
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === "win32") {
|
||||
return env.PATH ?? "";
|
||||
}
|
||||
|
||||
return getMinimalServicePathParts(options).join(path.delimiter);
|
||||
}
|
||||
|
||||
export function buildServiceEnvironment(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
port: number;
|
||||
token?: string;
|
||||
launchdLabel?: string;
|
||||
}): Record<string, string | undefined> {
|
||||
const { env, port, token, launchdLabel } = params;
|
||||
return {
|
||||
PATH: buildMinimalServicePath({ env }),
|
||||
CLAWDBOT_PROFILE: env.CLAWDBOT_PROFILE,
|
||||
CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
|
||||
CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
|
||||
CLAWDBOT_GATEWAY_PORT: String(port),
|
||||
CLAWDBOT_GATEWAY_TOKEN: token,
|
||||
CLAWDBOT_LAUNCHD_LABEL: launchdLabel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
fetchChannelPermissionsDiscord: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("discord audit", () => {
|
||||
it("collects numeric channel ids and counts unresolved keys", async () => {
|
||||
const { collectDiscordAuditChannelIds, auditDiscordChannelPermissions } =
|
||||
await import("./audit.js");
|
||||
const { fetchChannelPermissionsDiscord } = await import("./send.js");
|
||||
|
||||
const cfg = {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
"111": { allow: true },
|
||||
general: { allow: true },
|
||||
"222": { allow: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as import("../config/config.js").ClawdbotConfig;
|
||||
|
||||
const collected = collectDiscordAuditChannelIds({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(collected.channelIds).toEqual(["111"]);
|
||||
expect(collected.unresolvedChannels).toBe(1);
|
||||
|
||||
(
|
||||
fetchChannelPermissionsDiscord as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValueOnce({
|
||||
channelId: "111",
|
||||
permissions: ["ViewChannel"],
|
||||
raw: "0",
|
||||
isDm: false,
|
||||
});
|
||||
|
||||
const audit = await auditDiscordChannelPermissions({
|
||||
token: "t",
|
||||
accountId: "default",
|
||||
channelIds: collected.channelIds,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(audit.ok).toBe(false);
|
||||
expect(audit.channels[0]?.channelId).toBe("111");
|
||||
expect(audit.channels[0]?.missing).toContain("SendMessages");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type {
|
||||
DiscordGuildChannelConfig,
|
||||
DiscordGuildEntry,
|
||||
} from "../config/types.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||
|
||||
export type DiscordChannelPermissionsAuditEntry = {
|
||||
channelId: string;
|
||||
ok: boolean;
|
||||
missing?: string[];
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type DiscordChannelPermissionsAudit = {
|
||||
ok: boolean;
|
||||
checkedChannels: number;
|
||||
unresolvedChannels: number;
|
||||
channels: DiscordChannelPermissionsAuditEntry[];
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function shouldAuditChannelConfig(
|
||||
config: DiscordGuildChannelConfig | undefined,
|
||||
) {
|
||||
if (!config) return true;
|
||||
if (config.allow === false) return false;
|
||||
if (config.enabled === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function listConfiguredGuildChannelKeys(
|
||||
guilds: Record<string, DiscordGuildEntry> | undefined,
|
||||
): string[] {
|
||||
if (!guilds) return [];
|
||||
const ids = new Set<string>();
|
||||
for (const entry of Object.values(guilds)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const channelsRaw = (entry as { channels?: unknown }).channels;
|
||||
if (!isRecord(channelsRaw)) continue;
|
||||
for (const [key, value] of Object.entries(channelsRaw)) {
|
||||
const channelId = String(key).trim();
|
||||
if (!channelId) continue;
|
||||
if (
|
||||
!shouldAuditChannelConfig(
|
||||
value as DiscordGuildChannelConfig | undefined,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
ids.add(channelId);
|
||||
}
|
||||
}
|
||||
return [...ids].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function collectDiscordAuditChannelIds(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}) {
|
||||
const account = resolveDiscordAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const keys = listConfiguredGuildChannelKeys(account.config.guilds);
|
||||
const channelIds = keys.filter((key) => /^\d+$/.test(key));
|
||||
const unresolvedChannels = keys.length - channelIds.length;
|
||||
return { channelIds, unresolvedChannels };
|
||||
}
|
||||
|
||||
export async function auditDiscordChannelPermissions(params: {
|
||||
token: string;
|
||||
accountId?: string | null;
|
||||
channelIds: string[];
|
||||
timeoutMs: number;
|
||||
}): Promise<DiscordChannelPermissionsAudit> {
|
||||
const started = Date.now();
|
||||
const token = params.token?.trim() ?? "";
|
||||
if (!token || params.channelIds.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
checkedChannels: 0,
|
||||
unresolvedChannels: 0,
|
||||
channels: [],
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const required = [...REQUIRED_CHANNEL_PERMISSIONS];
|
||||
const channels: DiscordChannelPermissionsAuditEntry[] = [];
|
||||
|
||||
for (const channelId of params.channelIds) {
|
||||
try {
|
||||
const perms = await fetchChannelPermissionsDiscord(channelId, {
|
||||
token,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const missing = required.filter((p) => !perms.permissions.includes(p));
|
||||
channels.push({
|
||||
channelId,
|
||||
ok: missing.length === 0,
|
||||
missing: missing.length ? missing : undefined,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
channels.push({
|
||||
channelId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: channels.every((c) => c.ok),
|
||||
checkedChannels: channels.length,
|
||||
unresolvedChannels: 0,
|
||||
channels,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,31 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores gateway errors when instructed", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const onGatewayError = vi.fn();
|
||||
const abort = new AbortController();
|
||||
const err = new Error("transient");
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { emitter, disconnect },
|
||||
abortSignal: abort.signal,
|
||||
onGatewayError,
|
||||
shouldStopOnError: () => false,
|
||||
});
|
||||
|
||||
emitter.emit("error", err);
|
||||
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||
expect(disconnect).toHaveBeenCalledTimes(0);
|
||||
expect(emitter.listenerCount("error")).toBe(1);
|
||||
|
||||
abort.abort();
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves on abort without a gateway", async () => {
|
||||
const abort = new AbortController();
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ export async function waitForDiscordGatewayStop(params: {
|
||||
gateway?: DiscordGatewayHandle;
|
||||
abortSignal?: AbortSignal;
|
||||
onGatewayError?: (err: unknown) => void;
|
||||
shouldStopOnError?: (err: unknown) => boolean;
|
||||
}): Promise<void> {
|
||||
const { gateway, abortSignal, onGatewayError } = params;
|
||||
const { gateway, abortSignal, onGatewayError, shouldStopOnError } = params;
|
||||
const emitter = gateway?.emitter;
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -49,7 +50,10 @@ export async function waitForDiscordGatewayStop(params: {
|
||||
};
|
||||
const onGatewayErrorEvent = (err: unknown) => {
|
||||
onGatewayError?.(err);
|
||||
finishReject(err);
|
||||
const shouldStop = shouldStopOnError?.(err) ?? true;
|
||||
if (shouldStop) {
|
||||
finishReject(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const sendMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn();
|
||||
const dispatchMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const upsertPairingRequestMock = vi.fn();
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
|
||||
@@ -12,6 +14,12 @@ vi.mock("./send.js", () => ({
|
||||
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
|
||||
}));
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readProviderAllowFromStore: (...args: unknown[]) =>
|
||||
readAllowFromStoreMock(...args),
|
||||
upsertProviderPairingRequest: (...args: unknown[]) =>
|
||||
upsertPairingRequestMock(...args),
|
||||
}));
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
@@ -29,6 +37,10 @@ beforeEach(() => {
|
||||
dispatcher.sendFinalReply({ text: "hi" });
|
||||
return { queuedFinal: true, counts: { final: 1 } };
|
||||
});
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock
|
||||
.mockReset()
|
||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -99,6 +111,76 @@ describe("discord tool result dispatch", () => {
|
||||
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
|
||||
}, 10000);
|
||||
|
||||
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
|
||||
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,
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m1",
|
||||
content: "hello",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u2", bot: false, username: "Ada" },
|
||||
},
|
||||
author: { id: "u2", bot: false, username: "Ada" },
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Your Discord user id: u2",
|
||||
);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Pairing code: PAIRCODE",
|
||||
);
|
||||
}, 10000);
|
||||
|
||||
it("accepts guild messages when mentionPatterns match", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
|
||||
+60
-39
@@ -44,10 +44,12 @@ import { loadConfig } from "../config/config.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { formatDurationSeconds } from "../infra/format-duration.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||
import {
|
||||
readProviderAllowFromStore,
|
||||
upsertProviderPairingRequest,
|
||||
@@ -330,6 +332,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
},
|
||||
[
|
||||
new GatewayPlugin({
|
||||
reconnect: {
|
||||
maxAttempts: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
intents:
|
||||
GatewayIntents.Guilds |
|
||||
GatewayIntents.GuildMessages |
|
||||
@@ -408,18 +413,35 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal: opts.abortSignal,
|
||||
onGatewayError: (err) => {
|
||||
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||
},
|
||||
});
|
||||
const onGatewayWarning = (warning: unknown) => {
|
||||
logVerbose(`discord gateway warning: ${String(warning)}`);
|
||||
};
|
||||
if (shouldLogVerbose()) {
|
||||
gatewayEmitter?.on("warning", onGatewayWarning);
|
||||
}
|
||||
try {
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal: opts.abortSignal,
|
||||
onGatewayError: (err) => {
|
||||
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||
},
|
||||
shouldStopOnError: (err) => {
|
||||
const message = String(err);
|
||||
return (
|
||||
message.includes("Max reconnect attempts") ||
|
||||
message.includes("Fatal Gateway error")
|
||||
);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
gatewayEmitter?.removeListener("warning", onGatewayWarning);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDiscordNativeCommands(params: {
|
||||
@@ -479,7 +501,6 @@ export function createDiscordMessageHandler(params: {
|
||||
guildEntries,
|
||||
} = params;
|
||||
const logger = getChildLogger({ module: "discord-auto-reply" });
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const groupPolicy = discordConfig?.groupPolicy ?? "open";
|
||||
@@ -548,14 +569,11 @@ export function createDiscordMessageHandler(params: {
|
||||
try {
|
||||
await sendMessageDiscord(
|
||||
`user:${author.id}`,
|
||||
[
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider discord <code>",
|
||||
].join("\n"),
|
||||
buildPairingReply({
|
||||
provider: "discord",
|
||||
idLine: `Your Discord user id: ${author.id}`,
|
||||
code,
|
||||
}),
|
||||
{ token, rest: client.rest, accountId },
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -576,6 +594,22 @@ export function createDiscordMessageHandler(params: {
|
||||
}
|
||||
const botId = botUserId;
|
||||
const baseText = resolveDiscordMessageText(message);
|
||||
recordProviderActivity({
|
||||
provider: "discord",
|
||||
accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "discord",
|
||||
accountId,
|
||||
guildId: data.guild_id ?? undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
||||
id: isDirectMessage ? author.id : message.channelId,
|
||||
},
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const wasMentioned =
|
||||
!isDirectMessage &&
|
||||
(Boolean(
|
||||
@@ -647,16 +681,6 @@ export function createDiscordMessageHandler(params: {
|
||||
guildInfo?.slug ||
|
||||
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : "");
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "discord",
|
||||
accountId,
|
||||
guildId: data.guild_id ?? undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
||||
id: isDirectMessage ? author.id : message.channelId,
|
||||
},
|
||||
});
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const channelConfig = isGuildMessage
|
||||
? resolveDiscordChannelConfig({
|
||||
@@ -1384,14 +1408,11 @@ function createDiscordNativeCommand(params: {
|
||||
});
|
||||
if (created) {
|
||||
await interaction.reply({
|
||||
content: [
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider discord <code>",
|
||||
].join("\n"),
|
||||
content: buildPairingReply({
|
||||
provider: "discord",
|
||||
idLine: `Your Discord user id: ${user.id}`,
|
||||
code,
|
||||
}),
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveDiscordPrivilegedIntentsFromFlags } from "./probe.js";
|
||||
|
||||
describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
|
||||
it("reports disabled when no bits set", () => {
|
||||
expect(resolveDiscordPrivilegedIntentsFromFlags(0)).toEqual({
|
||||
presence: "disabled",
|
||||
guildMembers: "disabled",
|
||||
messageContent: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports enabled when full intent bits set", () => {
|
||||
const flags = (1 << 12) | (1 << 14) | (1 << 18);
|
||||
expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
|
||||
presence: "enabled",
|
||||
guildMembers: "enabled",
|
||||
messageContent: "enabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports limited when limited intent bits set", () => {
|
||||
const flags = (1 << 13) | (1 << 15) | (1 << 19);
|
||||
expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
|
||||
presence: "limited",
|
||||
guildMembers: "limited",
|
||||
messageContent: "limited",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers enabled over limited when both set", () => {
|
||||
const flags =
|
||||
(1 << 12) | (1 << 13) | (1 << 14) | (1 << 15) | (1 << 18) | (1 << 19);
|
||||
expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
|
||||
presence: "enabled",
|
||||
guildMembers: "enabled",
|
||||
messageContent: "enabled",
|
||||
});
|
||||
});
|
||||
});
|
||||
+93
-1
@@ -8,8 +8,89 @@ export type DiscordProbe = {
|
||||
error?: string | null;
|
||||
elapsedMs: number;
|
||||
bot?: { id?: string | null; username?: string | null };
|
||||
application?: DiscordApplicationSummary;
|
||||
};
|
||||
|
||||
export type DiscordPrivilegedIntentStatus = "enabled" | "limited" | "disabled";
|
||||
|
||||
export type DiscordPrivilegedIntentsSummary = {
|
||||
messageContent: DiscordPrivilegedIntentStatus;
|
||||
guildMembers: DiscordPrivilegedIntentStatus;
|
||||
presence: DiscordPrivilegedIntentStatus;
|
||||
};
|
||||
|
||||
export type DiscordApplicationSummary = {
|
||||
id?: string | null;
|
||||
flags?: number | null;
|
||||
intents?: DiscordPrivilegedIntentsSummary;
|
||||
};
|
||||
|
||||
const DISCORD_APP_FLAG_GATEWAY_PRESENCE = 1 << 12;
|
||||
const DISCORD_APP_FLAG_GATEWAY_PRESENCE_LIMITED = 1 << 13;
|
||||
const DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS = 1 << 14;
|
||||
const DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS_LIMITED = 1 << 15;
|
||||
const DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT = 1 << 18;
|
||||
const DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19;
|
||||
|
||||
export function resolveDiscordPrivilegedIntentsFromFlags(
|
||||
flags: number,
|
||||
): DiscordPrivilegedIntentsSummary {
|
||||
const resolve = (enabledBit: number, limitedBit: number) => {
|
||||
if ((flags & enabledBit) !== 0) return "enabled";
|
||||
if ((flags & limitedBit) !== 0) return "limited";
|
||||
return "disabled";
|
||||
};
|
||||
return {
|
||||
presence: resolve(
|
||||
DISCORD_APP_FLAG_GATEWAY_PRESENCE,
|
||||
DISCORD_APP_FLAG_GATEWAY_PRESENCE_LIMITED,
|
||||
),
|
||||
guildMembers: resolve(
|
||||
DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS,
|
||||
DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS_LIMITED,
|
||||
),
|
||||
messageContent: resolve(
|
||||
DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT,
|
||||
DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchDiscordApplicationSummary(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<DiscordApplicationSummary | undefined> {
|
||||
const normalized = normalizeDiscordToken(token);
|
||||
if (!normalized) return undefined;
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${DISCORD_API_BASE}/oauth2/applications/@me`,
|
||||
timeoutMs,
|
||||
fetcher,
|
||||
{
|
||||
Authorization: `Bot ${normalized}`,
|
||||
},
|
||||
);
|
||||
if (!res.ok) return undefined;
|
||||
const json = (await res.json()) as { id?: string; flags?: number };
|
||||
const flags =
|
||||
typeof json.flags === "number" && Number.isFinite(json.flags)
|
||||
? json.flags
|
||||
: undefined;
|
||||
return {
|
||||
id: json.id ?? null,
|
||||
flags: flags ?? null,
|
||||
intents:
|
||||
typeof flags === "number"
|
||||
? resolveDiscordPrivilegedIntentsFromFlags(flags)
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
@@ -28,8 +109,11 @@ async function fetchWithTimeout(
|
||||
export async function probeDiscord(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
opts?: { fetcher?: typeof fetch; includeApplication?: boolean },
|
||||
): Promise<DiscordProbe> {
|
||||
const started = Date.now();
|
||||
const fetcher = opts?.fetcher ?? fetch;
|
||||
const includeApplication = opts?.includeApplication === true;
|
||||
const normalized = normalizeDiscordToken(token);
|
||||
const result: DiscordProbe = {
|
||||
ok: false,
|
||||
@@ -48,7 +132,7 @@ export async function probeDiscord(
|
||||
const res = await fetchWithTimeout(
|
||||
`${DISCORD_API_BASE}/users/@me`,
|
||||
timeoutMs,
|
||||
fetch,
|
||||
fetcher,
|
||||
{
|
||||
Authorization: `Bot ${normalized}`,
|
||||
},
|
||||
@@ -64,6 +148,14 @@ export async function probeDiscord(
|
||||
id: json.id ?? null,
|
||||
username: json.username ?? null,
|
||||
};
|
||||
if (includeApplication) {
|
||||
result.application =
|
||||
(await fetchDiscordApplicationSummary(
|
||||
normalized,
|
||||
timeoutMs,
|
||||
fetcher,
|
||||
)) ?? undefined;
|
||||
}
|
||||
return { ...result, elapsedMs: Date.now() - started };
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "discord-api-types/v10";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import {
|
||||
createDiscordRetryRunner,
|
||||
@@ -589,6 +590,11 @@ export async function sendMessageDiscord(
|
||||
});
|
||||
}
|
||||
|
||||
recordProviderActivity({
|
||||
provider: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return {
|
||||
messageId: result.id ? String(result.id) : "unknown",
|
||||
channelId: String(result.channel_id ?? channelId),
|
||||
|
||||
@@ -12,4 +12,84 @@ describe("gateway auth", () => {
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("reports missing and mismatched token reasons", async () => {
|
||||
const missing = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: null,
|
||||
});
|
||||
expect(missing.ok).toBe(false);
|
||||
expect(missing.reason).toBe("token_missing");
|
||||
|
||||
const mismatch = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: { token: "wrong" },
|
||||
});
|
||||
expect(mismatch.ok).toBe(false);
|
||||
expect(mismatch.reason).toBe("token_mismatch");
|
||||
});
|
||||
|
||||
it("reports missing token config reason", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", allowTailscale: false },
|
||||
connectAuth: { token: "anything" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_missing_config");
|
||||
});
|
||||
|
||||
it("reports missing and mismatched password reasons", async () => {
|
||||
const missing = await authorizeGatewayConnect({
|
||||
auth: { mode: "password", password: "secret", allowTailscale: false },
|
||||
connectAuth: null,
|
||||
});
|
||||
expect(missing.ok).toBe(false);
|
||||
expect(missing.reason).toBe("password_missing");
|
||||
|
||||
const mismatch = await authorizeGatewayConnect({
|
||||
auth: { mode: "password", password: "secret", allowTailscale: false },
|
||||
connectAuth: { password: "wrong" },
|
||||
});
|
||||
expect(mismatch.ok).toBe(false);
|
||||
expect(mismatch.reason).toBe("password_mismatch");
|
||||
});
|
||||
|
||||
it("reports missing password config reason", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "password", allowTailscale: false },
|
||||
connectAuth: { password: "secret" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("password_missing_config");
|
||||
});
|
||||
|
||||
it("reports tailscale auth reasons when required", async () => {
|
||||
const reqBase = {
|
||||
socket: { remoteAddress: "100.100.100.100" },
|
||||
headers: { host: "gateway.local" },
|
||||
};
|
||||
|
||||
const missingUser = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
req: reqBase as never,
|
||||
});
|
||||
expect(missingUser.ok).toBe(false);
|
||||
expect(missingUser.reason).toBe("tailscale_user_missing");
|
||||
|
||||
const missingProxy = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
req: {
|
||||
...reqBase,
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"tailscale-user-login": "peter",
|
||||
"tailscale-user-name": "Peter",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(missingProxy.ok).toBe(false);
|
||||
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
|
||||
});
|
||||
});
|
||||
+27
-13
@@ -150,10 +150,10 @@ export async function authorizeGatewayConnect(params: {
|
||||
if (auth.allowTailscale && !localDirect) {
|
||||
const tailscaleUser = getTailscaleUser(req);
|
||||
if (!tailscaleUser) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
return { ok: false, reason: "tailscale_user_missing" };
|
||||
}
|
||||
if (!isTailscaleProxyRequest(req)) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
return { ok: false, reason: "tailscale_proxy_missing" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
@@ -165,31 +165,45 @@ export async function authorizeGatewayConnect(params: {
|
||||
}
|
||||
|
||||
if (auth.mode === "token") {
|
||||
if (auth.token && connectAuth?.token === auth.token) {
|
||||
return { ok: true, method: "token" };
|
||||
if (!auth.token) {
|
||||
return { ok: false, reason: "token_missing_config" };
|
||||
}
|
||||
if (!connectAuth?.token) {
|
||||
return { ok: false, reason: "token_missing" };
|
||||
}
|
||||
if (connectAuth.token !== auth.token) {
|
||||
return { ok: false, reason: "token_mismatch" };
|
||||
}
|
||||
return { ok: true, method: "token" };
|
||||
}
|
||||
|
||||
if (auth.mode === "password") {
|
||||
const password = connectAuth?.password;
|
||||
if (!password || !auth.password) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
if (!auth.password) {
|
||||
return { ok: false, reason: "password_missing_config" };
|
||||
}
|
||||
if (!password) {
|
||||
return { ok: false, reason: "password_missing" };
|
||||
}
|
||||
if (!safeEqual(password, auth.password)) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
return { ok: false, reason: "password_mismatch" };
|
||||
}
|
||||
return { ok: true, method: "password" };
|
||||
}
|
||||
|
||||
if (auth.allowTailscale) {
|
||||
const tailscaleUser = getTailscaleUser(req);
|
||||
if (tailscaleUser && isTailscaleProxyRequest(req)) {
|
||||
return {
|
||||
ok: true,
|
||||
method: "tailscale",
|
||||
user: tailscaleUser.login,
|
||||
};
|
||||
if (!tailscaleUser) {
|
||||
return { ok: false, reason: "tailscale_user_missing" };
|
||||
}
|
||||
if (!isTailscaleProxyRequest(req)) {
|
||||
return { ok: false, reason: "tailscale_proxy_missing" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
method: "tailscale",
|
||||
user: tailscaleUser.login,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
type AgentEvent,
|
||||
AgentEventSchema,
|
||||
AgentParamsSchema,
|
||||
type AgentSummary,
|
||||
AgentSummarySchema,
|
||||
type AgentsListParams,
|
||||
AgentsListParamsSchema,
|
||||
type AgentsListResult,
|
||||
AgentsListResultSchema,
|
||||
type AgentWaitParams,
|
||||
AgentWaitParamsSchema,
|
||||
type ChatAbortParams,
|
||||
@@ -163,6 +169,9 @@ export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(
|
||||
AgentWaitParamsSchema,
|
||||
);
|
||||
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
|
||||
export const validateAgentsListParams = ajv.compile<AgentsListParams>(
|
||||
AgentsListParamsSchema,
|
||||
);
|
||||
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
|
||||
NodePairRequestParamsSchema,
|
||||
);
|
||||
@@ -332,6 +341,9 @@ export {
|
||||
ProvidersStatusParamsSchema,
|
||||
WebLoginStartParamsSchema,
|
||||
WebLoginWaitParamsSchema,
|
||||
AgentSummarySchema,
|
||||
AgentsListParamsSchema,
|
||||
AgentsListResultSchema,
|
||||
ModelsListParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
SkillsInstallParamsSchema,
|
||||
@@ -394,6 +406,9 @@ export type {
|
||||
ProvidersStatusParams,
|
||||
WebLoginStartParams,
|
||||
WebLoginWaitParams,
|
||||
AgentSummary,
|
||||
AgentsListParams,
|
||||
AgentsListResult,
|
||||
SkillsStatusParams,
|
||||
SkillsInstallParams,
|
||||
SkillsUpdateParams,
|
||||
|
||||
@@ -314,6 +314,7 @@ export const SessionsListParamsSchema = Type.Object(
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -590,6 +591,29 @@ export const ModelChoiceSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentSummarySchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
name: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentsListParamsSchema = Type.Object(
|
||||
{},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentsListResultSchema = Type.Object(
|
||||
{
|
||||
defaultId: NonEmptyString,
|
||||
mainKey: NonEmptyString,
|
||||
scope: Type.Union([Type.Literal("per-sender"), Type.Literal("global")]),
|
||||
agents: Type.Array(AgentSummarySchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ModelsListParamsSchema = Type.Object(
|
||||
{},
|
||||
{ additionalProperties: false },
|
||||
@@ -927,6 +951,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
ProvidersStatusParams: ProvidersStatusParamsSchema,
|
||||
WebLoginStartParams: WebLoginStartParamsSchema,
|
||||
WebLoginWaitParams: WebLoginWaitParamsSchema,
|
||||
AgentSummary: AgentSummarySchema,
|
||||
AgentsListParams: AgentsListParamsSchema,
|
||||
AgentsListResult: AgentsListResultSchema,
|
||||
ModelChoice: ModelChoiceSchema,
|
||||
ModelsListParams: ModelsListParamsSchema,
|
||||
ModelsListResult: ModelsListResultSchema,
|
||||
@@ -1000,6 +1027,9 @@ export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
|
||||
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
|
||||
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
|
||||
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
|
||||
export type AgentSummary = Static<typeof AgentSummarySchema>;
|
||||
export type AgentsListParams = Static<typeof AgentsListParamsSchema>;
|
||||
export type AgentsListResult = Static<typeof AgentsListResultSchema>;
|
||||
export type ModelChoice = Static<typeof ModelChoiceSchema>;
|
||||
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
|
||||
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ErrorCodes, errorShape } from "./protocol/index.js";
|
||||
import { agentHandlers } from "./server-methods/agent.js";
|
||||
import { agentsHandlers } from "./server-methods/agents.js";
|
||||
import { chatHandlers } from "./server-methods/chat.js";
|
||||
import { configHandlers } from "./server-methods/config.js";
|
||||
import { connectHandlers } from "./server-methods/connect.js";
|
||||
@@ -45,6 +46,7 @@ const handlers: GatewayRequestHandlers = {
|
||||
...sendHandlers,
|
||||
...usageHandlers,
|
||||
...agentHandlers,
|
||||
...agentsHandlers,
|
||||
};
|
||||
|
||||
export async function handleGatewayRequest(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateAgentsListParams,
|
||||
} from "../protocol/index.js";
|
||||
import { listAgentsForGateway } from "../session-utils.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const agentsHandlers: GatewayRequestHandlers = {
|
||||
"agents.list": ({ params, respond }) => {
|
||||
if (!validateAgentsListParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const result = listAgentsForGateway(cfg);
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
};
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import type { TelegramGroupConfig } from "../../config/types.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccount,
|
||||
} from "../../discord/accounts.js";
|
||||
import {
|
||||
auditDiscordChannelPermissions,
|
||||
collectDiscordAuditChannelIds,
|
||||
} from "../../discord/audit.js";
|
||||
import { type DiscordProbe, probeDiscord } from "../../discord/probe.js";
|
||||
import {
|
||||
listIMessageAccountIds,
|
||||
@@ -16,6 +21,7 @@ import {
|
||||
resolveIMessageAccount,
|
||||
} from "../../imessage/accounts.js";
|
||||
import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js";
|
||||
import { getProviderActivity } from "../../infra/provider-activity.js";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
resolveDefaultSignalAccountId,
|
||||
@@ -33,6 +39,10 @@ import {
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "../../telegram/accounts.js";
|
||||
import {
|
||||
auditTelegramGroupMembership,
|
||||
collectTelegramUnmentionedGroupIds,
|
||||
} from "../../telegram/audit.js";
|
||||
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
|
||||
import {
|
||||
listEnabledWhatsAppAccounts,
|
||||
@@ -89,6 +99,16 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
const configured = Boolean(account.token);
|
||||
let telegramProbe: TelegramProbe | undefined;
|
||||
let lastProbeAt: number | null = null;
|
||||
const groups =
|
||||
cfg.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
cfg.telegram?.groups;
|
||||
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
|
||||
collectTelegramUnmentionedGroupIds(
|
||||
groups as Record<string, TelegramGroupConfig> | undefined,
|
||||
);
|
||||
let audit:
|
||||
| Awaited<ReturnType<typeof auditTelegramGroupMembership>>
|
||||
| undefined;
|
||||
if (probe && configured && account.enabled) {
|
||||
telegramProbe = await probeTelegram(
|
||||
account.token,
|
||||
@@ -96,7 +116,47 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
account.config.proxy,
|
||||
);
|
||||
lastProbeAt = Date.now();
|
||||
const botId =
|
||||
telegramProbe.ok && telegramProbe.bot?.id != null
|
||||
? telegramProbe.bot.id
|
||||
: null;
|
||||
if (botId && (groupIds.length > 0 || unresolvedGroups > 0)) {
|
||||
const auditRes = await auditTelegramGroupMembership({
|
||||
token: account.token,
|
||||
botId,
|
||||
groupIds,
|
||||
proxyUrl: account.config.proxy,
|
||||
timeoutMs,
|
||||
});
|
||||
audit = {
|
||||
...auditRes,
|
||||
unresolvedGroups,
|
||||
hasWildcardUnmentionedGroups,
|
||||
};
|
||||
} else if (unresolvedGroups > 0 || hasWildcardUnmentionedGroups) {
|
||||
audit = {
|
||||
ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups,
|
||||
checkedGroups: 0,
|
||||
unresolvedGroups,
|
||||
hasWildcardUnmentionedGroups,
|
||||
groups: [],
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
const allowUnmentionedGroups =
|
||||
Boolean(
|
||||
groups?.["*"] &&
|
||||
(groups["*"] as { requireMention?: boolean }).requireMention ===
|
||||
false,
|
||||
) ||
|
||||
Object.entries(groups ?? {}).some(
|
||||
([key, value]) =>
|
||||
key !== "*" &&
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
(value as { requireMention?: boolean }).requireMention === false,
|
||||
);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
@@ -110,6 +170,16 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
lastError: rt?.lastError ?? null,
|
||||
probe: telegramProbe,
|
||||
lastProbeAt,
|
||||
audit,
|
||||
allowUnmentionedGroups,
|
||||
lastInboundAt: getProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
}).inboundAt,
|
||||
lastOutboundAt: getProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
}).outboundAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -129,9 +199,25 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
const configured = Boolean(account.token);
|
||||
let discordProbe: DiscordProbe | undefined;
|
||||
let lastProbeAt: number | null = null;
|
||||
const { channelIds: auditChannelIds, unresolvedChannels } =
|
||||
collectDiscordAuditChannelIds({ cfg, accountId: account.accountId });
|
||||
let audit:
|
||||
| Awaited<ReturnType<typeof auditDiscordChannelPermissions>>
|
||||
| undefined;
|
||||
if (probe && configured && account.enabled) {
|
||||
discordProbe = await probeDiscord(account.token, timeoutMs);
|
||||
discordProbe = await probeDiscord(account.token, timeoutMs, {
|
||||
includeApplication: true,
|
||||
});
|
||||
lastProbeAt = Date.now();
|
||||
if (auditChannelIds.length > 0 || unresolvedChannels > 0) {
|
||||
const auditRes = await auditDiscordChannelPermissions({
|
||||
token: account.token,
|
||||
accountId: account.accountId,
|
||||
channelIds: auditChannelIds,
|
||||
timeoutMs,
|
||||
});
|
||||
audit = { ...auditRes, unresolvedChannels };
|
||||
}
|
||||
}
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
@@ -139,12 +225,23 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
tokenSource: account.tokenSource,
|
||||
bot: rt?.bot ?? null,
|
||||
application: rt?.application ?? null,
|
||||
running: rt?.running ?? false,
|
||||
lastStartAt: rt?.lastStartAt ?? null,
|
||||
lastStopAt: rt?.lastStopAt ?? null,
|
||||
lastError: rt?.lastError ?? null,
|
||||
probe: discordProbe,
|
||||
lastProbeAt,
|
||||
audit,
|
||||
lastInboundAt: getProviderActivity({
|
||||
provider: "discord",
|
||||
accountId: account.accountId,
|
||||
}).inboundAt,
|
||||
lastOutboundAt: getProviderActivity({
|
||||
provider: "discord",
|
||||
accountId: account.accountId,
|
||||
}).outboundAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -302,6 +399,14 @@ export const providersHandlers: GatewayRequestHandlers = {
|
||||
lastMessageAt: rt.lastMessageAt ?? null,
|
||||
lastEventAt: rt.lastEventAt ?? null,
|
||||
lastError: rt.lastError ?? null,
|
||||
lastInboundAt: getProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: account.accountId,
|
||||
}).inboundAt,
|
||||
lastOutboundAt: getProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: account.accountId,
|
||||
}).outboundAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
resolveDiscordAccount,
|
||||
} from "../discord/accounts.js";
|
||||
import { monitorDiscordProvider } from "../discord/index.js";
|
||||
import type {
|
||||
DiscordApplicationSummary,
|
||||
DiscordProbe,
|
||||
} from "../discord/probe.js";
|
||||
import { probeDiscord } from "../discord/probe.js";
|
||||
import { shouldLogVerbose } from "../globals.js";
|
||||
import {
|
||||
@@ -56,6 +60,8 @@ export type DiscordRuntimeStatus = {
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
bot?: DiscordProbe["bot"];
|
||||
application?: DiscordApplicationSummary;
|
||||
};
|
||||
|
||||
export type SlackRuntimeStatus = {
|
||||
@@ -194,6 +200,8 @@ export function createProviderManager(
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
bot: undefined,
|
||||
application: undefined,
|
||||
});
|
||||
const defaultSlackStatus = (): SlackRuntimeStatus => ({
|
||||
running: false,
|
||||
@@ -544,9 +552,24 @@ export function createProviderManager(
|
||||
}
|
||||
let discordBotLabel = "";
|
||||
try {
|
||||
const probe = await probeDiscord(token, 2500);
|
||||
const probe = await probeDiscord(token, 2500, {
|
||||
includeApplication: true,
|
||||
});
|
||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||
if (username) discordBotLabel = ` (@${username})`;
|
||||
const latest =
|
||||
discordRuntimes.get(account.accountId) ?? defaultDiscordStatus();
|
||||
discordRuntimes.set(account.accountId, {
|
||||
...latest,
|
||||
bot: probe.bot,
|
||||
application: probe.application,
|
||||
});
|
||||
const messageContent = probe.application?.intents?.messageContent;
|
||||
if (messageContent && messageContent !== "enabled") {
|
||||
logDiscord.warn(
|
||||
`[${account.accountId}] Discord Message Content Intent is ${messageContent}; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (shouldLogVerbose()) {
|
||||
logDiscord.debug(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server agents", () => {
|
||||
test("lists configured agents via agents.list RPC", async () => {
|
||||
testState.routingConfig = {
|
||||
defaultAgentId: "work",
|
||||
agents: {
|
||||
work: { name: "Work" },
|
||||
home: { name: "Home" },
|
||||
},
|
||||
};
|
||||
|
||||
const { ws } = await startServerWithClient();
|
||||
const hello = await connectOk(ws);
|
||||
expect(
|
||||
(hello as unknown as { features?: { methods?: string[] } }).features
|
||||
?.methods,
|
||||
).toEqual(expect.arrayContaining(["agents.list"]));
|
||||
|
||||
const res = await rpcReq<{
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope: string;
|
||||
agents: Array<{ id: string; name?: string }>;
|
||||
}>(ws, "agents.list", {});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.defaultId).toBe("work");
|
||||
expect(res.payload?.mainKey).toBe("main");
|
||||
expect(res.payload?.scope).toBe("per-sender");
|
||||
expect(res.payload?.agents.map((agent) => agent.id)).toEqual([
|
||||
"work",
|
||||
"home",
|
||||
]);
|
||||
const work = res.payload?.agents.find((agent) => agent.id === "work");
|
||||
const home = res.payload?.agents.find((agent) => agent.id === "home");
|
||||
expect(work?.name).toBe("Work");
|
||||
expect(home?.name).toBe("Home");
|
||||
});
|
||||
});
|
||||
@@ -320,4 +320,84 @@ describe("gateway server sessions", () => {
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("filters sessions by agentId", async () => {
|
||||
const dir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-sessions-agents-"),
|
||||
);
|
||||
testState.sessionConfig = {
|
||||
store: path.join(dir, "{agentId}", "sessions.json"),
|
||||
};
|
||||
testState.routingConfig = {
|
||||
defaultAgentId: "home",
|
||||
agents: {
|
||||
home: {},
|
||||
work: {},
|
||||
},
|
||||
};
|
||||
const homeDir = path.join(dir, "home");
|
||||
const workDir = path.join(dir, "work");
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(homeDir, "sessions.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:home:main": {
|
||||
sessionId: "sess-home-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
"agent:home:discord:group:dev": {
|
||||
sessionId: "sess-home-group",
|
||||
updatedAt: Date.now() - 1000,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workDir, "sessions.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:work:main": {
|
||||
sessionId: "sess-work-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const homeSessions = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: "home",
|
||||
});
|
||||
expect(homeSessions.ok).toBe(true);
|
||||
expect(homeSessions.payload?.sessions.map((s) => s.key).sort()).toEqual([
|
||||
"agent:home:discord:group:dev",
|
||||
"agent:home:main",
|
||||
]);
|
||||
|
||||
const workSessions = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: "work",
|
||||
});
|
||||
expect(workSessions.ok).toBe(true);
|
||||
expect(workSessions.payload?.sessions.map((s) => s.key)).toEqual([
|
||||
"agent:work:main",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -227,6 +227,7 @@ const METHODS = [
|
||||
"wizard.status",
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"agents.list",
|
||||
"skills.status",
|
||||
"skills.install",
|
||||
"skills.update",
|
||||
@@ -1202,10 +1203,17 @@ export async function startGatewayServer(
|
||||
wss.on("connection", (socket, upgradeReq) => {
|
||||
let client: Client | null = null;
|
||||
let closed = false;
|
||||
const openedAt = Date.now();
|
||||
const connId = randomUUID();
|
||||
const remoteAddr = (
|
||||
socket as WebSocket & { _socket?: { remoteAddress?: string } }
|
||||
)._socket?.remoteAddress;
|
||||
const headerValue = (value: string | string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
const requestHost = headerValue(upgradeReq.headers.host);
|
||||
const requestOrigin = headerValue(upgradeReq.headers.origin);
|
||||
const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
|
||||
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
|
||||
const canvasHostPortForWs =
|
||||
canvasHostServer?.port ?? (canvasHost ? port : undefined);
|
||||
const canvasHostOverride =
|
||||
@@ -1223,6 +1231,19 @@ export async function startGatewayServer(
|
||||
const isWebchatConnect = (params: ConnectParams | null | undefined) =>
|
||||
params?.client?.mode === "webchat" ||
|
||||
params?.client?.name === "webchat-ui";
|
||||
let handshakeState: "pending" | "connected" | "failed" = "pending";
|
||||
let closeCause: string | undefined;
|
||||
let closeMeta: Record<string, unknown> = {};
|
||||
let lastFrameType: string | undefined;
|
||||
let lastFrameMethod: string | undefined;
|
||||
let lastFrameId: string | undefined;
|
||||
|
||||
const setCloseCause = (cause: string, meta?: Record<string, unknown>) => {
|
||||
if (!closeCause) closeCause = cause;
|
||||
if (meta && Object.keys(meta).length > 0) {
|
||||
closeMeta = { ...closeMeta, ...meta };
|
||||
}
|
||||
};
|
||||
|
||||
const send = (obj: unknown) => {
|
||||
try {
|
||||
@@ -1251,9 +1272,24 @@ export async function startGatewayServer(
|
||||
close();
|
||||
});
|
||||
socket.once("close", (code, reason) => {
|
||||
const durationMs = Date.now() - openedAt;
|
||||
const closeContext = {
|
||||
cause: closeCause,
|
||||
handshake: handshakeState,
|
||||
durationMs,
|
||||
lastFrameType,
|
||||
lastFrameMethod,
|
||||
lastFrameId,
|
||||
host: requestHost,
|
||||
origin: requestOrigin,
|
||||
userAgent: requestUserAgent,
|
||||
forwardedFor,
|
||||
...closeMeta,
|
||||
};
|
||||
if (!client) {
|
||||
logWsControl.warn(
|
||||
`closed before connect conn=${connId} remote=${remoteAddr ?? "?"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`,
|
||||
closeContext,
|
||||
);
|
||||
}
|
||||
if (client && isWebchatConnect(client.connect)) {
|
||||
@@ -1280,12 +1316,22 @@ export async function startGatewayServer(
|
||||
connId,
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
durationMs,
|
||||
cause: closeCause,
|
||||
handshake: handshakeState,
|
||||
lastFrameType,
|
||||
lastFrameMethod,
|
||||
lastFrameId,
|
||||
});
|
||||
close();
|
||||
});
|
||||
|
||||
const handshakeTimer = setTimeout(() => {
|
||||
if (!client) {
|
||||
handshakeState = "failed";
|
||||
setCloseCause("handshake-timeout", {
|
||||
handshakeMs: Date.now() - openedAt,
|
||||
});
|
||||
logWsControl.warn(
|
||||
`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`,
|
||||
);
|
||||
@@ -1298,6 +1344,29 @@ export async function startGatewayServer(
|
||||
const text = rawDataToString(data);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const frameType =
|
||||
parsed && typeof parsed === "object" && "type" in parsed
|
||||
? typeof (parsed as { type?: unknown }).type === "string"
|
||||
? String((parsed as { type?: unknown }).type)
|
||||
: undefined
|
||||
: undefined;
|
||||
const frameMethod =
|
||||
parsed && typeof parsed === "object" && "method" in parsed
|
||||
? typeof (parsed as { method?: unknown }).method === "string"
|
||||
? String((parsed as { method?: unknown }).method)
|
||||
: undefined
|
||||
: undefined;
|
||||
const frameId =
|
||||
parsed && typeof parsed === "object" && "id" in parsed
|
||||
? typeof (parsed as { id?: unknown }).id === "string"
|
||||
? String((parsed as { id?: unknown }).id)
|
||||
: undefined
|
||||
: undefined;
|
||||
if (frameType || frameMethod || frameId) {
|
||||
lastFrameType = frameType;
|
||||
lastFrameMethod = frameMethod;
|
||||
lastFrameId = frameId;
|
||||
}
|
||||
if (!client) {
|
||||
// Handshake must be a normal request:
|
||||
// { type:"req", method:"connect", params: ConnectParams }.
|
||||
@@ -1324,6 +1393,18 @@ export async function startGatewayServer(
|
||||
`invalid handshake conn=${connId} remote=${remoteAddr ?? "?"}`,
|
||||
);
|
||||
}
|
||||
handshakeState = "failed";
|
||||
const handshakeError = validateRequestFrame(parsed)
|
||||
? (parsed as RequestFrame).method === "connect"
|
||||
? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`
|
||||
: "invalid handshake: first request must be connect"
|
||||
: "invalid request frame";
|
||||
setCloseCause("invalid-handshake", {
|
||||
frameType,
|
||||
frameMethod,
|
||||
frameId,
|
||||
handshakeError,
|
||||
});
|
||||
socket.close(1008, "invalid handshake");
|
||||
close();
|
||||
return;
|
||||
@@ -1338,9 +1419,18 @@ export async function startGatewayServer(
|
||||
maxProtocol < PROTOCOL_VERSION ||
|
||||
minProtocol > PROTOCOL_VERSION
|
||||
) {
|
||||
handshakeState = "failed";
|
||||
logWsControl.warn(
|
||||
`protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||
);
|
||||
setCloseCause("protocol-mismatch", {
|
||||
minProtocol,
|
||||
maxProtocol,
|
||||
expectedProtocol: PROTOCOL_VERSION,
|
||||
client: connectParams.client.name,
|
||||
mode: connectParams.client.mode,
|
||||
version: connectParams.client.version,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
@@ -1364,9 +1454,24 @@ export async function startGatewayServer(
|
||||
req: upgradeReq,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
handshakeState = "failed";
|
||||
logWsControl.warn(
|
||||
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||
);
|
||||
const authProvided = connectParams.auth?.token
|
||||
? "token"
|
||||
: connectParams.auth?.password
|
||||
? "password"
|
||||
: "none";
|
||||
setCloseCause("unauthorized", {
|
||||
authMode: resolvedAuth.mode,
|
||||
authProvided,
|
||||
authReason: authResult.reason,
|
||||
allowTailscale: resolvedAuth.allowTailscale,
|
||||
client: connectParams.client.name,
|
||||
mode: connectParams.client.mode,
|
||||
version: connectParams.client.version,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
@@ -1444,6 +1549,7 @@ export async function startGatewayServer(
|
||||
|
||||
clearTimeout(handshakeTimer);
|
||||
client = { socket, connect: connectParams, connId, presenceKey };
|
||||
handshakeState = "connected";
|
||||
|
||||
logWs("out", "hello-ok", {
|
||||
connId,
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
resolveSessionTranscriptPath,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
} from "../config/sessions.js";
|
||||
import {
|
||||
DEFAULT_MAIN_KEY,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
@@ -56,6 +58,11 @@ export type GatewaySessionRow = {
|
||||
lastAccountId?: string;
|
||||
};
|
||||
|
||||
export type GatewayAgentRow = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
ts: number;
|
||||
path: string;
|
||||
@@ -237,6 +244,39 @@ function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function listAgentsForGateway(cfg: ClawdbotConfig): {
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope: SessionScope;
|
||||
agents: GatewayAgentRow[];
|
||||
} {
|
||||
const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId);
|
||||
const mainKey =
|
||||
(cfg.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
|
||||
const scope = cfg.session?.scope ?? "per-sender";
|
||||
const configured = cfg.routing?.agents;
|
||||
const configuredById = new Map<string, { name?: string }>();
|
||||
if (configured && typeof configured === "object") {
|
||||
for (const [key, value] of Object.entries(configured)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
configuredById.set(normalizeAgentId(key), {
|
||||
name:
|
||||
typeof value.name === "string" && value.name.trim()
|
||||
? value.name.trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
const agents = listConfiguredAgentIds(cfg).map((id) => {
|
||||
const meta = configuredById.get(id);
|
||||
return {
|
||||
id,
|
||||
name: meta?.name,
|
||||
};
|
||||
});
|
||||
return { defaultId, mainKey, scope, agents };
|
||||
}
|
||||
|
||||
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
|
||||
if (key === "global" || key === "unknown") return key;
|
||||
if (key.startsWith("agent:")) return key;
|
||||
@@ -394,6 +434,8 @@ export function listSessionsFromStore(params: {
|
||||
const includeGlobal = opts.includeGlobal === true;
|
||||
const includeUnknown = opts.includeUnknown === true;
|
||||
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||
const agentId =
|
||||
typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
||||
const activeMinutes =
|
||||
typeof opts.activeMinutes === "number" &&
|
||||
Number.isFinite(opts.activeMinutes)
|
||||
@@ -404,6 +446,12 @@ export function listSessionsFromStore(params: {
|
||||
.filter(([key]) => {
|
||||
if (!includeGlobal && key === "global") return false;
|
||||
if (!includeUnknown && key === "unknown") return false;
|
||||
if (agentId) {
|
||||
if (key === "global" || key === "unknown") return false;
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
if (!parsed) return false;
|
||||
return normalizeAgentId(parsed.agentId) === agentId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.filter(([key, entry]) => {
|
||||
|
||||
@@ -85,6 +85,7 @@ export const agentCommand = hoisted.agentCommand;
|
||||
|
||||
export const testState = {
|
||||
agentConfig: undefined as Record<string, unknown> | undefined,
|
||||
routingConfig: undefined as Record<string, unknown> | undefined,
|
||||
sessionStorePath: undefined as string | undefined,
|
||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||
allowFrom: undefined as string[] | undefined,
|
||||
@@ -246,6 +247,7 @@ vi.mock("../config/config.js", async () => {
|
||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||
...testState.agentConfig,
|
||||
},
|
||||
routing: testState.routingConfig,
|
||||
whatsapp: {
|
||||
allowFrom: testState.allowFrom,
|
||||
},
|
||||
@@ -354,6 +356,7 @@ export function installGatewayTestHooks() {
|
||||
testState.sessionConfig = undefined;
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.agentConfig = undefined;
|
||||
testState.routingConfig = undefined;
|
||||
testState.allowFrom = undefined;
|
||||
testIsNixMode.value = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
|
||||
@@ -284,6 +284,9 @@ describe("monitorIMessageProvider", () => {
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Your iMessage sender id: +15550001111",
|
||||
);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Pairing code: PAIRCODE",
|
||||
);
|
||||
|
||||
+18
-20
@@ -17,6 +17,7 @@ import {
|
||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||
import {
|
||||
readProviderAllowFromStore,
|
||||
upsertProviderPairingRequest,
|
||||
@@ -149,7 +150,6 @@ export async function monitorIMessageProvider(
|
||||
);
|
||||
const groupPolicy = imessageCfg.groupPolicy ?? "open";
|
||||
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
const includeAttachments =
|
||||
opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
|
||||
const mediaMaxBytes =
|
||||
@@ -257,14 +257,11 @@ export async function monitorIMessageProvider(
|
||||
try {
|
||||
await sendMessageIMessage(
|
||||
sender,
|
||||
[
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider imessage <code>",
|
||||
].join("\n"),
|
||||
buildPairingReply({
|
||||
provider: "imessage",
|
||||
idLine: `Your iMessage sender id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
{
|
||||
client,
|
||||
maxBytes: mediaMaxBytes,
|
||||
@@ -287,6 +284,18 @@ export async function monitorIMessageProvider(
|
||||
}
|
||||
}
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup
|
||||
? String(chatId ?? "unknown")
|
||||
: normalizeIMessageHandle(sender),
|
||||
},
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const messageText = (message.text ?? "").trim();
|
||||
const mentioned = isGroup
|
||||
? matchesMentionPatterns(messageText, mentionRegexes)
|
||||
@@ -357,17 +366,6 @@ export async function monitorIMessageProvider(
|
||||
body: bodyText,
|
||||
});
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup
|
||||
? String(chatId ?? "unknown")
|
||||
: normalizeIMessageHandle(sender),
|
||||
},
|
||||
});
|
||||
const imessageTo = chatTarget || `imessage:${sender}`;
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getProviderActivity,
|
||||
recordProviderActivity,
|
||||
resetProviderActivityForTest,
|
||||
} from "./provider-activity.js";
|
||||
|
||||
describe("provider activity", () => {
|
||||
beforeEach(() => {
|
||||
resetProviderActivityForTest();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-08T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("records inbound/outbound separately", () => {
|
||||
recordProviderActivity({ provider: "telegram", direction: "inbound" });
|
||||
vi.advanceTimersByTime(1000);
|
||||
recordProviderActivity({ provider: "telegram", direction: "outbound" });
|
||||
const res = getProviderActivity({ provider: "telegram" });
|
||||
expect(res.inboundAt).toBe(1767830400000);
|
||||
expect(res.outboundAt).toBe(1767830401000);
|
||||
});
|
||||
|
||||
it("isolates accounts", () => {
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: "a",
|
||||
direction: "inbound",
|
||||
at: 1,
|
||||
});
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: "b",
|
||||
direction: "inbound",
|
||||
at: 2,
|
||||
});
|
||||
expect(
|
||||
getProviderActivity({ provider: "whatsapp", accountId: "a" }),
|
||||
).toEqual({
|
||||
inboundAt: 1,
|
||||
outboundAt: null,
|
||||
});
|
||||
expect(
|
||||
getProviderActivity({ provider: "whatsapp", accountId: "b" }),
|
||||
).toEqual({
|
||||
inboundAt: 2,
|
||||
outboundAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
export type ProviderId = "discord" | "telegram" | "whatsapp";
|
||||
export type ProviderDirection = "inbound" | "outbound";
|
||||
|
||||
type ActivityEntry = {
|
||||
inboundAt: number | null;
|
||||
outboundAt: number | null;
|
||||
};
|
||||
|
||||
const activity = new Map<string, ActivityEntry>();
|
||||
|
||||
function keyFor(provider: ProviderId, accountId: string) {
|
||||
return `${provider}:${accountId || "default"}`;
|
||||
}
|
||||
|
||||
function ensureEntry(provider: ProviderId, accountId: string): ActivityEntry {
|
||||
const key = keyFor(provider, accountId);
|
||||
const existing = activity.get(key);
|
||||
if (existing) return existing;
|
||||
const created: ActivityEntry = { inboundAt: null, outboundAt: null };
|
||||
activity.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function recordProviderActivity(params: {
|
||||
provider: ProviderId;
|
||||
accountId?: string | null;
|
||||
direction: ProviderDirection;
|
||||
at?: number;
|
||||
}) {
|
||||
const at = typeof params.at === "number" ? params.at : Date.now();
|
||||
const accountId = params.accountId?.trim() || "default";
|
||||
const entry = ensureEntry(params.provider, accountId);
|
||||
if (params.direction === "inbound") entry.inboundAt = at;
|
||||
if (params.direction === "outbound") entry.outboundAt = at;
|
||||
}
|
||||
|
||||
export function getProviderActivity(params: {
|
||||
provider: ProviderId;
|
||||
accountId?: string | null;
|
||||
}): ActivityEntry {
|
||||
const accountId = params.accountId?.trim() || "default";
|
||||
return (
|
||||
activity.get(keyFor(params.provider, accountId)) ?? {
|
||||
inboundAt: null,
|
||||
outboundAt: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function resetProviderActivityForTest() {
|
||||
activity.clear();
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
export type ProviderStatusIssue = {
|
||||
provider: "discord" | "telegram" | "whatsapp";
|
||||
accountId: string;
|
||||
kind: "intent" | "permissions" | "config" | "auth" | "runtime";
|
||||
message: string;
|
||||
fix?: string;
|
||||
};
|
||||
|
||||
type DiscordIntentSummary = {
|
||||
messageContent?: "enabled" | "limited" | "disabled";
|
||||
};
|
||||
|
||||
type DiscordApplicationSummary = {
|
||||
intents?: DiscordIntentSummary;
|
||||
};
|
||||
|
||||
type DiscordAccountStatus = {
|
||||
accountId?: unknown;
|
||||
enabled?: unknown;
|
||||
configured?: unknown;
|
||||
application?: unknown;
|
||||
audit?: unknown;
|
||||
};
|
||||
|
||||
type TelegramAccountStatus = {
|
||||
accountId?: unknown;
|
||||
enabled?: unknown;
|
||||
configured?: unknown;
|
||||
allowUnmentionedGroups?: unknown;
|
||||
audit?: unknown;
|
||||
};
|
||||
|
||||
type WhatsAppAccountStatus = {
|
||||
accountId?: unknown;
|
||||
enabled?: unknown;
|
||||
linked?: unknown;
|
||||
connected?: unknown;
|
||||
running?: unknown;
|
||||
reconnectAttempts?: unknown;
|
||||
lastError?: unknown;
|
||||
};
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readDiscordAccountStatus(value: unknown): DiscordAccountStatus | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
accountId: value.accountId,
|
||||
enabled: value.enabled,
|
||||
configured: value.configured,
|
||||
application: value.application,
|
||||
audit: value.audit,
|
||||
};
|
||||
}
|
||||
|
||||
function readDiscordApplicationSummary(
|
||||
value: unknown,
|
||||
): DiscordApplicationSummary {
|
||||
if (!isRecord(value)) return {};
|
||||
const intentsRaw = value.intents;
|
||||
if (!isRecord(intentsRaw)) return {};
|
||||
return {
|
||||
intents: {
|
||||
messageContent:
|
||||
intentsRaw.messageContent === "enabled" ||
|
||||
intentsRaw.messageContent === "limited" ||
|
||||
intentsRaw.messageContent === "disabled"
|
||||
? intentsRaw.messageContent
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type DiscordPermissionsAuditSummary = {
|
||||
unresolvedChannels?: number;
|
||||
channels?: Array<{
|
||||
channelId: string;
|
||||
ok?: boolean;
|
||||
missing?: string[];
|
||||
error?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function readDiscordPermissionsAuditSummary(
|
||||
value: unknown,
|
||||
): DiscordPermissionsAuditSummary {
|
||||
if (!isRecord(value)) return {};
|
||||
const unresolvedChannels =
|
||||
typeof value.unresolvedChannels === "number" &&
|
||||
Number.isFinite(value.unresolvedChannels)
|
||||
? value.unresolvedChannels
|
||||
: undefined;
|
||||
const channelsRaw = value.channels;
|
||||
const channels = Array.isArray(channelsRaw)
|
||||
? (channelsRaw
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry)) return null;
|
||||
const channelId = asString(entry.channelId);
|
||||
if (!channelId) return null;
|
||||
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
|
||||
const missing = Array.isArray(entry.missing)
|
||||
? entry.missing.map((v) => asString(v)).filter(Boolean)
|
||||
: undefined;
|
||||
const error = asString(entry.error) ?? null;
|
||||
return {
|
||||
channelId,
|
||||
ok,
|
||||
missing: missing?.length ? missing : undefined,
|
||||
error,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as DiscordPermissionsAuditSummary["channels"])
|
||||
: undefined;
|
||||
return { unresolvedChannels, channels };
|
||||
}
|
||||
|
||||
function readTelegramAccountStatus(
|
||||
value: unknown,
|
||||
): TelegramAccountStatus | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
accountId: value.accountId,
|
||||
enabled: value.enabled,
|
||||
configured: value.configured,
|
||||
allowUnmentionedGroups: value.allowUnmentionedGroups,
|
||||
audit: value.audit,
|
||||
};
|
||||
}
|
||||
|
||||
type TelegramGroupMembershipAuditSummary = {
|
||||
unresolvedGroups?: number;
|
||||
hasWildcardUnmentionedGroups?: boolean;
|
||||
groups?: Array<{
|
||||
chatId: string;
|
||||
ok?: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function readTelegramGroupMembershipAuditSummary(
|
||||
value: unknown,
|
||||
): TelegramGroupMembershipAuditSummary {
|
||||
if (!isRecord(value)) return {};
|
||||
const unresolvedGroups =
|
||||
typeof value.unresolvedGroups === "number" &&
|
||||
Number.isFinite(value.unresolvedGroups)
|
||||
? value.unresolvedGroups
|
||||
: undefined;
|
||||
const hasWildcardUnmentionedGroups =
|
||||
typeof value.hasWildcardUnmentionedGroups === "boolean"
|
||||
? value.hasWildcardUnmentionedGroups
|
||||
: undefined;
|
||||
const groupsRaw = value.groups;
|
||||
const groups = Array.isArray(groupsRaw)
|
||||
? (groupsRaw
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry)) return null;
|
||||
const chatId = asString(entry.chatId);
|
||||
if (!chatId) return null;
|
||||
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
|
||||
const status = asString(entry.status) ?? null;
|
||||
const error = asString(entry.error) ?? null;
|
||||
return { chatId, ok, status, error };
|
||||
})
|
||||
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
|
||||
: undefined;
|
||||
return { unresolvedGroups, hasWildcardUnmentionedGroups, groups };
|
||||
}
|
||||
|
||||
function readWhatsAppAccountStatus(
|
||||
value: unknown,
|
||||
): WhatsAppAccountStatus | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
accountId: value.accountId,
|
||||
enabled: value.enabled,
|
||||
linked: value.linked,
|
||||
connected: value.connected,
|
||||
running: value.running,
|
||||
reconnectAttempts: value.reconnectAttempts,
|
||||
lastError: value.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectProvidersStatusIssues(
|
||||
payload: Record<string, unknown>,
|
||||
): ProviderStatusIssue[] {
|
||||
const issues: ProviderStatusIssue[] = [];
|
||||
|
||||
const discordAccountsRaw = payload.discordAccounts;
|
||||
if (Array.isArray(discordAccountsRaw)) {
|
||||
for (const entry of discordAccountsRaw) {
|
||||
const account = readDiscordAccountStatus(entry);
|
||||
if (!account) continue;
|
||||
const accountId = asString(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
const configured = account.configured === true;
|
||||
if (!enabled || !configured) continue;
|
||||
|
||||
const app = readDiscordApplicationSummary(account.application);
|
||||
const messageContent = app.intents?.messageContent;
|
||||
if (messageContent && messageContent !== "enabled") {
|
||||
issues.push({
|
||||
provider: "discord",
|
||||
accountId,
|
||||
kind: "intent",
|
||||
message: `Message Content Intent is ${messageContent}. Bot may not see normal channel messages.`,
|
||||
fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.",
|
||||
});
|
||||
}
|
||||
|
||||
const audit = readDiscordPermissionsAuditSummary(account.audit);
|
||||
if (audit.unresolvedChannels && audit.unresolvedChannels > 0) {
|
||||
issues.push({
|
||||
provider: "discord",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`,
|
||||
fix: "Use numeric channel IDs as keys in discord.guilds.*.channels (then rerun providers status --probe).",
|
||||
});
|
||||
}
|
||||
for (const channel of audit.channels ?? []) {
|
||||
if (channel.ok === true) continue;
|
||||
const missing = channel.missing?.length
|
||||
? ` missing ${channel.missing.join(", ")}`
|
||||
: "";
|
||||
const error = channel.error ? `: ${channel.error}` : "";
|
||||
issues.push({
|
||||
provider: "discord",
|
||||
accountId,
|
||||
kind: "permissions",
|
||||
message: `Channel ${channel.channelId} permission check failed.${missing}${error}`,
|
||||
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const telegramAccountsRaw = payload.telegramAccounts;
|
||||
if (Array.isArray(telegramAccountsRaw)) {
|
||||
for (const entry of telegramAccountsRaw) {
|
||||
const account = readTelegramAccountStatus(entry);
|
||||
if (!account) continue;
|
||||
const accountId = asString(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
const configured = account.configured === true;
|
||||
if (!enabled || !configured) continue;
|
||||
|
||||
if (account.allowUnmentionedGroups === true) {
|
||||
issues.push({
|
||||
provider: "telegram",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
"Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.",
|
||||
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).",
|
||||
});
|
||||
}
|
||||
|
||||
const audit = readTelegramGroupMembershipAuditSummary(account.audit);
|
||||
if (audit.hasWildcardUnmentionedGroups === true) {
|
||||
issues.push({
|
||||
provider: "telegram",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.',
|
||||
fix: "Add explicit numeric group ids under telegram.groups (or per-account groups) to enable probing.",
|
||||
});
|
||||
}
|
||||
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
|
||||
issues.push({
|
||||
provider: "telegram",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`,
|
||||
fix: "Use numeric chat IDs (e.g. -100...) as keys in telegram.groups for requireMention=false groups.",
|
||||
});
|
||||
}
|
||||
for (const group of audit.groups ?? []) {
|
||||
if (group.ok === true) continue;
|
||||
const status = group.status ? ` status=${group.status}` : "";
|
||||
const err = group.error ? `: ${group.error}` : "";
|
||||
issues.push({
|
||||
provider: "telegram",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: `Group ${group.chatId} not reachable by bot.${status}${err}`,
|
||||
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const whatsappAccountsRaw = payload.whatsappAccounts;
|
||||
if (Array.isArray(whatsappAccountsRaw)) {
|
||||
for (const entry of whatsappAccountsRaw) {
|
||||
const account = readWhatsAppAccountStatus(entry);
|
||||
if (!account) continue;
|
||||
const accountId = asString(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
if (!enabled) continue;
|
||||
const linked = account.linked === true;
|
||||
const running = account.running === true;
|
||||
const connected = account.connected === true;
|
||||
const reconnectAttempts =
|
||||
typeof account.reconnectAttempts === "number"
|
||||
? account.reconnectAttempts
|
||||
: null;
|
||||
const lastError = asString(account.lastError);
|
||||
|
||||
if (!linked) {
|
||||
issues.push({
|
||||
provider: "whatsapp",
|
||||
accountId,
|
||||
kind: "auth",
|
||||
message: "Not linked (no WhatsApp Web session).",
|
||||
fix: "Run: clawdbot providers login (scan QR on the gateway host).",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (running && !connected) {
|
||||
issues.push({
|
||||
provider: "whatsapp",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`,
|
||||
fix: "Run: clawdbot doctor (or restart the gateway). If it persists, relink via providers login and check logs.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PairingProvider } from "./pairing-store.js";
|
||||
|
||||
export const PROVIDER_ID_LABELS: Record<PairingProvider, string> = {
|
||||
telegram: "telegramUserId",
|
||||
discord: "discordUserId",
|
||||
slack: "slackUserId",
|
||||
signal: "signalNumber",
|
||||
imessage: "imessageSenderId",
|
||||
whatsapp: "whatsappSenderId",
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPairingReply } from "./pairing-messages.js";
|
||||
|
||||
describe("buildPairingReply", () => {
|
||||
const cases = [
|
||||
{
|
||||
provider: "discord",
|
||||
idLine: "Your Discord user id: 1",
|
||||
code: "ABC123",
|
||||
},
|
||||
{
|
||||
provider: "slack",
|
||||
idLine: "Your Slack user id: U1",
|
||||
code: "DEF456",
|
||||
},
|
||||
{
|
||||
provider: "signal",
|
||||
idLine: "Your Signal number: +15550001111",
|
||||
code: "GHI789",
|
||||
},
|
||||
{
|
||||
provider: "imessage",
|
||||
idLine: "Your iMessage sender id: +15550002222",
|
||||
code: "JKL012",
|
||||
},
|
||||
{
|
||||
provider: "whatsapp",
|
||||
idLine: "Your WhatsApp sender id: +15550003333",
|
||||
code: "MNO345",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(`formats pairing reply for ${testCase.provider}`, () => {
|
||||
const text = buildPairingReply(testCase);
|
||||
expect(text).toContain(testCase.idLine);
|
||||
expect(text).toContain(`Pairing code: ${testCase.code}`);
|
||||
expect(text).toContain(
|
||||
`clawdbot pairing approve --provider ${testCase.provider} <code>`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PairingProvider } from "./pairing-store.js";
|
||||
|
||||
export function buildPairingReply(params: {
|
||||
provider: PairingProvider;
|
||||
idLine: string;
|
||||
code: string;
|
||||
}): string {
|
||||
const { provider, idLine, code } = params;
|
||||
return [
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
idLine,
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
`clawdbot pairing approve --provider ${provider} <code>`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
|
||||
export type SignalSender =
|
||||
| { kind: "phone"; raw: string; e164: string }
|
||||
| { kind: "uuid"; raw: string };
|
||||
|
||||
type SignalAllowEntry =
|
||||
| { kind: "any" }
|
||||
| { kind: "phone"; e164: string }
|
||||
| { kind: "uuid"; raw: string };
|
||||
|
||||
const UUID_HYPHENATED_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_COMPACT_RE = /^[0-9a-f]{32}$/i;
|
||||
|
||||
function looksLikeUuid(value: string): boolean {
|
||||
if (UUID_HYPHENATED_RE.test(value) || UUID_COMPACT_RE.test(value)) {
|
||||
return true;
|
||||
}
|
||||
const compact = value.replace(/-/g, "");
|
||||
if (!/^[0-9a-f]+$/i.test(compact)) return false;
|
||||
return /[a-f]/i.test(compact);
|
||||
}
|
||||
|
||||
function stripSignalPrefix(value: string): string {
|
||||
return value.replace(/^signal:/i, "").trim();
|
||||
}
|
||||
|
||||
export function resolveSignalSender(params: {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
}): SignalSender | null {
|
||||
const sourceNumber = params.sourceNumber?.trim();
|
||||
if (sourceNumber) {
|
||||
return {
|
||||
kind: "phone",
|
||||
raw: sourceNumber,
|
||||
e164: normalizeE164(sourceNumber),
|
||||
};
|
||||
}
|
||||
const sourceUuid = params.sourceUuid?.trim();
|
||||
if (sourceUuid) {
|
||||
return { kind: "uuid", raw: sourceUuid };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatSignalSenderId(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
export function formatSignalSenderDisplay(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
export function formatSignalPairingIdLine(sender: SignalSender): string {
|
||||
if (sender.kind === "phone") {
|
||||
return `Your Signal number: ${sender.e164}`;
|
||||
}
|
||||
return `Your Signal sender id: ${formatSignalSenderId(sender)}`;
|
||||
}
|
||||
|
||||
export function resolveSignalRecipient(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : sender.raw;
|
||||
}
|
||||
|
||||
export function resolveSignalPeerId(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
function parseSignalAllowEntry(entry: string): SignalAllowEntry | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed === "*") return { kind: "any" };
|
||||
|
||||
const stripped = stripSignalPrefix(trimmed);
|
||||
const lower = stripped.toLowerCase();
|
||||
if (lower.startsWith("uuid:")) {
|
||||
const raw = stripped.slice("uuid:".length).trim();
|
||||
if (!raw) return null;
|
||||
return { kind: "uuid", raw };
|
||||
}
|
||||
|
||||
if (looksLikeUuid(stripped)) {
|
||||
return { kind: "uuid", raw: stripped };
|
||||
}
|
||||
|
||||
return { kind: "phone", e164: normalizeE164(stripped) };
|
||||
}
|
||||
|
||||
export function isSignalSenderAllowed(
|
||||
sender: SignalSender,
|
||||
allowFrom: string[],
|
||||
): boolean {
|
||||
if (allowFrom.length === 0) return false;
|
||||
const parsed = allowFrom
|
||||
.map(parseSignalAllowEntry)
|
||||
.filter((entry): entry is SignalAllowEntry => entry !== null);
|
||||
if (parsed.some((entry) => entry.kind === "any")) return true;
|
||||
return parsed.some((entry) => {
|
||||
if (entry.kind === "phone" && sender.kind === "phone") {
|
||||
return entry.e164 === sender.e164;
|
||||
}
|
||||
if (entry.kind === "uuid" && sender.kind === "uuid") {
|
||||
return entry.raw === sender.raw;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function isSignalGroupAllowed(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
allowFrom: string[];
|
||||
sender: SignalSender;
|
||||
}): boolean {
|
||||
const { groupPolicy, allowFrom, sender } = params;
|
||||
if (groupPolicy === "disabled") return false;
|
||||
if (groupPolicy === "open") return true;
|
||||
if (allowFrom.length === 0) return false;
|
||||
return isSignalSenderAllowed(sender, allowFrom);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSignalGroupAllowed } from "./monitor.js";
|
||||
import { isSignalGroupAllowed } from "./identity.js";
|
||||
|
||||
describe("signal groupPolicy gating", () => {
|
||||
it("allows when policy is open", () => {
|
||||
@@ -8,7 +8,7 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
sender: "+15550001111",
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["+15550001111"],
|
||||
sender: "+15550001111",
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
sender: "+15550001111",
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -38,7 +38,7 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
sender: "+15550001111",
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -48,7 +48,20 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["*"],
|
||||
sender: "+15550002222",
|
||||
sender: { kind: "phone", raw: "+15550002222", e164: "+15550002222" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist when uuid sender matches", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["uuid:123e4567-e89b-12d3-a456-426614174000"],
|
||||
sender: {
|
||||
kind: "uuid",
|
||||
raw: "123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -146,6 +146,9 @@ describe("monitorSignalProvider tool results", () => {
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Your Signal number: +15550001111",
|
||||
);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Pairing code: PAIRCODE",
|
||||
);
|
||||
@@ -197,6 +200,51 @@ describe("monitorSignalProvider tool results", () => {
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
|
||||
config = {
|
||||
...config,
|
||||
signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] },
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
const uuid = "123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
const payload = {
|
||||
envelope: {
|
||||
sourceUuid: uuid,
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
};
|
||||
await onEvent({
|
||||
event: "receive",
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
await monitorSignalProvider({
|
||||
autoStart: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "signal", id: `uuid:${uuid}` }),
|
||||
);
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0]?.[0]).toBe(`signal:${uuid}`);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
`Your Signal sender id: uuid:${uuid}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("reconnects after stream errors until aborted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const abortController = new AbortController();
|
||||
|
||||
+48
-48
@@ -9,6 +9,7 @@ import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||
import {
|
||||
readProviderAllowFromStore,
|
||||
upsertProviderPairingRequest,
|
||||
@@ -19,11 +20,21 @@ import { normalizeE164 } from "../utils.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
import { spawnSignalDaemon } from "./daemon.js";
|
||||
import {
|
||||
formatSignalPairingIdLine,
|
||||
formatSignalSenderDisplay,
|
||||
formatSignalSenderId,
|
||||
isSignalSenderAllowed,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
} from "./identity.js";
|
||||
import { sendMessageSignal } from "./send.js";
|
||||
import { runSignalSseLoop } from "./sse-reconnect.js";
|
||||
|
||||
type SignalEnvelope = {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
sourceName?: string | null;
|
||||
timestamp?: number | null;
|
||||
dataMessage?: SignalDataMessage | null;
|
||||
@@ -91,28 +102,6 @@ function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||
return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
|
||||
if (allowFrom.length === 0) return false;
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedAllow = allowFrom
|
||||
.map((entry) => entry.replace(/^signal:/i, ""))
|
||||
.map((entry) => normalizeE164(entry));
|
||||
const normalizedSender = normalizeE164(sender);
|
||||
return normalizedAllow.includes(normalizedSender);
|
||||
}
|
||||
|
||||
export function isSignalGroupAllowed(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
allowFrom: string[];
|
||||
sender: string;
|
||||
}): boolean {
|
||||
const { groupPolicy, allowFrom, sender } = params;
|
||||
if (groupPolicy === "disabled") return false;
|
||||
if (groupPolicy === "open") return true;
|
||||
if (allowFrom.length === 0) return false;
|
||||
return isAllowedSender(sender, allowFrom);
|
||||
}
|
||||
|
||||
async function waitForSignalDaemonReady(params: {
|
||||
baseUrl: string;
|
||||
abortSignal?: AbortSignal;
|
||||
@@ -319,11 +308,19 @@ export async function monitorSignalProvider(
|
||||
envelope.dataMessage ?? envelope.editMessage?.dataMessage;
|
||||
if (!dataMessage) return;
|
||||
|
||||
const sender = envelope.sourceNumber?.trim();
|
||||
const sender = resolveSignalSender(envelope);
|
||||
if (!sender) return;
|
||||
if (account && normalizeE164(sender) === normalizeE164(account)) {
|
||||
return;
|
||||
if (account && sender.kind === "phone") {
|
||||
if (sender.e164 === normalizeE164(account)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const senderDisplay = formatSignalSenderDisplay(sender);
|
||||
const senderRecipient = resolveSignalRecipient(sender);
|
||||
const senderPeerId = resolveSignalPeerId(sender);
|
||||
const senderAllowId = formatSignalSenderId(sender);
|
||||
if (!senderRecipient) return;
|
||||
const senderIdLine = formatSignalPairingIdLine(sender);
|
||||
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
|
||||
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
|
||||
const isGroup = Boolean(groupId);
|
||||
@@ -333,13 +330,15 @@ export async function monitorSignalProvider(
|
||||
const effectiveDmAllow = [...allowFrom, ...storeAllowFrom];
|
||||
const effectiveGroupAllow = [...groupAllowFrom, ...storeAllowFrom];
|
||||
const dmAllowed =
|
||||
dmPolicy === "open" ? true : isAllowedSender(sender, effectiveDmAllow);
|
||||
dmPolicy === "open"
|
||||
? true
|
||||
: isSignalSenderAllowed(sender, effectiveDmAllow);
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") return;
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const senderId = normalizeE164(sender);
|
||||
const senderId = senderAllowId;
|
||||
const { code, created } = await upsertProviderPairingRequest({
|
||||
provider: "signal",
|
||||
id: senderId,
|
||||
@@ -351,15 +350,12 @@ export async function monitorSignalProvider(
|
||||
logVerbose(`signal pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendMessageSignal(
|
||||
senderId,
|
||||
[
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider signal <code>",
|
||||
].join("\n"),
|
||||
`signal:${senderRecipient}`,
|
||||
buildPairingReply({
|
||||
provider: "signal",
|
||||
idLine: senderIdLine,
|
||||
code,
|
||||
}),
|
||||
{
|
||||
baseUrl,
|
||||
account,
|
||||
@@ -375,7 +371,7 @@ export async function monitorSignalProvider(
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
`Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`,
|
||||
`Blocked signal sender ${senderDisplay} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -392,9 +388,9 @@ export async function monitorSignalProvider(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isAllowedSender(sender, effectiveGroupAllow)) {
|
||||
if (!isSignalSenderAllowed(sender, effectiveGroupAllow)) {
|
||||
logVerbose(
|
||||
`Blocked signal group sender ${sender} (not in groupAllowFrom)`,
|
||||
`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -402,7 +398,7 @@ export async function monitorSignalProvider(
|
||||
|
||||
const commandAuthorized = isGroup
|
||||
? effectiveGroupAllow.length > 0
|
||||
? isAllowedSender(sender, effectiveGroupAllow)
|
||||
? isSignalSenderAllowed(sender, effectiveGroupAllow)
|
||||
: true
|
||||
: dmAllowed;
|
||||
const messageText = (dataMessage.message ?? "").trim();
|
||||
@@ -417,7 +413,7 @@ export async function monitorSignalProvider(
|
||||
baseUrl,
|
||||
account,
|
||||
attachment: firstAttachment,
|
||||
sender,
|
||||
sender: senderRecipient,
|
||||
groupId,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
@@ -444,7 +440,7 @@ export async function monitorSignalProvider(
|
||||
|
||||
const fromLabel = isGroup
|
||||
? `${groupName ?? "Signal Group"} id:${groupId}`
|
||||
: `${envelope.sourceName ?? sender} id:${sender}`;
|
||||
: `${envelope.sourceName ?? senderDisplay} id:${senderDisplay}`;
|
||||
const body = formatAgentEnvelope({
|
||||
provider: "Signal",
|
||||
from: fromLabel,
|
||||
@@ -458,20 +454,24 @@ export async function monitorSignalProvider(
|
||||
accountId: accountInfo.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender),
|
||||
id: isGroup ? (groupId ?? "unknown") : senderPeerId,
|
||||
},
|
||||
});
|
||||
const signalTo = isGroup ? `group:${groupId}` : `signal:${sender}`;
|
||||
const signalTo = isGroup
|
||||
? `group:${groupId}`
|
||||
: `signal:${senderRecipient}`;
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
From: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${sender}`,
|
||||
From: isGroup
|
||||
? `group:${groupId ?? "unknown"}`
|
||||
: `signal:${senderRecipient}`,
|
||||
To: signalTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
GroupSubject: isGroup ? (groupName ?? undefined) : undefined,
|
||||
SenderName: envelope.sourceName ?? sender,
|
||||
SenderId: sender,
|
||||
SenderName: envelope.sourceName ?? senderDisplay,
|
||||
SenderId: senderDisplay,
|
||||
Provider: "signal" as const,
|
||||
Surface: "signal" as const,
|
||||
MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined,
|
||||
@@ -494,7 +494,7 @@ export async function monitorSignalProvider(
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
provider: "signal",
|
||||
to: normalizeE164(sender),
|
||||
to: senderRecipient,
|
||||
accountId: route.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -567,6 +567,9 @@ describe("monitorSlackProvider tool results", () => {
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Your Slack user id: U1",
|
||||
);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Pairing code: PAIRCODE",
|
||||
);
|
||||
|
||||
+22
-27
@@ -43,6 +43,7 @@ import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||
import {
|
||||
readProviderAllowFromStore,
|
||||
upsertProviderPairingRequest,
|
||||
@@ -508,7 +509,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
opts.slashCommand ?? slackCfg.slashCommand,
|
||||
);
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const mediaMaxBytes =
|
||||
@@ -825,14 +825,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
try {
|
||||
await sendMessageSlack(
|
||||
message.channel,
|
||||
[
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider slack <code>",
|
||||
].join("\n"),
|
||||
buildPairingReply({
|
||||
provider: "slack",
|
||||
idLine: `Your Slack user id: ${directUserId}`,
|
||||
code,
|
||||
}),
|
||||
{
|
||||
token: botToken,
|
||||
client: app.client,
|
||||
@@ -855,6 +852,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "slack",
|
||||
accountId: account.accountId,
|
||||
teamId: teamId || undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
||||
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
|
||||
},
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const wasMentioned =
|
||||
opts.wasMentioned ??
|
||||
(!isDirectMessage &&
|
||||
@@ -963,16 +971,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
: isRoom
|
||||
? `slack:channel:${message.channel}`
|
||||
: `slack:group:${message.channel}`;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "slack",
|
||||
accountId: account.accountId,
|
||||
teamId: teamId || undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
||||
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
|
||||
},
|
||||
});
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const threadTs = message.thread_ts;
|
||||
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
|
||||
@@ -1717,14 +1715,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
});
|
||||
if (created) {
|
||||
await respond({
|
||||
text: [
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider slack <code>",
|
||||
].join("\n"),
|
||||
text: buildPairingReply({
|
||||
provider: "slack",
|
||||
idLine: `Your Slack user id: ${command.user_id}`,
|
||||
code,
|
||||
}),
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("telegram audit", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("collects unmentioned numeric group ids and flags wildcard", async () => {
|
||||
const { collectTelegramUnmentionedGroupIds } = await import("./audit.js");
|
||||
const res = collectTelegramUnmentionedGroupIds({
|
||||
"*": { requireMention: false },
|
||||
"-1001": { requireMention: false },
|
||||
"@group": { requireMention: false },
|
||||
"-1002": { requireMention: true },
|
||||
"-1003": { requireMention: false, enabled: false },
|
||||
});
|
||||
expect(res.hasWildcardUnmentionedGroups).toBe(true);
|
||||
expect(res.groupIds).toEqual(["-1001"]);
|
||||
expect(res.unresolvedGroups).toBe(1);
|
||||
});
|
||||
|
||||
it("audits membership via getChatMember", async () => {
|
||||
const { auditTelegramGroupMembership } = await import("./audit.js");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ ok: true, result: { status: "member" } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
const res = await auditTelegramGroupMembership({
|
||||
token: "t",
|
||||
botId: 123,
|
||||
groupIds: ["-1001"],
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.groups[0]?.chatId).toBe("-1001");
|
||||
expect(res.groups[0]?.status).toBe("member");
|
||||
});
|
||||
|
||||
it("reports bot not in group when status is left", async () => {
|
||||
const { auditTelegramGroupMembership } = await import("./audit.js");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ok: true, result: { status: "left" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const res = await auditTelegramGroupMembership({
|
||||
token: "t",
|
||||
botId: 123,
|
||||
groupIds: ["-1001"],
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.groups[0]?.ok).toBe(false);
|
||||
expect(res.groups[0]?.status).toBe("left");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { TelegramGroupConfig } from "../config/types.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
|
||||
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
||||
|
||||
export type TelegramGroupMembershipAuditEntry = {
|
||||
chatId: string;
|
||||
ok: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type TelegramGroupMembershipAudit = {
|
||||
ok: boolean;
|
||||
checkedGroups: number;
|
||||
unresolvedGroups: number;
|
||||
hasWildcardUnmentionedGroups: boolean;
|
||||
groups: TelegramGroupMembershipAuditEntry[];
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
type TelegramApiOk<T> = { ok: true; result: T };
|
||||
type TelegramApiErr = { ok: false; description?: string };
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
fetcher: typeof fetch,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetcher(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function collectTelegramUnmentionedGroupIds(
|
||||
groups: Record<string, TelegramGroupConfig> | undefined,
|
||||
) {
|
||||
if (!groups || typeof groups !== "object") {
|
||||
return {
|
||||
groupIds: [] as string[],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
};
|
||||
}
|
||||
const hasWildcardUnmentionedGroups =
|
||||
Boolean(groups["*"]?.requireMention === false) &&
|
||||
groups["*"]?.enabled !== false;
|
||||
const groupIds: string[] = [];
|
||||
let unresolvedGroups = 0;
|
||||
for (const [key, value] of Object.entries(groups)) {
|
||||
if (key === "*") continue;
|
||||
if (!value || typeof value !== "object") continue;
|
||||
if ((value as TelegramGroupConfig).enabled === false) continue;
|
||||
if ((value as TelegramGroupConfig).requireMention !== false) continue;
|
||||
const id = String(key).trim();
|
||||
if (!id) continue;
|
||||
if (/^-?\d+$/.test(id)) {
|
||||
groupIds.push(id);
|
||||
} else {
|
||||
unresolvedGroups += 1;
|
||||
}
|
||||
}
|
||||
groupIds.sort((a, b) => a.localeCompare(b));
|
||||
return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
}
|
||||
|
||||
export async function auditTelegramGroupMembership(params: {
|
||||
token: string;
|
||||
botId: number;
|
||||
groupIds: string[];
|
||||
proxyUrl?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<TelegramGroupMembershipAudit> {
|
||||
const started = Date.now();
|
||||
const token = params.token?.trim() ?? "";
|
||||
if (!token || params.groupIds.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
checkedGroups: 0,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups: [],
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const fetcher = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : fetch;
|
||||
const base = `${TELEGRAM_API_BASE}/bot${token}`;
|
||||
const groups: TelegramGroupMembershipAuditEntry[] = [];
|
||||
|
||||
for (const chatId of params.groupIds) {
|
||||
try {
|
||||
const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`;
|
||||
const res = await fetchWithTimeout(url, params.timeoutMs, fetcher);
|
||||
const json = (await res.json()) as
|
||||
| TelegramApiOk<{ status?: string }>
|
||||
| TelegramApiErr;
|
||||
if (!res.ok || !isRecord(json) || json.ok !== true) {
|
||||
const desc =
|
||||
isRecord(json) &&
|
||||
json.ok === false &&
|
||||
typeof json.description === "string"
|
||||
? json.description
|
||||
: `getChatMember failed (${res.status})`;
|
||||
groups.push({ chatId, ok: false, status: null, error: desc });
|
||||
continue;
|
||||
}
|
||||
const status = isRecord((json as TelegramApiOk<unknown>).result)
|
||||
? ((json as TelegramApiOk<{ status?: string }>).result.status ?? null)
|
||||
: null;
|
||||
const ok =
|
||||
status === "creator" ||
|
||||
status === "administrator" ||
|
||||
status === "member";
|
||||
groups.push({
|
||||
chatId,
|
||||
ok,
|
||||
status,
|
||||
error: ok ? null : "bot not in group",
|
||||
});
|
||||
} catch (err) {
|
||||
groups.push({
|
||||
chatId,
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: groups.every((g) => g.ok),
|
||||
checkedGroups: groups.length,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
@@ -284,6 +284,9 @@ describe("createTelegramBot", () => {
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234);
|
||||
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain(
|
||||
"Your Telegram user id: 999",
|
||||
);
|
||||
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain(
|
||||
"Pairing code:",
|
||||
);
|
||||
|
||||
+11
-1
@@ -38,6 +38,7 @@ import {
|
||||
} from "../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { detectMime, isGifMedia } from "../media/mime.js";
|
||||
@@ -225,7 +226,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024;
|
||||
const logger = getChildLogger({ module: "telegram-auto-reply" });
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
let botHasTopicsEnabled: boolean | undefined;
|
||||
const resolveBotTopicsEnabled = async (ctx?: TelegramContext) => {
|
||||
const fromCtx = ctx?.me as { has_topics_enabled?: boolean } | undefined;
|
||||
@@ -301,6 +301,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
storeAllowFrom: string[],
|
||||
) => {
|
||||
const msg = primaryCtx.message;
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number })
|
||||
@@ -322,6 +327,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const effectiveDmAllow = normalizeAllowFrom([
|
||||
...(allowFrom ?? []),
|
||||
...storeAllowFrom,
|
||||
@@ -384,8 +390,10 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
id?: number;
|
||||
}
|
||||
| undefined;
|
||||
const telegramUserId = from?.id ? String(from.id) : candidate;
|
||||
const { code, created } = await upsertTelegramPairingRequest({
|
||||
chatId: candidate,
|
||||
username: from?.username,
|
||||
@@ -407,6 +415,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
[
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
`Your Telegram user id: ${telegramUserId}`,
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiClientOptions } from "grammy";
|
||||
import { Bot, InputFile } from "grammy";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
@@ -227,6 +228,11 @@ export async function sendMessageTelegram(
|
||||
});
|
||||
}
|
||||
const messageId = String(result?.message_id ?? "unknown");
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId, chatId: String(result?.chat?.id ?? chatId) };
|
||||
}
|
||||
|
||||
@@ -263,6 +269,11 @@ export async function sendMessageTelegram(
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
const messageId = String(res?.message_id ?? "unknown");
|
||||
recordProviderActivity({
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ export function getSlashCommands(): SlashCommand[] {
|
||||
return [
|
||||
{ name: "help", description: "Show slash command help" },
|
||||
{ name: "status", description: "Show gateway status summary" },
|
||||
{ name: "agent", description: "Switch agent (or open picker)" },
|
||||
{ name: "agents", description: "Open agent picker" },
|
||||
{ name: "session", description: "Switch session (or open picker)" },
|
||||
{ name: "sessions", description: "Open session picker" },
|
||||
{
|
||||
@@ -108,6 +110,7 @@ export function helpText(): string {
|
||||
"Slash commands:",
|
||||
"/help",
|
||||
"/status",
|
||||
"/agent <id> (or /agents)",
|
||||
"/session <key> (or /sessions)",
|
||||
"/model <provider/model> (or /models)",
|
||||
"/think <off|minimal|low|medium|high>",
|
||||
|
||||
@@ -4,6 +4,7 @@ export class CustomEditor extends Editor {
|
||||
onEscape?: () => void;
|
||||
onCtrlC?: () => void;
|
||||
onCtrlD?: () => void;
|
||||
onCtrlG?: () => void;
|
||||
onCtrlL?: () => void;
|
||||
onCtrlO?: () => void;
|
||||
onCtrlP?: () => void;
|
||||
@@ -28,6 +29,10 @@ export class CustomEditor extends Editor {
|
||||
this.onCtrlP();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.ctrl("g")) && this.onCtrlG) {
|
||||
this.onCtrlG();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.ctrl("t")) && this.onCtrlT) {
|
||||
this.onCtrlT();
|
||||
return;
|
||||
|
||||
@@ -57,6 +57,16 @@ export type GatewaySessionList = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GatewayAgentsList = {
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope: "per-sender" | "global";
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GatewayModelChoice = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -165,9 +175,14 @@ export class GatewayChatClient {
|
||||
activeMinutes: opts?.activeMinutes,
|
||||
includeGlobal: opts?.includeGlobal,
|
||||
includeUnknown: opts?.includeUnknown,
|
||||
agentId: opts?.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
async listAgents() {
|
||||
return await this.client.request<GatewayAgentsList>("agents.list", {});
|
||||
}
|
||||
|
||||
async patchSession(opts: SessionsPatchParams) {
|
||||
return await this.client.request("sessions.patch", opts);
|
||||
}
|
||||
|
||||
+196
-23
@@ -7,6 +7,11 @@ import {
|
||||
TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
|
||||
import { ChatLog } from "./components/chat-log.js";
|
||||
import { CustomEditor } from "./components/custom-editor.js";
|
||||
@@ -14,7 +19,7 @@ import {
|
||||
createSelectList,
|
||||
createSettingsList,
|
||||
} from "./components/selectors.js";
|
||||
import { GatewayChatClient } from "./gateway-chat.js";
|
||||
import { type GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
|
||||
import { editorTheme, theme } from "./theme/theme.js";
|
||||
|
||||
export type TuiOptions = {
|
||||
@@ -53,6 +58,13 @@ type SessionInfo = {
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
type SessionScope = "per-sender" | "global";
|
||||
|
||||
type AgentSummary = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function extractTextBlocks(
|
||||
content: unknown,
|
||||
opts?: { includeThinking?: boolean },
|
||||
@@ -106,9 +118,18 @@ function asString(value: unknown, fallback = ""): string {
|
||||
|
||||
export async function runTui(opts: TuiOptions) {
|
||||
const config = loadConfig();
|
||||
const defaultSession =
|
||||
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
||||
let currentSessionKey = defaultSession;
|
||||
const initialSessionInput = (opts.session ?? "").trim();
|
||||
let sessionScope: SessionScope = (config.session?.scope ??
|
||||
"per-sender") as SessionScope;
|
||||
let sessionMainKey = (config.session?.mainKey ?? "main").trim() || "main";
|
||||
let agentDefaultId = normalizeAgentId(
|
||||
config.routing?.defaultAgentId ?? "main",
|
||||
);
|
||||
let currentAgentId = agentDefaultId;
|
||||
let agents: AgentSummary[] = [];
|
||||
const agentNames = new Map<string, string>();
|
||||
let currentSessionKey = "";
|
||||
let initialSessionApplied = false;
|
||||
let currentSessionId: string | null = null;
|
||||
let activeChatRunId: string | null = null;
|
||||
const finalizedRuns = new Map<string, number>();
|
||||
@@ -144,10 +165,39 @@ export async function runTui(opts: TuiOptions) {
|
||||
tui.addChild(root);
|
||||
tui.setFocus(editor);
|
||||
|
||||
const formatSessionKey = (key: string) => {
|
||||
if (key === "global" || key === "unknown") return key;
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
return parsed?.rest ?? key;
|
||||
};
|
||||
|
||||
const formatAgentLabel = (id: string) => {
|
||||
const name = agentNames.get(id);
|
||||
return name ? `${id} (${name})` : id;
|
||||
};
|
||||
|
||||
const resolveSessionKey = (raw?: string) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (sessionScope === "global") return "global";
|
||||
if (!trimmed) {
|
||||
return buildAgentMainSessionKey({
|
||||
agentId: currentAgentId,
|
||||
mainKey: sessionMainKey,
|
||||
});
|
||||
}
|
||||
if (trimmed === "global" || trimmed === "unknown") return trimmed;
|
||||
if (trimmed.startsWith("agent:")) return trimmed;
|
||||
return `agent:${currentAgentId}:${trimmed}`;
|
||||
};
|
||||
|
||||
currentSessionKey = resolveSessionKey(initialSessionInput);
|
||||
|
||||
const updateHeader = () => {
|
||||
const sessionLabel = formatSessionKey(currentSessionKey);
|
||||
const agentLabel = formatAgentLabel(currentAgentId);
|
||||
header.setText(
|
||||
theme.header(
|
||||
`clawdbot tui - ${client.connection.url} - session ${currentSessionKey}`,
|
||||
`clawdbot tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -158,9 +208,11 @@ export async function runTui(opts: TuiOptions) {
|
||||
|
||||
const updateFooter = () => {
|
||||
const connection = isConnected ? "connected" : "disconnected";
|
||||
const sessionKeyLabel = formatSessionKey(currentSessionKey);
|
||||
const sessionLabel = sessionInfo.displayName
|
||||
? `${currentSessionKey} (${sessionInfo.displayName})`
|
||||
: currentSessionKey;
|
||||
? `${sessionKeyLabel} (${sessionInfo.displayName})`
|
||||
: sessionKeyLabel;
|
||||
const agentLabel = formatAgentLabel(currentAgentId);
|
||||
const modelLabel = sessionInfo.model ?? "unknown";
|
||||
const tokens = formatTokens(
|
||||
sessionInfo.totalTokens ?? null,
|
||||
@@ -172,7 +224,7 @@ export async function runTui(opts: TuiOptions) {
|
||||
const deliver = deliverDefault ? "on" : "off";
|
||||
footer.setText(
|
||||
theme.dim(
|
||||
`${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | reasoning ${reasoning} | ${tokens} | deliver ${deliver}`,
|
||||
`${connection} | agent ${agentLabel} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | reasoning ${reasoning} | ${tokens} | deliver ${deliver}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -188,15 +240,82 @@ export async function runTui(opts: TuiOptions) {
|
||||
tui.setFocus(component);
|
||||
};
|
||||
|
||||
const initialSessionAgentId = (() => {
|
||||
if (!initialSessionInput) return null;
|
||||
const parsed = parseAgentSessionKey(initialSessionInput);
|
||||
return parsed ? normalizeAgentId(parsed.agentId) : null;
|
||||
})();
|
||||
|
||||
const applyAgentsResult = (result: GatewayAgentsList) => {
|
||||
agentDefaultId = normalizeAgentId(result.defaultId);
|
||||
sessionMainKey = result.mainKey.trim() || sessionMainKey;
|
||||
sessionScope = result.scope ?? sessionScope;
|
||||
agents = result.agents.map((agent) => ({
|
||||
id: normalizeAgentId(agent.id),
|
||||
name: agent.name?.trim() || undefined,
|
||||
}));
|
||||
agentNames.clear();
|
||||
for (const agent of agents) {
|
||||
if (agent.name) agentNames.set(agent.id, agent.name);
|
||||
}
|
||||
if (!initialSessionApplied) {
|
||||
if (initialSessionAgentId) {
|
||||
if (agents.some((agent) => agent.id === initialSessionAgentId)) {
|
||||
currentAgentId = initialSessionAgentId;
|
||||
}
|
||||
} else if (!agents.some((agent) => agent.id === currentAgentId)) {
|
||||
currentAgentId =
|
||||
agents[0]?.id ?? normalizeAgentId(result.defaultId ?? currentAgentId);
|
||||
}
|
||||
const nextSessionKey = resolveSessionKey(initialSessionInput);
|
||||
if (nextSessionKey !== currentSessionKey) {
|
||||
currentSessionKey = nextSessionKey;
|
||||
}
|
||||
initialSessionApplied = true;
|
||||
} else if (!agents.some((agent) => agent.id === currentAgentId)) {
|
||||
currentAgentId =
|
||||
agents[0]?.id ?? normalizeAgentId(result.defaultId ?? currentAgentId);
|
||||
}
|
||||
updateHeader();
|
||||
updateFooter();
|
||||
};
|
||||
|
||||
const refreshAgents = async () => {
|
||||
try {
|
||||
const result = await client.listAgents();
|
||||
applyAgentsResult(result);
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`agents list failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAgentFromSessionKey = (key: string) => {
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
if (!parsed) return;
|
||||
const next = normalizeAgentId(parsed.agentId);
|
||||
if (next !== currentAgentId) {
|
||||
currentAgentId = next;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSessionInfo = async () => {
|
||||
try {
|
||||
const listAgentId =
|
||||
currentSessionKey === "global" || currentSessionKey === "unknown"
|
||||
? undefined
|
||||
: currentAgentId;
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: listAgentId,
|
||||
});
|
||||
const entry = result.sessions.find((row) => {
|
||||
// Exact match
|
||||
if (row.key === currentSessionKey) return true;
|
||||
// Also match canonical keys like "agent:default:main" against "main"
|
||||
const parsed = parseAgentSessionKey(row.key);
|
||||
return parsed?.rest === currentSessionKey;
|
||||
});
|
||||
const entry = result.sessions.find(
|
||||
(row) => row.key === currentSessionKey,
|
||||
);
|
||||
sessionInfo = {
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
@@ -272,12 +391,15 @@ export async function runTui(opts: TuiOptions) {
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const setSession = async (key: string) => {
|
||||
currentSessionKey = key;
|
||||
const setSession = async (rawKey: string) => {
|
||||
const nextKey = resolveSessionKey(rawKey);
|
||||
updateAgentFromSessionKey(nextKey);
|
||||
currentSessionKey = nextKey;
|
||||
activeChatRunId = null;
|
||||
currentSessionId = null;
|
||||
historyLoaded = false;
|
||||
updateHeader();
|
||||
updateFooter();
|
||||
await loadHistory();
|
||||
};
|
||||
|
||||
@@ -429,15 +551,51 @@ export async function runTui(opts: TuiOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
const setAgent = async (id: string) => {
|
||||
currentAgentId = normalizeAgentId(id);
|
||||
await setSession("");
|
||||
};
|
||||
|
||||
const openAgentSelector = async () => {
|
||||
await refreshAgents();
|
||||
if (agents.length === 0) {
|
||||
chatLog.addSystem("no agents found");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
const items = agents.map((agent) => ({
|
||||
value: agent.id,
|
||||
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
|
||||
description: agent.id === agentDefaultId ? "default" : "",
|
||||
}));
|
||||
const selector = createSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
closeOverlay();
|
||||
await setAgent(item.value);
|
||||
tui.requestRender();
|
||||
})();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
};
|
||||
openOverlay(selector);
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const openSessionSelector = async () => {
|
||||
try {
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: currentAgentId,
|
||||
});
|
||||
const items = result.sessions.map((session) => ({
|
||||
value: session.key,
|
||||
label: session.displayName ?? session.key,
|
||||
label: session.displayName
|
||||
? `${session.displayName} (${formatSessionKey(session.key)})`
|
||||
: formatSessionKey(session.key),
|
||||
description: session.updatedAt
|
||||
? new Date(session.updatedAt).toLocaleString()
|
||||
: "",
|
||||
@@ -528,6 +686,16 @@ export async function runTui(opts: TuiOptions) {
|
||||
chatLog.addSystem(`status failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "agent":
|
||||
if (!args) {
|
||||
await openAgentSelector();
|
||||
} else {
|
||||
await setAgent(args);
|
||||
}
|
||||
break;
|
||||
case "agents":
|
||||
await openAgentSelector();
|
||||
break;
|
||||
case "session":
|
||||
if (!args) {
|
||||
await openSessionSelector();
|
||||
@@ -744,6 +912,9 @@ export async function runTui(opts: TuiOptions) {
|
||||
editor.onCtrlL = () => {
|
||||
void openModelSelector();
|
||||
};
|
||||
editor.onCtrlG = () => {
|
||||
void openAgentSelector();
|
||||
};
|
||||
editor.onCtrlP = () => {
|
||||
void openSessionSelector();
|
||||
};
|
||||
@@ -760,17 +931,19 @@ export async function runTui(opts: TuiOptions) {
|
||||
client.onConnected = () => {
|
||||
isConnected = true;
|
||||
setStatus("connected");
|
||||
updateHeader();
|
||||
if (!historyLoaded) {
|
||||
void loadHistory().then(() => {
|
||||
void (async () => {
|
||||
await refreshAgents();
|
||||
updateHeader();
|
||||
if (!historyLoaded) {
|
||||
await loadHistory();
|
||||
chatLog.addSystem("gateway connected");
|
||||
tui.requestRender();
|
||||
});
|
||||
} else {
|
||||
chatLog.addSystem("gateway reconnected");
|
||||
}
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
} else {
|
||||
chatLog.addSystem("gateway reconnected");
|
||||
}
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
})();
|
||||
};
|
||||
|
||||
client.onDisconnected = (reason) => {
|
||||
|
||||
@@ -1132,6 +1132,84 @@ describe("web auto-reply", () => {
|
||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns for group gating", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
routing: {
|
||||
groupChat: { mentionPatterns: ["@global"] },
|
||||
agents: {
|
||||
work: { mentionPatterns: ["@workbot"] },
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "work",
|
||||
match: {
|
||||
provider: "whatsapp",
|
||||
peer: { kind: "group", id: "123@g.us" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@global ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g1",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@workbot ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g2",
|
||||
senderE164: "+222",
|
||||
senderName: "Bob",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows group messages when whatsapp groups default disables mention gating", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
+21
-7
@@ -162,8 +162,11 @@ type MentionConfig = {
|
||||
allowFrom?: Array<string | number>;
|
||||
};
|
||||
|
||||
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
function buildMentionConfig(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
agentId?: string,
|
||||
): MentionConfig {
|
||||
const mentionRegexes = buildMentionRegexes(cfg, agentId);
|
||||
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
|
||||
}
|
||||
|
||||
@@ -793,7 +796,9 @@ export async function monitorWebProvider(
|
||||
tuning.heartbeatSeconds,
|
||||
);
|
||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||
const mentionConfig = buildMentionConfig(cfg);
|
||||
const resolveMentionConfig = (agentId?: string) =>
|
||||
buildMentionConfig(cfg, agentId);
|
||||
const baseMentionConfig = resolveMentionConfig();
|
||||
const groupHistoryLimit =
|
||||
cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT;
|
||||
const groupHistories = new Map<
|
||||
@@ -913,7 +918,7 @@ export async function monitorWebProvider(
|
||||
};
|
||||
|
||||
const resolveOwnerList = (selfE164?: string | null) => {
|
||||
const allowFrom = mentionConfig.allowFrom;
|
||||
const allowFrom = baseMentionConfig.allowFrom;
|
||||
const raw =
|
||||
Array.isArray(allowFrom) && allowFrom.length > 0
|
||||
? allowFrom
|
||||
@@ -943,9 +948,13 @@ export async function monitorWebProvider(
|
||||
);
|
||||
};
|
||||
|
||||
const stripMentionsForCommand = (text: string, selfE164?: string | null) => {
|
||||
const stripMentionsForCommand = (
|
||||
text: string,
|
||||
mentionRegexes: RegExp[],
|
||||
selfE164?: string | null,
|
||||
) => {
|
||||
let result = text;
|
||||
for (const re of mentionConfig.mentionRegexes) {
|
||||
for (const re of mentionRegexes) {
|
||||
result = result.replace(re, " ");
|
||||
}
|
||||
if (selfE164) {
|
||||
@@ -1362,7 +1371,12 @@ export async function monitorWebProvider(
|
||||
});
|
||||
}
|
||||
noteGroupMember(groupHistoryKey, msg.senderE164, msg.senderName);
|
||||
const commandBody = stripMentionsForCommand(msg.body, msg.selfE164);
|
||||
const mentionConfig = resolveMentionConfig(route.agentId);
|
||||
const commandBody = stripMentionsForCommand(
|
||||
msg.body,
|
||||
mentionConfig.mentionRegexes,
|
||||
msg.selfE164,
|
||||
);
|
||||
const activationCommand = parseActivationCommand(commandBody);
|
||||
const isOwner = isOwnerSender(msg);
|
||||
const statusCommand = isStatusCommand(commandBody);
|
||||
|
||||
+24
-10
@@ -14,8 +14,10 @@ import {
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { recordProviderActivity } from "../infra/provider-activity.js";
|
||||
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||
import {
|
||||
readProviderAllowFromStore,
|
||||
upsertProviderPairingRequest,
|
||||
@@ -171,6 +173,11 @@ export async function monitorWebInbox(options: {
|
||||
}) => {
|
||||
if (upsert.type !== "notify" && upsert.type !== "append") return;
|
||||
for (const msg of upsert.messages ?? []) {
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const id = msg.key?.id ?? undefined;
|
||||
// De-dupe on message id; Baileys can emit retries.
|
||||
if (id && seen.has(id)) continue;
|
||||
@@ -304,14 +311,11 @@ export async function monitorWebInbox(options: {
|
||||
);
|
||||
try {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: [
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider whatsapp <code>",
|
||||
].join("\n"),
|
||||
text: buildPairingReply({
|
||||
provider: "whatsapp",
|
||||
idLine: `Your WhatsApp sender id: ${candidate}`,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
@@ -536,7 +540,7 @@ export async function monitorWebInbox(options: {
|
||||
text: string,
|
||||
mediaBuffer?: Buffer,
|
||||
mediaType?: string,
|
||||
options?: ActiveWebSendOptions,
|
||||
sendOptions?: ActiveWebSendOptions,
|
||||
): Promise<{ messageId: string }> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
let payload: AnyMessageContent;
|
||||
@@ -554,7 +558,7 @@ export async function monitorWebInbox(options: {
|
||||
mimetype: mediaType,
|
||||
};
|
||||
} else if (mediaType.startsWith("video/")) {
|
||||
const gifPlayback = options?.gifPlayback;
|
||||
const gifPlayback = sendOptions?.gifPlayback;
|
||||
payload = {
|
||||
video: mediaBuffer,
|
||||
caption: text || undefined,
|
||||
@@ -573,6 +577,11 @@ export async function monitorWebInbox(options: {
|
||||
payload = { text };
|
||||
}
|
||||
const result = await sock.sendMessage(jid, payload);
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId: result?.key?.id ?? "unknown" };
|
||||
},
|
||||
/**
|
||||
@@ -591,6 +600,11 @@ export async function monitorWebInbox(options: {
|
||||
selectableCount: poll.maxSelections ?? 1,
|
||||
},
|
||||
});
|
||||
recordProviderActivity({
|
||||
provider: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId: result?.key?.id ?? "unknown" };
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -670,6 +670,9 @@ describe("web monitor inbox", () => {
|
||||
// Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn).
|
||||
expect(sock.readMessages).not.toHaveBeenCalled();
|
||||
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Your WhatsApp sender id: +999"),
|
||||
});
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Pairing code: PAIRCODE"),
|
||||
});
|
||||
@@ -1121,6 +1124,9 @@ describe("web monitor inbox", () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Your WhatsApp sender id: +999"),
|
||||
});
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Pairing code: PAIRCODE"),
|
||||
});
|
||||
@@ -1274,6 +1280,9 @@ describe("web monitor inbox", () => {
|
||||
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Your WhatsApp sender id: +999"),
|
||||
});
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Pairing code: PAIRCODE"),
|
||||
});
|
||||
|
||||
@@ -46,7 +46,9 @@ import {
|
||||
} from "../config/config.js";
|
||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -221,10 +223,16 @@ export async function runOnboardingWizard(
|
||||
},
|
||||
};
|
||||
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const authStore = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const authChoice = (await prompter.select({
|
||||
message: "Model/auth choice",
|
||||
options: buildAuthChoiceOptions({ store: authStore, includeSkip: true }),
|
||||
options: buildAuthChoiceOptions({
|
||||
store: authStore,
|
||||
includeSkip: true,
|
||||
includeClaudeCliIfMissing: true,
|
||||
}),
|
||||
})) as AuthChoice;
|
||||
|
||||
const authResult = await applyAuthChoice({
|
||||
@@ -480,20 +488,26 @@ export async function runOnboardingWizard(
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
const environment: Record<string, string | undefined> = {
|
||||
PATH: process.env.PATH,
|
||||
CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
|
||||
CLAWDBOT_LAUNCHD_LABEL:
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token: gatewayToken,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? GATEWAY_LAUNCH_AGENT_LABEL
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
|
||||
Reference in new issue
Block a user