Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-08 16:22:25 -05:00
committed by GitHub
35 changed files with 847 additions and 201 deletions

View File

@@ -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");

View File

@@ -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;

View File

@@ -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)"}`;

View File

@@ -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:

View File

@@ -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),
});

View File

@@ -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 =

View File

@@ -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,
};
}

View File

@@ -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>();

View File

@@ -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);

View File

@@ -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,
});
});
});

View File

@@ -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,
});
}
}

View File

@@ -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",
});
}
}

View File

@@ -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({

View File

@@ -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);

View 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
View 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 };
}

View File

@@ -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;

View File

@@ -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 () => {