refactor(heartbeat): harden dm delivery classification
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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