diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3b3360991..f05b11ae77d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,11 @@ Docs: https://docs.openclaw.ai ### Breaking - **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. -- **BREAKING:** Heartbeat delivery now blocks DM-style `user:` targets. Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. +- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. ### Fixes -- Heartbeat routing: prevent heartbeat leakage/spam into Discord DMs by blocking DM-style heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) +- Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) - iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. - Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. - Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 050106968f4..01ad82b6098 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -812,7 +812,7 @@ Periodic heartbeat runs. - `every`: duration string (ms/s/m/h). Default: `30m`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. -- Heartbeats never deliver to DM-style `user:` targets; those runs still execute, but outbound delivery is skipped. +- Heartbeats never deliver to direct/DM chat targets when the destination can be classified as direct (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs); those runs still execute, but outbound delivery is skipped. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index c2a762bb6a0..cf7ea489c40 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -215,7 +215,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - `none` (default): run the heartbeat but **do not deliver** externally. -- DM-style heartbeat destinations are blocked (`user:` targets resolve to no-delivery). +- Direct/DM heartbeat destinations are blocked when target parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). @@ -236,7 +236,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `session` only affects the run context; delivery is controlled by `target` and `to`. - To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session. -- Heartbeat deliveries never send to DM-style `user:` targets; those runs still execute, but outbound delivery is skipped. +- Heartbeat deliveries never send to direct/DM targets when the destination is identified as direct; those runs still execute, but outbound delivery is skipped. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 2696d4bdb03..648acf1813c 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -55,7 +55,7 @@ describe("Ghost reminder bug (issue #13317)", () => { const sessionKey = await seedMainSessionStore(params.storePath, cfg, { lastChannel: "telegram", lastProvider: "telegram", - lastTo: "155462274", + lastTo: "-100155462274", }); return { cfg, sessionKey }; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 2f1748bae1b..0ec2afcafdd 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -241,7 +241,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "target defaults to none when unset", cfg: {}, - entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1555" }, + entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "120363401234567890@g.us" }, expected: { channel: "none", reason: "target-none", @@ -253,13 +253,15 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "normalize explicit whatsapp target when allowFrom wildcard", cfg: { - agents: { defaults: { heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" } } }, + agents: { + defaults: { heartbeat: { target: "whatsapp", to: "whatsapp:120363401234567890@G.US" } }, + }, channels: { whatsapp: { allowFrom: ["*"] } }, }, entry: baseEntry, expected: { channel: "whatsapp", - to: "+555123", + to: "120363401234567890@g.us", accountId: undefined, lastChannel: undefined, lastAccountId: undefined, @@ -281,7 +283,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { name: "reject explicit whatsapp target outside allowFrom", cfg: { agents: { defaults: { heartbeat: { target: "whatsapp", to: "+1999" } } }, - channels: { whatsapp: { allowFrom: ["+1555", "+1666"] } }, + channels: { whatsapp: { allowFrom: ["120363401234567890@g.us", "+1666"] } }, }, entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1222" }, expected: { @@ -296,7 +298,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { name: "normalize prefixed whatsapp group targets", cfg: { agents: { defaults: { heartbeat: { target: "last" } } }, - channels: { whatsapp: { allowFrom: ["+1555"] } }, + channels: { whatsapp: { allowFrom: ["120363401234567890@g.us"] } }, }, entry: { ...baseEntry, @@ -313,11 +315,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { }, { name: "keep explicit telegram target", - cfg: { agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } } }, + cfg: { agents: { defaults: { heartbeat: { target: "telegram", to: "-100123" } } } }, entry: baseEntry, expected: { channel: "telegram", - to: "123", + to: "-100123", accountId: undefined, lastChannel: undefined, lastAccountId: undefined, @@ -358,7 +360,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { accountId: "work", expected: { channel: "telegram", - to: "123", + to: "-100123", accountId: "work", lastChannel: undefined, lastAccountId: undefined, @@ -380,7 +382,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { const cfg: OpenClawConfig = { agents: { defaults: { - heartbeat: { target: "telegram", to: "123", accountId: testCase.accountId }, + heartbeat: { target: "telegram", to: "-100123", accountId: testCase.accountId }, }, }, channels: { telegram: { accounts: { work: { botToken: "token" } } } }, @@ -391,9 +393,9 @@ describe("resolveHeartbeatDeliveryTarget", () => { it("prefers per-agent heartbeat overrides when provided", () => { const cfg: OpenClawConfig = { - agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } }, + agents: { defaults: { heartbeat: { target: "telegram", to: "-100123" } } }, }; - const heartbeat = { target: "whatsapp", to: "+1555" } as const; + const heartbeat = { target: "whatsapp", to: "120363401234567890@g.us" } as const; expect( resolveHeartbeatDeliveryTarget({ cfg, @@ -402,7 +404,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { }), ).toEqual({ channel: "whatsapp", - to: "+1555", + to: "120363401234567890@g.us", accountId: undefined, lastChannel: "whatsapp", lastAccountId: undefined, @@ -518,7 +520,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -535,7 +537,11 @@ describe("runHeartbeatOnce", () => { }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); } finally { replySpy.mockRestore(); } @@ -572,7 +578,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -587,15 +593,19 @@ describe("runHeartbeatOnce", () => { deps: createHeartbeatDeps(sendWhatsApp), }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); expect(replySpy).toHaveBeenCalledWith( expect.objectContaining({ Body: expect.stringMatching(/Ops check[\s\S]*Current time: /), SessionKey: sessionKey, - From: "+1555", - To: "+1555", + From: "120363401234567890@g.us", + To: "120363401234567890@g.us", OriginatingChannel: "whatsapp", - OriginatingTo: "+1555", + OriginatingTo: "120363401234567890@g.us", Provider: "heartbeat", }), expect.objectContaining({ isHeartbeat: true, suppressToolErrorWarnings: false }), @@ -645,7 +655,7 @@ describe("runHeartbeatOnce", () => { sessionFile, updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -663,12 +673,16 @@ describe("runHeartbeatOnce", () => { expect(result.status).toBe("ran"); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); expect(replySpy).toHaveBeenCalledWith( expect.objectContaining({ SessionKey: sessionKey, - From: "+1555", - To: "+1555", + From: "120363401234567890@g.us", + To: "120363401234567890@g.us", Provider: "heartbeat", }), expect.objectContaining({ isHeartbeat: true, suppressToolErrorWarnings: false }), @@ -709,8 +723,8 @@ describe("runHeartbeatOnce", () => { { name: "runHeartbeatOnce sessionKey arg", caseDir: "hb-forced-session-override", - peerKind: "direct" as const, - peerId: "+15559990000", + peerKind: "group" as const, + peerId: "120363401234567891@g.us", message: "Forced alert", applyOverride: () => {}, runOptions: ({ sessionKey }: { sessionKey: string }) => ({ sessionKey }), @@ -750,7 +764,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid-main", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, [overrideSessionKey]: { sessionId: `sid-${testCase.peerKind}`, @@ -819,7 +833,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", lastHeartbeatText: "Final alert", lastHeartbeatSentAt: 0, }, @@ -892,7 +906,7 @@ describe("runHeartbeatOnce", () => { updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -912,7 +926,7 @@ describe("runHeartbeatOnce", () => { for (const [index, text] of testCase.expectedTexts.entries()) { expect(sendWhatsApp, testCase.name).toHaveBeenNthCalledWith( index + 1, - "+1555", + "120363401234567890@g.us", text, expect.any(Object), ); @@ -949,7 +963,7 @@ describe("runHeartbeatOnce", () => { updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -967,7 +981,7 @@ describe("runHeartbeatOnce", () => { expect(sendWhatsApp).toHaveBeenCalledTimes(1); expect(sendWhatsApp).toHaveBeenCalledWith( - "+1555", + "120363401234567890@g.us", "Hello from heartbeat", expect.any(Object), ); @@ -1024,7 +1038,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -1173,7 +1187,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -1226,7 +1240,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b7ae733e633..73c2fafb1ae 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -553,6 +553,40 @@ async function resolveHeartbeatPreflight(params: { return basePreflight; } +type HeartbeatPromptResolution = { + prompt: string; + hasExecCompletion: boolean; + hasCronEvents: boolean; +}; + +function resolveHeartbeatRunPrompt(params: { + cfg: OpenClawConfig; + heartbeat?: HeartbeatConfig; + preflight: HeartbeatPreflight; + canRelayToUser: boolean; +}): HeartbeatPromptResolution { + const pendingEventEntries = params.preflight.pendingEventEntries; + const pendingEvents = params.preflight.shouldInspectPendingEvents + ? pendingEventEntries.map((event) => event.text) + : []; + const cronEvents = pendingEventEntries + .filter( + (event) => + (params.preflight.isCronEventReason || event.contextKey?.startsWith("cron:")) && + isCronSystemEvent(event.text), + ) + .map((event) => event.text); + const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); + const hasCronEvents = cronEvents.length > 0; + const prompt = hasExecCompletion + ? buildExecEventPrompt({ deliverToUser: params.canRelayToUser }) + : hasCronEvents + ? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser }) + : resolveHeartbeatPrompt(params.cfg, params.heartbeat); + + return { prompt, hasExecCompletion, hasCronEvents }; +} + export async function runHeartbeatOnce(opts: { cfg?: OpenClawConfig; agentId?: string; @@ -601,7 +635,6 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: preflight.skipReason }; } const { entry, sessionKey, storePath } = preflight.session; - const { isCronEventReason, pendingEventEntries } = preflight; const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const heartbeatAccountId = heartbeat?.accountId?.trim(); @@ -631,30 +664,15 @@ export async function runHeartbeatOnce(opts: { accountId: delivery.accountId, }).responsePrefix; - // Check if this is an exec event or cron event with pending system events. - // If so, use a specialized prompt that instructs the model to relay the result - // instead of the standard heartbeat prompt with "reply HEARTBEAT_OK". - const shouldInspectPendingEvents = preflight.shouldInspectPendingEvents; - const pendingEvents = shouldInspectPendingEvents - ? pendingEventEntries.map((event) => event.text) - : []; - const cronEvents = pendingEventEntries - .filter( - (event) => - (isCronEventReason || event.contextKey?.startsWith("cron:")) && - isCronSystemEvent(event.text), - ) - .map((event) => event.text); - const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); - const hasCronEvents = cronEvents.length > 0; const canRelayToUser = Boolean( delivery.channel !== "none" && delivery.to && visibility.showAlerts, ); - const prompt = hasExecCompletion - ? buildExecEventPrompt({ deliverToUser: canRelayToUser }) - : hasCronEvents - ? buildCronEventPrompt(cronEvents, { deliverToUser: canRelayToUser }) - : resolveHeartbeatPrompt(cfg, heartbeat); + const { prompt, hasExecCompletion, hasCronEvents } = resolveHeartbeatRunPrompt({ + cfg, + heartbeat, + preflight, + canRelayToUser, + }); const ctx = { Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), From: sender, diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 24b7343e9bf..8f120702de0 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -341,6 +341,102 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.reason).toBe("dm-blocked"); }); + it("blocks heartbeat delivery to Telegram direct chats", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-direct", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to Telegram groups", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-group", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "-1001234567890", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-1001234567890"); + }); + + it("blocks heartbeat delivery to WhatsApp direct chats", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-whatsapp-direct", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "+15551234567", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to WhatsApp groups", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-whatsapp-group", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "120363140186826074@g.us", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("120363140186826074@g.us"); + }); + + it("uses session chatType hint when target parser cannot classify", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-imessage-direct", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + it("keeps heartbeat delivery to Discord channels", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ @@ -386,12 +482,12 @@ describe("resolveSessionDeliveryTarget", () => { cfg, heartbeat: { target: "telegram", - to: "63448508:topic:1008013", + to: "-10063448508:topic:1008013", }, }); expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("63448508"); + expect(resolved.to).toBe("-10063448508"); expect(resolved.threadId).toBe(1008013); }); }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index cf08ac74db8..41baa558653 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,4 +1,4 @@ -import type { ChatType } from "../../channels/chat-type.js"; +import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; @@ -8,7 +8,7 @@ import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; import { parseDiscordTarget } from "../../discord/targets.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { parseSlackTarget } from "../../slack/targets.js"; -import { parseTelegramTarget } from "../../telegram/targets.js"; +import { parseTelegramTarget, resolveTelegramTargetChatType } from "../../telegram/targets.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import type { DeliverableMessageChannel, @@ -19,6 +19,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { missingTargetError } from "./target-errors.js"; export type OutboundChannel = DeliverableMessageChannel | "none"; @@ -249,13 +250,11 @@ export function resolveHeartbeatDeliveryTarget(params: { if (target === "none") { const base = resolveSessionDeliveryTarget({ entry }); - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "target-none", - accountId: undefined, lastChannel: base.lastChannel, lastAccountId: base.lastAccountId, - }; + }); } const resolvedTarget = resolveSessionDeliveryTarget({ @@ -279,26 +278,24 @@ export function resolveHeartbeatDeliveryTarget(params: { accountIds.map((accountId) => normalizeAccountId(accountId)), ); if (!normalizedAccountIds.has(normalizedAccountId)) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "unknown-account", accountId: normalizedAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } effectiveAccountId = normalizedAccountId; } } if (!resolvedTarget.channel || !resolvedTarget.to) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "no-target", accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } const resolved = resolveOutboundTarget({ @@ -309,27 +306,28 @@ export function resolveHeartbeatDeliveryTarget(params: { mode: "heartbeat", }); if (!resolved.ok) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "no-target", accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } + const sessionChatTypeHint = + target === "last" && !heartbeat?.to ? normalizeChatType(entry?.chatType) : undefined; const deliveryChatType = resolveHeartbeatDeliveryChatType({ channel: resolvedTarget.channel, to: resolved.to, + sessionChatType: sessionChatTypeHint, }); if (deliveryChatType === "direct") { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "dm-blocked", accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } let reason: string | undefined; @@ -358,6 +356,85 @@ export function resolveHeartbeatDeliveryTarget(params: { }; } +function buildNoHeartbeatDeliveryTarget(params: { + reason: string; + accountId?: string; + lastChannel?: DeliverableMessageChannel; + lastAccountId?: string; +}): OutboundTarget { + return { + channel: "none", + reason: params.reason, + accountId: params.accountId, + lastChannel: params.lastChannel, + lastAccountId: params.lastAccountId, + }; +} + +function inferDiscordTargetChatType(to: string): ChatType | undefined { + try { + const target = parseDiscordTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; + } catch { + return undefined; + } +} + +function inferSlackTargetChatType(to: string): ChatType | undefined { + const target = parseSlackTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; +} + +function inferTelegramTargetChatType(to: string): ChatType | undefined { + const chatType = resolveTelegramTargetChatType(to); + return chatType === "unknown" ? undefined : chatType; +} + +function inferWhatsAppTargetChatType(to: string): ChatType | undefined { + const normalized = normalizeWhatsAppTarget(to); + if (!normalized) { + return undefined; + } + return isWhatsAppGroupJid(normalized) ? "group" : "direct"; +} + +function inferSignalTargetChatType(rawTo: string): ChatType | undefined { + let to = rawTo.trim(); + if (!to) { + return undefined; + } + if (/^signal:/i.test(to)) { + to = to.replace(/^signal:/i, "").trim(); + } + if (!to) { + return undefined; + } + const lower = to.toLowerCase(); + if (lower.startsWith("group:")) { + return "group"; + } + if (lower.startsWith("username:") || lower.startsWith("u:")) { + return "direct"; + } + return "direct"; +} + +const HEARTBEAT_TARGET_CHAT_TYPE_INFERERS: Partial< + Record ChatType | undefined> +> = { + discord: inferDiscordTargetChatType, + slack: inferSlackTargetChatType, + telegram: inferTelegramTargetChatType, + whatsapp: inferWhatsAppTargetChatType, + signal: inferSignalTargetChatType, +}; + function inferChatTypeFromTarget(params: { channel: DeliverableMessageChannel; to: string; @@ -376,35 +453,17 @@ function inferChatTypeFromTarget(params: { if (/^group:/i.test(to)) { return "group"; } - - switch (params.channel) { - case "discord": { - try { - const target = parseDiscordTarget(to, { defaultKind: "channel" }); - if (!target) { - return undefined; - } - return target.kind === "user" ? "direct" : "channel"; - } catch { - return undefined; - } - } - case "slack": { - const target = parseSlackTarget(to, { defaultKind: "channel" }); - if (!target) { - return undefined; - } - return target.kind === "user" ? "direct" : "channel"; - } - default: - return undefined; - } + return HEARTBEAT_TARGET_CHAT_TYPE_INFERERS[params.channel]?.(to); } function resolveHeartbeatDeliveryChatType(params: { channel: DeliverableMessageChannel; to: string; + sessionChatType?: ChatType; }): ChatType | undefined { + if (params.sessionChatType) { + return params.sessionChatType; + } return inferChatTypeFromTarget({ channel: params.channel, to: params.to,