fix(heartbeat): preserve Telegram topic routing for isolated heartbeats (#66035)

Merged via squash.

Prepared head SHA: 83b986a4c3
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-04-13 18:26:19 +02:00
committed by GitHub
parent a78d922acf
commit b42c999633
4 changed files with 176 additions and 3 deletions

View File

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

View File

@@ -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 }),
);
});
});
});

View File

@@ -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: {},

View File

@@ -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<string | number>;
deliveryTo?: string;