mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 02:38:26 +00:00
refactor(heartbeat): harden dm delivery classification
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user