diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa8df3b654..a9f1c01508f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Docs: https://docs.openclaw.ai - Context engines: reject resolved plugin engines whose reported `info.id` does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev. - WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient `ENOENT` crashes on image sends. (#65896) Thanks @frankekn. - Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale `dist/entry.js` and current `dist/index.js` paths. (#65984) Thanks @mbelinky. +- Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when `target=last`, instead of dropping them into the group root chat. (#66035) Thanks @mbelinky. + ## 2026.4.12 ### Changes diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index c67472780a4..7a29bf3c6b9 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -408,7 +408,6 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(options?.messageThreadId).toBeUndefined(); }); }); - it("keeps exec-event delivery pinned to the original Telegram topic when session route drifts", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => { const cfg: OpenClawConfig = { @@ -475,4 +474,73 @@ describe("Ghost reminder bug (issue #13317)", () => { ); }); }); + + it("keeps Telegram topic routing for isolated scheduled heartbeats", async () => { + await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "last", + isolatedSession: true, + }, + }, + }, + channels: { telegram: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "telegram", + lastTo: "-100155462274", + deliveryContext: { + channel: "telegram", + to: "-100155462274", + threadId: 42, + }, + chatType: "group", + }, + }), + ); + + const sendTelegram = vi.fn().mockResolvedValue({ + messageId: "m1", + chatId: "-100155462274", + }); + replySpy.mockResolvedValue({ text: "Topic heartbeat" }); + + const result = await runHeartbeatOnce({ + cfg, + agentId: "main", + reason: "timer", + deps: { + getReplyFromConfig: replySpy, + telegram: sendTelegram, + }, + }); + + expect(result.status).toBe("ran"); + expect(replySpy).toHaveBeenCalledWith( + expect.objectContaining({ + SessionKey: `${sessionKey}:heartbeat`, + MessageThreadId: 42, + }), + expect.anything(), + expect.anything(), + ); + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "-100155462274", + "Topic heartbeat", + expect.objectContaining({ messageThreadId: 42 }), + ); + }); + }); }); diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index efed9353ff1..5a875c7c508 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -607,6 +607,74 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBe(1008013); }); + it("preserves Telegram topic threadId for heartbeat target=last on topic-bound group sessions", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-topic", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "-1001234567890", + lastThreadId: 1122, + chatType: "group", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-1001234567890"); + expect(resolved.threadId).toBe(1122); + }); + + it("reuses Telegram topic routing when only deliveryContext carries the topic threadId", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-topic-context-only", + updatedAt: 1, + deliveryContext: { + channel: "telegram", + to: "-1001234567890", + threadId: 1122, + }, + chatType: "group", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-1001234567890"); + expect(resolved.threadId).toBe(1122); + }); + + it("does not inherit stale Telegram threadId for direct-chat heartbeat routes", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-direct-stale-thread", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + lastThreadId: 1122, + chatType: "direct", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("5232990709"); + expect(resolved.threadId).toBeUndefined(); + }); + it("prefers turn-scoped routing over mutable session routing for target=last", () => { const resolved = resolveHeartbeatDeliveryTarget({ cfg: {}, diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index c65a39aa005..33006fd42aa 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -49,7 +49,7 @@ export type HeartbeatSenderContext = { export type { OutboundTargetResolution } from "./targets-resolve-shared.js"; export { resolveSessionDeliveryTarget, type SessionDeliveryTarget } from "./targets-session.js"; -import { resolveSessionDeliveryTarget } from "./targets-session.js"; +import { resolveSessionDeliveryTarget, type SessionDeliveryTarget } from "./targets-session.js"; // Channel docking: prefer plugin.outbound.resolveTarget + allowFrom to normalize destinations. export function resolveOutboundTarget(params: { @@ -220,12 +220,26 @@ export function resolveHeartbeatDeliveryTarget(params: { } } + const inheritedHeartbeatThreadId = shouldReuseHeartbeatTelegramTopicThread({ + target, + heartbeat, + turnSource: params.turnSource, + entry, + resolvedTarget, + }) + ? resolvedTarget.lastThreadId + : undefined; + return { channel: resolvedTarget.channel, to: resolved.to, reason, accountId: effectiveAccountId, - threadId: resolvedTarget.threadId, + // Heartbeats normally avoid inheriting session reply-thread IDs, but + // Telegram forum-topic sessions encode the topic as part of the + // destination identity. Preserve that topic routing when the heartbeat is + // still targeting the same group session. + threadId: resolvedTarget.threadId ?? inheritedHeartbeatThreadId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, }; @@ -285,6 +299,27 @@ function resolveHeartbeatDeliveryChatType(params: { }); } +function shouldReuseHeartbeatTelegramTopicThread(params: { + target: HeartbeatTarget; + heartbeat?: AgentDefaultsConfig["heartbeat"]; + turnSource?: DeliveryContext; + entry?: SessionEntry; + resolvedTarget: SessionDeliveryTarget; +}): boolean { + return ( + params.resolvedTarget.threadId == null && + params.target === "last" && + !params.heartbeat?.to && + params.turnSource?.threadId == null && + params.resolvedTarget.channel === "telegram" && + params.resolvedTarget.lastChannel === "telegram" && + Boolean(params.resolvedTarget.to) && + Boolean(params.resolvedTarget.lastTo) && + params.resolvedTarget.to === params.resolvedTarget.lastTo && + normalizeChatType(params.entry?.chatType) === "group" + ); +} + function resolveHeartbeatSenderId(params: { allowFrom: Array; deliveryTo?: string;