fix(heartbeat): block dm targets and internalize blocked prompts

This commit is contained in:
Peter Steinberger
2026-02-25 02:02:26 +00:00
parent e0201c2774
commit a805d6b439
9 changed files with 161 additions and 6 deletions

View File

@@ -37,6 +37,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
const createConfig = async (params: {
tmpDir: string;
storePath: string;
target?: "telegram" | "none";
}): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => {
const cfg: OpenClawConfig = {
agents: {
@@ -44,7 +45,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
workspace: params.tmpDir,
heartbeat: {
every: "5m",
target: "telegram",
target: params.target ?? "telegram",
},
},
},
@@ -96,6 +97,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
replyText: string;
reason: string;
enqueue: (sessionKey: string) => void;
target?: "telegram" | "none";
}): Promise<{
result: Awaited<ReturnType<typeof runHeartbeatOnce>>;
sendTelegram: ReturnType<typeof vi.fn>;
@@ -105,7 +107,11 @@ describe("Ghost reminder bug (issue #13317)", () => {
return withTempHeartbeatSandbox(
async ({ tmpDir, storePath }) => {
const { sendTelegram, getReplySpy } = createHeartbeatDeps(params.replyText);
const { cfg, sessionKey } = await createConfig({ tmpDir, storePath });
const { cfg, sessionKey } = await createConfig({
tmpDir,
storePath,
target: params.target,
});
params.enqueue(sessionKey);
const result = await runHeartbeatOnce({
cfg,
@@ -192,4 +198,38 @@ describe("Ghost reminder bug (issue #13317)", () => {
expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md");
expect(sendTelegram).toHaveBeenCalled();
});
it("uses an internal-only cron prompt when delivery target is none", async () => {
const { result, sendTelegram, calledCtx } = await runHeartbeatCase({
tmpPrefix: "openclaw-cron-internal-",
replyText: "Handled internally",
reason: "cron:reminder-job",
target: "none",
enqueue: (sessionKey) => {
enqueueSystemEvent("Reminder: Rotate API keys", { sessionKey });
},
});
expect(result.status).toBe("ran");
expect(calledCtx?.Provider).toBe("cron-event");
expect(calledCtx?.Body).toContain("Handle this reminder internally");
expect(sendTelegram).not.toHaveBeenCalled();
});
it("uses an internal-only exec prompt when delivery target is none", async () => {
const { result, sendTelegram, calledCtx } = await runHeartbeatCase({
tmpPrefix: "openclaw-exec-internal-",
replyText: "Handled internally",
reason: "exec-event",
target: "none",
enqueue: (sessionKey) => {
enqueueSystemEvent("exec finished: deploy succeeded", { sessionKey });
},
});
expect(result.status).toBe("ran");
expect(calledCtx?.Provider).toBe("exec-event");
expect(calledCtx?.Body).toContain("Handle the result internally");
expect(sendTelegram).not.toHaveBeenCalled();
});
});

View File

@@ -301,7 +301,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("63448508");
});
it("does not return inherited threadId from resolveHeartbeatDeliveryTarget", () => {
it("blocks heartbeat delivery to Slack DMs and avoids inherited threadId", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -317,11 +317,49 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
expect(resolved.channel).toBe("slack");
expect(resolved.to).toBe("user:U123");
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
expect(resolved.threadId).toBeUndefined();
});
it("blocks heartbeat delivery to Discord DMs", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
sessionId: "sess-heartbeat-discord-dm",
updatedAt: 1,
lastChannel: "discord",
lastTo: "user:12345",
},
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({
cfg,
entry: {
sessionId: "sess-heartbeat-discord-channel",
updatedAt: 1,
lastChannel: "discord",
lastTo: "channel:999",
},
heartbeat: {
target: "last",
},
});
expect(resolved.channel).toBe("discord");
expect(resolved.to).toBe("channel:999");
});
it("keeps explicit threadId in heartbeat mode", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {

View File

@@ -1,10 +1,13 @@
import 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";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
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 { deliveryContextFromSession } from "../../utils/delivery-context.js";
import type {
@@ -319,6 +322,20 @@ export function resolveHeartbeatDeliveryTarget(params: {
};
}
const deliveryChatType = resolveHeartbeatDeliveryChatType({
channel: resolvedTarget.channel,
to: resolved.to,
});
if (deliveryChatType === "direct") {
return {
channel: "none",
reason: "dm-blocked",
accountId: effectiveAccountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
};
}
let reason: string | undefined;
const plugin = getChannelPlugin(resolvedTarget.channel);
if (plugin?.config.resolveAllowFrom) {
@@ -345,6 +362,59 @@ export function resolveHeartbeatDeliveryTarget(params: {
};
}
function inferChatTypeFromTarget(params: {
channel: DeliverableMessageChannel;
to: string;
}): ChatType | undefined {
const to = params.to.trim();
if (!to) {
return undefined;
}
if (/^user:/i.test(to)) {
return "direct";
}
if (/^(channel:|thread:)/i.test(to)) {
return "channel";
}
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;
}
}
function resolveHeartbeatDeliveryChatType(params: {
channel: DeliverableMessageChannel;
to: string;
}): ChatType | undefined {
return inferChatTypeFromTarget({
channel: params.channel,
to: params.to,
});
}
function resolveHeartbeatSenderId(params: {
allowFrom: Array<string | number>;
deliveryTo?: string;