fix(heartbeat): preserve Telegram topic routing

This commit is contained in:
mbelinky
2026-04-13 17:54:41 +02:00
parent a78d922acf
commit 894611c78d
4 changed files with 171 additions and 2 deletions

View File

@@ -791,6 +791,7 @@ Docs: https://docs.openclaw.ai
- Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.
- Telegram/outbound chunking: use static markdown chunking when Telegram runtime state is unavailable so long outbound Telegram messages still split correctly after cold starts. (#57816) Thanks @ForestDengHK.
- Update/Corepack: disable interactive Corepack download prompts during update preflight install unless `COREPACK_ENABLE_DOWNLOAD_PROMPT` is already explicitly set, so `openclaw update` can fetch the repo-pinned pnpm version non-interactively. (#61456) Thanks @p6l-richard.
- Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when `target=last`, instead of dropping them into the group root chat.
## 2026.4.2

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,70 @@ 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(),
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: {
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

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