mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user