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

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