refactor(heartbeat): harden dm delivery classification

This commit is contained in:
Peter Steinberger
2026-02-25 02:12:59 +00:00
parent 91ae82ae19
commit 24d7612ddf
8 changed files with 292 additions and 105 deletions

View File

@@ -13,11 +13,11 @@ Docs: https://docs.openclaw.ai
### Breaking
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` 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:<id>` 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:<id>`, 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.

View File

@@ -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:<id>` 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:<id>`, 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.

View File

@@ -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:<id>` targets resolve to no-delivery).
- Direct/DM heartbeat destinations are blocked when target parsing identifies a direct chat (for example `user:<id>`, 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 `<chatId>:topic:<messageThreadId>`.
- `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:<id>` 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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DeliverableMessageChannel, (to: string) => 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,