mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:20:43 +00:00
fix(heartbeat): preserve Telegram topic routing
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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