Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
isMessagingToolDuplicate,
|
||||
normalizeTextForComparison,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
sanitizeSessionMessagesImages,
|
||||
validateGeminiTurns,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
@@ -250,6 +251,77 @@ describe("sanitizeGoogleTurnOrdering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSessionMessagesImages", () => {
|
||||
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const content = (out[0] as { content?: unknown }).content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content).toHaveLength(1);
|
||||
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
|
||||
});
|
||||
|
||||
it("filters whitespace-only assistant text blocks", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "ok" },
|
||||
],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const content = (out[0] as { content?: unknown }).content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content).toHaveLength(1);
|
||||
expect((content as Array<{ text?: string }>)[0]?.text).toBe("ok");
|
||||
});
|
||||
|
||||
it("drops assistant messages that only contain empty text", async () => {
|
||||
const input = [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "" }] },
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.role).toBe("user");
|
||||
});
|
||||
|
||||
it("leaves non-assistant messages unchanged", async () => {
|
||||
const input = [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "toolResult",
|
||||
toolUseId: "tool-1",
|
||||
content: [{ type: "text", text: "result" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]?.role).toBe("user");
|
||||
expect(out[1]?.role).toBe("toolResult");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTextForComparison", () => {
|
||||
it("lowercases text", () => {
|
||||
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
|
||||
|
||||
@@ -99,6 +99,28 @@ export async function sanitizeSessionMessagesImages(
|
||||
}
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const content = assistantMsg.content;
|
||||
if (Array.isArray(content)) {
|
||||
const filteredContent = content.filter((block) => {
|
||||
if (!block || typeof block !== "object") return true;
|
||||
const rec = block as { type?: unknown; text?: unknown };
|
||||
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
||||
return rec.text.trim().length > 0;
|
||||
});
|
||||
const sanitizedContent = (await sanitizeContentBlocksImages(
|
||||
filteredContent as unknown as ContentBlock[],
|
||||
label,
|
||||
)) as unknown as typeof assistantMsg.content;
|
||||
if (sanitizedContent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
out.push({ ...assistantMsg, content: sanitizedContent });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out.push(msg);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -547,14 +547,14 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
||||
defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`);
|
||||
}
|
||||
if (service.configAudit?.issues.length) {
|
||||
defaultRuntime.error(
|
||||
"Service config looks out of date or non-standard.",
|
||||
);
|
||||
defaultRuntime.error("Service config looks out of date or non-standard.");
|
||||
for (const issue of service.configAudit.issues) {
|
||||
const detail = issue.detail ? ` (${issue.detail})` : "";
|
||||
defaultRuntime.error(`Service config issue: ${issue.message}${detail}`);
|
||||
}
|
||||
defaultRuntime.error('Recommendation: run "clawdbot doctor".');
|
||||
defaultRuntime.error(
|
||||
'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").',
|
||||
);
|
||||
}
|
||||
if (status.config) {
|
||||
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
||||
|
||||
@@ -74,6 +74,13 @@ function parsePort(raw: unknown): number | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const toOptionString = (value: unknown): string | undefined => {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "bigint")
|
||||
return value.toString();
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function describeUnknownError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
@@ -338,9 +345,10 @@ async function runGatewayCommand(
|
||||
}
|
||||
}
|
||||
if (opts.token) {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token);
|
||||
const token = toOptionString(opts.token);
|
||||
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
}
|
||||
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
||||
const authModeRaw = toOptionString(opts.auth);
|
||||
const authMode: GatewayAuthMode | null =
|
||||
authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null;
|
||||
if (authModeRaw && !authMode) {
|
||||
@@ -348,7 +356,7 @@ async function runGatewayCommand(
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
||||
const tailscaleRaw = toOptionString(opts.tailscale);
|
||||
const tailscaleMode =
|
||||
tailscaleRaw === "off" ||
|
||||
tailscaleRaw === "serve" ||
|
||||
@@ -362,6 +370,8 @@ async function runGatewayCommand(
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const passwordRaw = toOptionString(opts.password);
|
||||
const tokenRaw = toOptionString(opts.token);
|
||||
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
|
||||
const mode = cfg.gateway?.mode;
|
||||
if (!opts.allowUnconfigured && mode !== "local") {
|
||||
@@ -377,7 +387,7 @@ async function runGatewayCommand(
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
|
||||
const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
|
||||
const bind =
|
||||
bindRaw === "loopback" ||
|
||||
bindRaw === "tailnet" ||
|
||||
@@ -398,8 +408,8 @@ async function runGatewayCommand(
|
||||
const authConfig = {
|
||||
...cfg.gateway?.auth,
|
||||
...(authMode ? { mode: authMode } : {}),
|
||||
...(opts.password ? { password: String(opts.password) } : {}),
|
||||
...(opts.token ? { token: String(opts.token) } : {}),
|
||||
...(passwordRaw ? { password: passwordRaw } : {}),
|
||||
...(tokenRaw ? { token: tokenRaw } : {}),
|
||||
};
|
||||
const resolvedAuth = resolveGatewayAuth({
|
||||
authConfig,
|
||||
@@ -467,11 +477,11 @@ async function runGatewayCommand(
|
||||
await startGatewayServer(port, {
|
||||
bind,
|
||||
auth:
|
||||
authMode || opts.password || opts.token || authModeRaw
|
||||
authMode || passwordRaw || tokenRaw || authModeRaw
|
||||
? {
|
||||
mode: authMode ?? undefined,
|
||||
token: opts.token ? String(opts.token) : undefined,
|
||||
password: opts.password ? String(opts.password) : undefined,
|
||||
token: tokenRaw,
|
||||
password: passwordRaw,
|
||||
}
|
||||
: undefined,
|
||||
tailscale:
|
||||
|
||||
@@ -328,6 +328,12 @@ export function buildProgram() {
|
||||
false,
|
||||
)
|
||||
.option("--yes", "Accept defaults without prompting", false)
|
||||
.option("--repair", "Apply recommended repairs without prompting", false)
|
||||
.option(
|
||||
"--force",
|
||||
"Apply aggressive repairs (overwrites custom service config)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Run without prompts (safe migrations only)",
|
||||
@@ -339,6 +345,8 @@ export function buildProgram() {
|
||||
await doctorCommand(defaultRuntime, {
|
||||
workspaceSuggestions: opts.workspaceSuggestions,
|
||||
yes: Boolean(opts.yes),
|
||||
repair: Boolean(opts.repair),
|
||||
force: Boolean(opts.force),
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
deep: Boolean(opts.deep),
|
||||
});
|
||||
|
||||
@@ -142,7 +142,12 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
const command = await service.readCommand(process.env).catch(() => null);
|
||||
let command: Awaited<ReturnType<typeof service.readCommand>> | null = null;
|
||||
try {
|
||||
command = await service.readCommand(process.env);
|
||||
} catch {
|
||||
command = null;
|
||||
}
|
||||
if (!command) return;
|
||||
|
||||
const audit = await auditGatewayServiceConfig({
|
||||
@@ -154,16 +159,39 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
note(
|
||||
audit.issues
|
||||
.map((issue) =>
|
||||
issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`,
|
||||
issue.detail
|
||||
? `- ${issue.message} (${issue.detail})`
|
||||
: `- ${issue.message}`,
|
||||
)
|
||||
.join("\n"),
|
||||
"Gateway service config",
|
||||
);
|
||||
|
||||
const repair = await prompter.confirmSkipInNonInteractive({
|
||||
message: "Update gateway service config to the recommended defaults now?",
|
||||
initialValue: true,
|
||||
});
|
||||
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) {
|
||||
note(
|
||||
"Custom or unexpected service edits detected. Rerun with --force to overwrite.",
|
||||
"Gateway service config",
|
||||
);
|
||||
}
|
||||
|
||||
const repair = needsAggressive
|
||||
? await prompter.confirmAggressive({
|
||||
message: "Overwrite gateway service config with current defaults now?",
|
||||
initialValue: Boolean(prompter.shouldForce),
|
||||
})
|
||||
: await prompter.confirmRepair({
|
||||
message:
|
||||
"Update gateway service config to the recommended defaults now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!repair) return;
|
||||
|
||||
const devMode =
|
||||
|
||||
@@ -8,14 +8,22 @@ export type DoctorOptions = {
|
||||
yes?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
deep?: boolean;
|
||||
repair?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type DoctorPrompter = {
|
||||
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
|
||||
confirmRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
|
||||
confirmAggressive: (
|
||||
params: Parameters<typeof confirm>[0],
|
||||
) => Promise<boolean>;
|
||||
confirmSkipInNonInteractive: (
|
||||
params: Parameters<typeof confirm>[0],
|
||||
) => Promise<boolean>;
|
||||
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
|
||||
shouldRepair: boolean;
|
||||
shouldForce: boolean;
|
||||
};
|
||||
|
||||
export function createDoctorPrompter(params: {
|
||||
@@ -24,24 +32,42 @@ export function createDoctorPrompter(params: {
|
||||
}): DoctorPrompter {
|
||||
const yes = params.options.yes === true;
|
||||
const requestedNonInteractive = params.options.nonInteractive === true;
|
||||
const shouldRepair = params.options.repair === true || yes;
|
||||
const shouldForce = params.options.force === true;
|
||||
const isTty = Boolean(process.stdin.isTTY);
|
||||
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
|
||||
|
||||
const canPrompt = isTty && !yes && !nonInteractive;
|
||||
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
|
||||
if (nonInteractive) return false;
|
||||
if (shouldRepair) return true;
|
||||
if (!canPrompt) return Boolean(p.initialValue ?? false);
|
||||
return guardCancel(await confirm(p), params.runtime) === true;
|
||||
};
|
||||
|
||||
return {
|
||||
confirm: confirmDefault,
|
||||
confirmSkipInNonInteractive: async (p) => {
|
||||
confirmRepair: async (p) => {
|
||||
if (nonInteractive) return false;
|
||||
return confirmDefault(p);
|
||||
},
|
||||
confirmAggressive: async (p) => {
|
||||
if (nonInteractive) return false;
|
||||
if (shouldRepair && shouldForce) return true;
|
||||
if (shouldRepair && !shouldForce) return false;
|
||||
if (!canPrompt) return Boolean(p.initialValue ?? false);
|
||||
return guardCancel(await confirm(p), params.runtime) === true;
|
||||
},
|
||||
confirmSkipInNonInteractive: async (p) => {
|
||||
if (nonInteractive) return false;
|
||||
if (shouldRepair) return true;
|
||||
return confirmDefault(p);
|
||||
},
|
||||
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
|
||||
if (!canPrompt) return fallback;
|
||||
if (!canPrompt || shouldRepair) return fallback;
|
||||
return guardCancel(await select(p), params.runtime) as T;
|
||||
},
|
||||
shouldRepair,
|
||||
shouldForce,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ function findOtherStateDirs(stateDir: string): string[] {
|
||||
export async function noteStateIntegrity(
|
||||
cfg: ClawdbotConfig,
|
||||
prompter: DoctorPrompterLike,
|
||||
configPath?: string,
|
||||
) {
|
||||
const warnings: string[] = [];
|
||||
const changes: string[] = [];
|
||||
@@ -186,6 +187,49 @@ export async function noteStateIntegrity(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stateDirExists && process.platform !== "win32") {
|
||||
try {
|
||||
const stat = fs.statSync(stateDir);
|
||||
if ((stat.mode & 0o077) !== 0) {
|
||||
warnings.push(
|
||||
`- State directory permissions are too open (${stateDir}). Recommend chmod 700.`,
|
||||
);
|
||||
const tighten = await prompter.confirmSkipInNonInteractive({
|
||||
message: `Tighten permissions on ${stateDir} to 700?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (tighten) {
|
||||
fs.chmodSync(stateDir, 0o700);
|
||||
changes.push(`- Tightened permissions on ${stateDir} to 700`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`- Failed to read ${stateDir} permissions: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (configPath && existsFile(configPath) && process.platform !== "win32") {
|
||||
try {
|
||||
const stat = fs.statSync(configPath);
|
||||
if ((stat.mode & 0o077) !== 0) {
|
||||
warnings.push(
|
||||
`- Config file is group/world readable (${configPath}). Recommend chmod 600.`,
|
||||
);
|
||||
const tighten = await prompter.confirmSkipInNonInteractive({
|
||||
message: `Tighten permissions on ${configPath} to 600?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (tighten) {
|
||||
fs.chmodSync(configPath, 0o600);
|
||||
changes.push(`- Tightened permissions on ${configPath} to 600`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`- Failed to read config permissions (${configPath}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (stateDirExists) {
|
||||
const dirCandidates = new Map<string, string>();
|
||||
|
||||
@@ -129,10 +129,13 @@ export async function doctorCommand(
|
||||
const legacyState = await detectLegacyStateMigrations({ cfg });
|
||||
if (legacyState.preview.length > 0) {
|
||||
note(legacyState.preview.join("\n"), "Legacy state detected");
|
||||
const migrate = await prompter.confirm({
|
||||
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
|
||||
initialValue: true,
|
||||
});
|
||||
const migrate =
|
||||
options.nonInteractive === true
|
||||
? true
|
||||
: await prompter.confirm({
|
||||
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (migrate) {
|
||||
const migrated = await runLegacyStateMigrations({
|
||||
detected: legacyState,
|
||||
@@ -146,7 +149,11 @@ export async function doctorCommand(
|
||||
}
|
||||
}
|
||||
|
||||
await noteStateIntegrity(cfg, prompter);
|
||||
await noteStateIntegrity(
|
||||
cfg,
|
||||
prompter,
|
||||
snapshot.path ?? CONFIG_PATH_CLAWDBOT,
|
||||
);
|
||||
|
||||
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
|
||||
noteSandboxScopeWarnings(cfg);
|
||||
|
||||
@@ -20,10 +20,7 @@ vi.mock("../agents/model-catalog.js", () => ({
|
||||
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import {
|
||||
parseTelegramTarget,
|
||||
runCronIsolatedAgentTurn,
|
||||
} from "./isolated-agent.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-"));
|
||||
@@ -408,6 +405,51 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram topic targets 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: "telegram:group:-1001234567890:topic: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);
|
||||
@@ -673,63 +715,3 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTelegramTarget", () => {
|
||||
it("parses plain chatId", () => {
|
||||
expect(parseTelegramTarget("-1001234567890")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses @username", () => {
|
||||
expect(parseTelegramTarget("@mychannel")).toEqual({
|
||||
chatId: "@mychannel",
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses chatId:topicId format", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses chatId:topic:topicId format", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat non-numeric suffix as topicId", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
|
||||
chatId: "-1001234567890:abc",
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips internal telegram prefix", () => {
|
||||
expect(parseTelegramTarget("telegram:123")).toEqual({
|
||||
chatId: "123",
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips internal telegram + group prefixes before parsing topic", () => {
|
||||
expect(
|
||||
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
|
||||
).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
topicId: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,49 +46,11 @@ import {
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { parseTelegramTarget } from "../telegram/targets.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse a Telegram delivery target into chatId and optional topicId.
|
||||
* Supports formats:
|
||||
* - `chatId` (plain chat ID or @username)
|
||||
* - `chatId:topicId` (chat ID with topic/thread ID)
|
||||
* - `chatId:topic:topicId` (alternative format with explicit "topic" marker)
|
||||
*/
|
||||
export function parseTelegramTarget(to: string): {
|
||||
chatId: string;
|
||||
topicId: number | undefined;
|
||||
} {
|
||||
let trimmed = to.trim();
|
||||
|
||||
// Cron "lastTo" values can include internal prefixes like `telegram:...` or
|
||||
// `telegram:group:...` (see normalizeChatId in telegram/send.ts).
|
||||
// Strip these before parsing `:topic:` / `:<topicId>` suffixes.
|
||||
while (true) {
|
||||
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||
if (next === trimmed) break;
|
||||
trimmed = next;
|
||||
}
|
||||
|
||||
// Try format: chatId:topic:topicId
|
||||
const topicMatch = /^(.+?):topic:(\d+)$/.exec(trimmed);
|
||||
if (topicMatch) {
|
||||
return { chatId: topicMatch[1], topicId: parseInt(topicMatch[2], 10) };
|
||||
}
|
||||
|
||||
// Try format: chatId:topicId (where topicId is numeric)
|
||||
// Be careful not to match @username or other non-numeric suffixes
|
||||
const colonMatch = /^(.+):(\d+)$/.exec(trimmed);
|
||||
if (colonMatch) {
|
||||
return { chatId: colonMatch[1], topicId: parseInt(colonMatch[2], 10) };
|
||||
}
|
||||
|
||||
// Plain chatId, no topic
|
||||
return { chatId: trimmed, topicId: undefined };
|
||||
}
|
||||
|
||||
export type RunCronAgentTurnResult = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
@@ -526,7 +488,9 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
summary: "Delivery skipped (no Telegram chatId).",
|
||||
};
|
||||
}
|
||||
const { chatId, topicId } = parseTelegramTarget(resolvedDelivery.to);
|
||||
const telegramTarget = parseTelegramTarget(resolvedDelivery.to);
|
||||
const chatId = telegramTarget.chatId;
|
||||
const messageThreadId = telegramTarget.messageThreadId;
|
||||
const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
@@ -540,7 +504,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
await params.deps.sendMessageTelegram(chatId, chunk, {
|
||||
verbose: false,
|
||||
token: telegramToken || undefined,
|
||||
messageThreadId: topicId,
|
||||
messageThreadId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -552,7 +516,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
token: telegramToken || undefined,
|
||||
messageThreadId: topicId,
|
||||
messageThreadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type ServiceConfigIssue = {
|
||||
code: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
level?: "recommended" | "aggressive";
|
||||
};
|
||||
|
||||
export type ServiceConfigAudit = {
|
||||
@@ -84,6 +85,7 @@ async function auditSystemdUnit(
|
||||
code: "systemd-after-network-online",
|
||||
message: "Missing systemd After=network-online.target",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
if (!parsed.wants.has("network-online.target")) {
|
||||
@@ -91,6 +93,7 @@ async function auditSystemdUnit(
|
||||
code: "systemd-wants-network-online",
|
||||
message: "Missing systemd Wants=network-online.target",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
if (!isRestartSecPreferred(parsed.restartSec)) {
|
||||
@@ -98,6 +101,7 @@ async function auditSystemdUnit(
|
||||
code: "systemd-restart-sec",
|
||||
message: "RestartSec does not match the recommended 5s",
|
||||
detail: unitPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -121,6 +125,7 @@ async function auditLaunchdPlist(
|
||||
code: "launchd-run-at-load",
|
||||
message: "LaunchAgent is missing RunAtLoad=true",
|
||||
detail: plistPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
if (!hasKeepAlive) {
|
||||
@@ -128,6 +133,7 @@ async function auditLaunchdPlist(
|
||||
code: "launchd-keep-alive",
|
||||
message: "LaunchAgent is missing KeepAlive=true",
|
||||
detail: plistPath,
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,6 +147,7 @@ function auditGatewayCommand(
|
||||
issues.push({
|
||||
code: "gateway-command-missing",
|
||||
message: "Service command does not include the gateway subcommand",
|
||||
level: "aggressive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,31 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 55,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram(
|
||||
`telegram:group:${chatId}:topic:271`,
|
||||
"hello forum",
|
||||
{
|
||||
token: "tok",
|
||||
api,
|
||||
},
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reply_to_message_id for threaded replies", async () => {
|
||||
const chatId = "123";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -11,6 +11,10 @@ import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
import { markdownToTelegramHtml } from "./format.js";
|
||||
import {
|
||||
parseTelegramTarget,
|
||||
stripTelegramInternalPrefixes,
|
||||
} from "./targets.js";
|
||||
|
||||
type TelegramSendOpts = {
|
||||
token?: string;
|
||||
@@ -65,7 +69,7 @@ function normalizeChatId(to: string): string {
|
||||
// Common internal prefixes that sometimes leak into outbound sends.
|
||||
// - ctx.To uses `telegram:<id>`
|
||||
// - group sessions often use `telegram:group:<id>`
|
||||
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||
let normalized = stripTelegramInternalPrefixes(trimmed);
|
||||
|
||||
// Accept t.me links for public chats/channels.
|
||||
// (Invite links like `t.me/+...` are not resolvable via Bot API.)
|
||||
@@ -110,7 +114,8 @@ export async function sendMessageTelegram(
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const chatId = normalizeChatId(to);
|
||||
const target = parseTelegramTarget(to);
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
// Use provided api or create a new Bot instance. The nullish coalescing
|
||||
// operator ensures api is always defined (Bot.api is always non-null).
|
||||
const fetchImpl = resolveTelegramFetch();
|
||||
@@ -123,8 +128,12 @@ export async function sendMessageTelegram(
|
||||
// Build optional params for forum topics and reply threading.
|
||||
// Only include these if actually provided to keep API calls clean.
|
||||
const threadParams: Record<string, number> = {};
|
||||
if (opts.messageThreadId != null) {
|
||||
threadParams.message_thread_id = Math.trunc(opts.messageThreadId);
|
||||
const messageThreadId =
|
||||
opts.messageThreadId != null
|
||||
? opts.messageThreadId
|
||||
: target.messageThreadId;
|
||||
if (messageThreadId != null) {
|
||||
threadParams.message_thread_id = Math.trunc(messageThreadId);
|
||||
}
|
||||
if (opts.replyToMessageId != null) {
|
||||
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
|
||||
|
||||
72
src/telegram/targets.test.ts
Normal file
72
src/telegram/targets.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseTelegramTarget,
|
||||
stripTelegramInternalPrefixes,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("stripTelegramInternalPrefixes", () => {
|
||||
it("strips telegram prefix", () => {
|
||||
expect(stripTelegramInternalPrefixes("telegram:123")).toBe("123");
|
||||
});
|
||||
|
||||
it("strips telegram+group prefixes", () => {
|
||||
expect(stripTelegramInternalPrefixes("telegram:group:-100123")).toBe(
|
||||
"-100123",
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
expect(stripTelegramInternalPrefixes("@mychannel")).toBe("@mychannel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTelegramTarget", () => {
|
||||
it("parses plain chatId", () => {
|
||||
expect(parseTelegramTarget("-1001234567890")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses @username", () => {
|
||||
expect(parseTelegramTarget("@mychannel")).toEqual({
|
||||
chatId: "@mychannel",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses chatId:topicId format", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses chatId:topic:topicId format", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat non-numeric suffix as topicId", () => {
|
||||
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
|
||||
chatId: "-1001234567890:abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips internal prefixes before parsing", () => {
|
||||
expect(
|
||||
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
|
||||
).toEqual({
|
||||
chatId: "-1001234567890",
|
||||
messageThreadId: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/telegram/targets.ts
Normal file
43
src/telegram/targets.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type TelegramTarget = {
|
||||
chatId: string;
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
export function stripTelegramInternalPrefixes(to: string): string {
|
||||
let trimmed = to.trim();
|
||||
while (true) {
|
||||
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||
if (next === trimmed) return trimmed;
|
||||
trimmed = next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Telegram delivery target into chatId and optional topic/thread ID.
|
||||
*
|
||||
* Supported formats:
|
||||
* - `chatId` (plain chat ID, t.me link, @username, or internal prefixes like `telegram:...`)
|
||||
* - `chatId:topicId` (numeric topic/thread ID)
|
||||
* - `chatId:topic:topicId` (explicit topic marker; preferred)
|
||||
*/
|
||||
export function parseTelegramTarget(to: string): TelegramTarget {
|
||||
const normalized = stripTelegramInternalPrefixes(to);
|
||||
|
||||
const topicMatch = /^(.+?):topic:(\d+)$/.exec(normalized);
|
||||
if (topicMatch) {
|
||||
return {
|
||||
chatId: topicMatch[1],
|
||||
messageThreadId: Number.parseInt(topicMatch[2], 10),
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = /^(.+):(\d+)$/.exec(normalized);
|
||||
if (colonMatch) {
|
||||
return {
|
||||
chatId: colonMatch[1],
|
||||
messageThreadId: Number.parseInt(colonMatch[2], 10),
|
||||
};
|
||||
}
|
||||
|
||||
return { chatId: normalized };
|
||||
}
|
||||
@@ -118,6 +118,25 @@ export async function monitorWebInbox(options: {
|
||||
{ subject?: string; participants?: string[]; expires: number }
|
||||
>();
|
||||
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const lidLookup = sock.signalRepository?.lidMapping;
|
||||
|
||||
const resolveJidToE164 = async (
|
||||
jid: string | null | undefined,
|
||||
): Promise<string | null> => {
|
||||
if (!jid) return null;
|
||||
const direct = jidToE164(jid);
|
||||
if (direct) return direct;
|
||||
if (!/(@lid|@hosted\.lid)$/.test(jid)) return null;
|
||||
if (!lidLookup?.getPNForLID) return null;
|
||||
try {
|
||||
const pnJid = await lidLookup.getPNForLID(jid);
|
||||
if (!pnJid) return null;
|
||||
return jidToE164(pnJid);
|
||||
} catch (err) {
|
||||
logVerbose(`LID mapping lookup failed for ${jid}: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getGroupMeta = async (jid: string) => {
|
||||
const cached = groupMetaCache.get(jid);
|
||||
@@ -125,9 +144,14 @@ export async function monitorWebInbox(options: {
|
||||
try {
|
||||
const meta = await sock.groupMetadata(jid);
|
||||
const participants =
|
||||
meta.participants
|
||||
?.map((p) => jidToE164(p.id) ?? p.id)
|
||||
.filter(Boolean) ?? [];
|
||||
(
|
||||
await Promise.all(
|
||||
meta.participants?.map(async (p) => {
|
||||
const mapped = await resolveJidToE164(p.id);
|
||||
return mapped ?? p.id;
|
||||
}) ?? [],
|
||||
)
|
||||
).filter(Boolean) ?? [];
|
||||
const entry = {
|
||||
subject: meta.subject,
|
||||
participants,
|
||||
@@ -159,12 +183,12 @@ export async function monitorWebInbox(options: {
|
||||
continue;
|
||||
const group = isJidGroup(remoteJid);
|
||||
const participantJid = msg.key?.participant ?? undefined;
|
||||
const from = group ? remoteJid : jidToE164(remoteJid);
|
||||
const from = group ? remoteJid : await resolveJidToE164(remoteJid);
|
||||
// Skip if we still can't resolve an id to key conversation
|
||||
if (!from) continue;
|
||||
const senderE164 = group
|
||||
? participantJid
|
||||
? jidToE164(participantJid)
|
||||
? await resolveJidToE164(participantJid)
|
||||
: null
|
||||
: from;
|
||||
let groupSubject: string | undefined;
|
||||
|
||||
@@ -50,6 +50,11 @@ vi.mock("./session.js", () => {
|
||||
readMessages: vi.fn().mockResolvedValue(undefined),
|
||||
updateMediaMessage: vi.fn(),
|
||||
logger: {},
|
||||
signalRepository: {
|
||||
lidMapping: {
|
||||
getPNForLID: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
return {
|
||||
@@ -136,6 +141,89 @@ describe("web monitor inbox", () => {
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("resolves LID JIDs using Baileys LID mapping store", async () => {
|
||||
const onMessage = vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const getPNForLID = vi.spyOn(
|
||||
sock.signalRepository.lidMapping,
|
||||
"getPNForLID",
|
||||
);
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce(
|
||||
"999:0@s.whatsapp.net",
|
||||
);
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@lid" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(getPNForLID).toHaveBeenCalledWith("999@lid");
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "ping", from: "+999", to: "+123" }),
|
||||
);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("resolves group participant LID JIDs via Baileys mapping", async () => {
|
||||
const onMessage = vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const getPNForLID = vi.spyOn(
|
||||
sock.signalRepository.lidMapping,
|
||||
"getPNForLID",
|
||||
);
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce(
|
||||
"444:0@s.whatsapp.net",
|
||||
);
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "abc",
|
||||
fromMe: false,
|
||||
remoteJid: "123@g.us",
|
||||
participant: "444@lid",
|
||||
},
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(getPNForLID).toHaveBeenCalledWith("444@lid");
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "ping",
|
||||
from: "123@g.us",
|
||||
senderE164: "+444",
|
||||
chatType: "group",
|
||||
}),
|
||||
);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("does not block follow-up messages when handler is pending", async () => {
|
||||
let resolveFirst: (() => void) | null = null;
|
||||
const onMessage = vi.fn(async () => {
|
||||
|
||||
Reference in New Issue
Block a user