fix(telegram): cron and heartbeat messages land in wrong chat instead of target topic#19367
Conversation
bb08866 to
539a260
Compare
Additional Comments (3)
The outer Prompt To Fix With AIThis is a comment left during a code review.
Path: ui/src/ui/views/chat.ts
Line: 439:441
Comment:
**Double confirmation dialog on delete**
`deleteSession()` in `controllers/sessions.ts` already calls `window.confirm(...)` internally. Wrapping the call here in another `confirm(...)` means the user is prompted **twice** in sequence. Additionally, when the user confirms in this first dialog but then cancels in `deleteSession`'s second dialog, the `onDelete` callback in `app-render.ts` still unconditionally navigates away to the main session — switching the user away from a session that was never actually deleted.
The outer `confirm` here should be removed so that `deleteSession` remains the sole confirmation gate:
```suggestion
@click=${() => {
props.onDelete!();
}}
```
How can I resolve this? If you propose a fix, please make it concise.
Either Prompt To Fix With AIThis is a comment left during a code review.
Path: ui/src/ui/app-render.ts
Line: 1134:1150
Comment:
**Navigation fires even when deletion is cancelled**
`deleteSession()` returns `void` — it returns early without deleting if the user cancels the confirm dialog (or if `sessionsLoading` is true). However, the code that follows always navigates to `mainKey` and reloads chat history regardless of whether deletion actually happened. This means a user who cancels the confirmation inside `deleteSession` will still have their active session silently switched to main.
Either `deleteSession` should return a `boolean` indicating success, or the navigation code should be inlined into `deleteSession` itself. For example:
```
const { deleteSession } = await import("./controllers/sessions.ts");
const deleted = await deleteSession(
state as Parameters<typeof deleteSession>[0],
state.sessionKey,
);
if (!deleted) return;
// Switch to main session after deletion
```
How can I resolve this? If you propose a fix, please make it concise.
The comment on the function says "return the most recent N", but For threads with more than 200 replies, the actual most recent messages (201 onward) are never fetched and won't appear in To get the genuinely most recent N messages, the fetch would need to paginate to the last page, or use the Prompt To Fix With AIThis is a comment left during a code review.
Path: src/slack/monitor/media.ts
Line: 235:248
Comment:
**Thread history fetch only covers the oldest 200 messages, not the most recent**
The comment on the function says "return the most recent N", but `conversations.replies` returns replies oldest-first. Fetching with `limit: 200` and no cursor gives messages 1–200 (the oldest). `.slice(-maxMessages)` then returns the last N of those oldest 200 — not the last N of the full thread.
For threads with more than 200 replies, the actual most recent messages (201 onward) are never fetched and won't appear in `ThreadHistoryBody`. The comment and intent are inconsistent with the implementation.
To get the genuinely most recent N messages, the fetch would need to paginate to the last page, or use the `latest` parameter available in `conversations.replies` to anchor near the current message timestamp.
How can I resolve this? If you propose a fix, please make it concise. |
|
/review |
|
@greptile-apps[bot] /review |
539a260 to
c333be0
Compare
|
@greptile-apps[bot] review |
5efa8a6 to
b48031a
Compare
|
@greptileai review |
b48031a to
ef02875
Compare
src/infra/outbound/targets.ts
Outdated
| if (rawExplicitTo && rawExplicitTo.includes(":topic:")) { | ||
| const parsed = parseTelegramTarget(rawExplicitTo); | ||
| explicitTo = parsed.chatId; | ||
| parsedThreadId = parsed.messageThreadId; | ||
| } |
There was a problem hiding this comment.
parseTelegramTarget imported into a channel-agnostic module
resolveSessionDeliveryTarget is channel-agnostic and is called for WhatsApp, Slack, and other channels as well as Telegram. Importing a Telegram-specific parser here means any explicitTo value containing :topic: on any channel will be silently mangled — explicitTo is rewritten to chatId and messageThreadId is extracted. While :topic: is unlikely to appear in a WhatsApp E.164 number, it is plausible in Slack channel identifiers or other future channels.
Consider guarding this branch on the resolved channel being "telegram" (or at least documenting that this is intentionally channel-agnostic because :topic: is Telegram-only by convention).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/targets.ts
Line: 89:93
Comment:
**`parseTelegramTarget` imported into a channel-agnostic module**
`resolveSessionDeliveryTarget` is channel-agnostic and is called for WhatsApp, Slack, and other channels as well as Telegram. Importing a Telegram-specific parser here means any `explicitTo` value containing `:topic:` on any channel will be silently mangled — `explicitTo` is rewritten to `chatId` and `messageThreadId` is extracted. While `:topic:` is unlikely to appear in a WhatsApp E.164 number, it is plausible in Slack channel identifiers or other future channels.
Consider guarding this branch on the resolved channel being `"telegram"` (or at least documenting that this is intentionally channel-agnostic because `:topic:` is Telegram-only by convention).
How can I resolve this? If you propose a fix, please make it concise.| it("parses :topic:NNN from explicitTo into threadId", () => { | ||
| const resolved = resolveSessionDeliveryTarget({ | ||
| entry: { | ||
| sessionId: "sess-topic", | ||
| updatedAt: 1, | ||
| lastChannel: "telegram", | ||
| lastTo: "63448508", | ||
| }, | ||
| requestedChannel: "last", | ||
| explicitTo: "63448508:topic:1008013", | ||
| }); | ||
|
|
||
| expect(resolved.to).toBe("63448508"); | ||
| expect(resolved.threadId).toBe(1008013); | ||
| }); |
There was a problem hiding this comment.
Test only covers the matching-lastTo case for :topic: parsing
Both :topic: tests set lastTo: "63448508" to match the chatId extracted from explicitTo: "63448508:topic:1008013". This means resolved.to === resolved.lastTo, which is the condition required for threadId to survive the guard in delivery-target.ts (line 99-102).
A test where lastTo does not match the chatId (or is absent) would expose the silent threadId drop described in the related comment on delivery-target.ts. Consider adding a case like:
it(":topic: parsed threadId is dropped by delivery-target guard when lastTo is absent", () => {
const resolved = resolveSessionDeliveryTarget({
entry: { sessionId: "s", updatedAt: 1, lastChannel: "telegram" }, // no lastTo
requestedChannel: "last",
explicitTo: "63448508:topic:1008013",
});
// resolveSessionDeliveryTarget returns threadId=1008013 here, but delivery-target.ts drops it
expect(resolved.threadId).toBe(1008013);
});Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/targets.test.ts
Line: 137:151
Comment:
**Test only covers the matching-`lastTo` case for `:topic:` parsing**
Both `:topic:` tests set `lastTo: "63448508"` to match the chatId extracted from `explicitTo: "63448508:topic:1008013"`. This means `resolved.to === resolved.lastTo`, which is the condition required for `threadId` to survive the guard in `delivery-target.ts` (line 99-102).
A test where `lastTo` does not match the chatId (or is absent) would expose the silent threadId drop described in the related comment on `delivery-target.ts`. Consider adding a case like:
```typescript
it(":topic: parsed threadId is dropped by delivery-target guard when lastTo is absent", () => {
const resolved = resolveSessionDeliveryTarget({
entry: { sessionId: "s", updatedAt: 1, lastChannel: "telegram" }, // no lastTo
requestedChannel: "last",
explicitTo: "63448508:topic:1008013",
});
// resolveSessionDeliveryTarget returns threadId=1008013 here, but delivery-target.ts drops it
expect(resolved.threadId).toBe(1008013);
});
```
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
How can I resolve this? If you propose a fix, please make it concise.
Additional Comments (1)
When a cron job has This means the very feature advertised in the PR description — The guard was designed to prevent stale session-derived thread IDs from leaking to a different recipient. But for thread IDs that were explicitly parsed from the Prompt To Fix With AIThis is a comment left during a code review.
Path: src/cron/isolated-agent/delivery-target.ts
Line: 99:102
Comment:
**`threadId` silently dropped for explicit `:topic:` cron targets with no session history**
When a cron job has `delivery.to = "chatId:topic:NNN"` (an explicit Telegram topic target), `resolveSessionDeliveryTarget` correctly parses it into `resolved.to = "chatId"` and `resolved.threadId = NNN`. However, this guard then checks `resolved.to === resolved.lastTo`. If no prior session exists for this cron (e.g. a newly configured cron job), `resolved.lastTo` is `undefined`, so the condition is `false` and `threadId` becomes `undefined`.
This means the very feature advertised in the PR description — `delivery.to = "chatId:topic:678"` for cron — silently drops the thread ID on the first run (or any run without a matching session entry). The thread session lookup path (no explicit `to`, relies on `sessionKey`) is unaffected because `resolved.to` and `resolved.lastTo` both come from `lastTo` of the found session entry and naturally match.
The guard was designed to prevent stale session-derived thread IDs from leaking to a different recipient. But for thread IDs that were explicitly parsed from the `to` config value (not from session state), the guard is overly strict. A possible fix is to bypass the guard when the threadId came from an explicit `to` (i.e. `parsedThreadId` was set).
How can I resolve this? If you propose a fix, please make it concise. |
4f48d62 to
3096c6e
Compare
|
@greptileai review — addressed all 3 findings: added channel guard for Telegram-only :topic: parsing, added missing test cases, confirmed sessionKey was already fixed in prior commit |
| const cfg: OpenClawConfig = {}; | ||
|
|
||
| it("uses thread session entry when sessionKey is provided and entry exists", async () => { | ||
| mockStore["/mock/store.json"] = { | ||
| "agent:main:main": { | ||
| sessionId: "s1", | ||
| updatedAt: 1, | ||
| lastChannel: "telegram", | ||
| lastTo: "-100111", | ||
| }, | ||
| "agent:main:main:thread:9999": { | ||
| sessionId: "s2", | ||
| updatedAt: 2, | ||
| lastChannel: "telegram", | ||
| lastTo: "-100111", | ||
| lastThreadId: 9999, | ||
| }, | ||
| }; | ||
|
|
||
| const result = await resolveDeliveryTarget(cfg, "main", { | ||
| channel: "last", | ||
| sessionKey: "agent:main:main:thread:9999", | ||
| }); | ||
|
|
||
| expect(result.to).toBe("-100111"); | ||
| expect(result.threadId).toBe(9999); | ||
| expect(result.channel).toBe("telegram"); | ||
| }); | ||
|
|
||
| it("falls back to main session when sessionKey entry does not exist", async () => { | ||
| mockStore["/mock/store.json"] = { | ||
| "agent:main:main": { | ||
| sessionId: "s1", | ||
| updatedAt: 1, | ||
| lastChannel: "telegram", | ||
| lastTo: "-100222", | ||
| }, | ||
| }; | ||
|
|
||
| const result = await resolveDeliveryTarget(cfg, "main", { | ||
| channel: "last", | ||
| sessionKey: "agent:main:main:thread:nonexistent", | ||
| }); | ||
|
|
||
| expect(result.to).toBe("-100222"); | ||
| expect(result.threadId).toBeUndefined(); | ||
| expect(result.channel).toBe("telegram"); | ||
| }); | ||
|
|
||
| it("falls back to main session when no sessionKey is provided", async () => { | ||
| mockStore["/mock/store.json"] = { | ||
| "agent:main:main": { | ||
| sessionId: "s1", | ||
| updatedAt: 1, | ||
| lastChannel: "telegram", | ||
| lastTo: "-100333", | ||
| }, | ||
| }; | ||
|
|
||
| const result = await resolveDeliveryTarget(cfg, "main", { | ||
| channel: "last", | ||
| }); | ||
|
|
||
| expect(result.to).toBe("-100333"); | ||
| expect(result.threadId).toBeUndefined(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Missing test: explicit :topic: in delivery.to on first cron run
The new tests cover the thread session lookup path (sessionKey + lastThreadId), but none test the case where delivery.to itself contains :topic:NNN and there is no prior session history. Due to the guard in delivery-target.ts (resolved.to === resolved.lastTo), the threadId parsed from "63448508:topic:1008013" will be dropped when lastTo is absent.
Consider adding a test:
it("preserves threadId from :topic: suffix in delivery.to when no session history", async () => {
mockStore["/mock/store.json"] = {}; // no prior session
const result = await resolveDeliveryTarget(cfg, "main", {
channel: "telegram",
to: "63448508:topic:1008013",
});
expect(result.to).toBe("63448508");
expect(result.threadId).toBe(1008013); // currently undefined — this would catch the bug
});Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/isolated-agent.delivery-target-thread-session.test.ts
Line: 33:99
Comment:
**Missing test: explicit `:topic:` in `delivery.to` on first cron run**
The new tests cover the thread session lookup path (`sessionKey` + `lastThreadId`), but none test the case where `delivery.to` itself contains `:topic:NNN` and there is no prior session history. Due to the guard in `delivery-target.ts` (`resolved.to === resolved.lastTo`), the `threadId` parsed from `"63448508:topic:1008013"` will be dropped when `lastTo` is absent.
Consider adding a test:
```typescript
it("preserves threadId from :topic: suffix in delivery.to when no session history", async () => {
mockStore["/mock/store.json"] = {}; // no prior session
const result = await resolveDeliveryTarget(cfg, "main", {
channel: "telegram",
to: "63448508:topic:1008013",
});
expect(result.to).toBe("63448508");
expect(result.threadId).toBe(1008013); // currently undefined — this would catch the bug
});
```
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
How can I resolve this? If you propose a fix, please make it concise.
Additional Comments (1)
When a cron job has The guard was designed to prevent stale session-derived Consider tracking whether // Allow explicitly-parsed threadId (from :topic: suffix) through even if lastTo is absent.
const isExplicitThreadId = Boolean(resolved.explicitThreadId);
const threadId =
resolved.threadId &&
resolved.to &&
(isExplicitThreadId || resolved.to === resolved.lastTo)
? resolved.threadId
: undefined;Prompt To Fix With AIThis is a comment left during a code review.
Path: src/cron/isolated-agent/delivery-target.ts
Line: 99:102
Comment:
**`threadId` from explicit `:topic:` suffix dropped on first cron run**
When a cron job has `delivery.to = "63448508:topic:1008013"` but no prior session history (`lastTo` is absent), the guard `resolved.to === resolved.lastTo` evaluates to `"63448508" === undefined` → `false`, and the parsed `threadId` is silently discarded. The fix is correct for thread session lookup (via `sessionKey`), but an explicitly-configured `:topic:` on `delivery.to` will fail on the very first run.
The guard was designed to prevent stale session-derived `threadId` from leaking to a different target. It should not apply when the `threadId` comes from an explicitly-parsed `:topic:` suffix — that case is intentional, not stale.
Consider tracking whether `threadId` originated from an explicit source (e.g. carry `resolvedTarget.explicitThreadId` separately from `resolvedTarget.threadId`), or allow the guard to pass through when the `threadId` was explicitly configured:
```typescript
// Allow explicitly-parsed threadId (from :topic: suffix) through even if lastTo is absent.
const isExplicitThreadId = Boolean(resolved.explicitThreadId);
const threadId =
resolved.threadId &&
resolved.to &&
(isExplicitThreadId || resolved.to === resolved.lastTo)
? resolved.threadId
: undefined;
```
How can I resolve this? If you propose a fix, please make it concise. |
3096c6e to
1216cfc
Compare
|
@greptileai review — fixed critical first-run threadId drop: added threadIdExplicit flag to distinguish explicit (:topic: parsed / param) from session-derived threadIds. Guard now preserves explicit threadIds regardless of lastTo. Added 2 tests for the exact scenario you flagged. |
1216cfc to
c404e30
Compare
|
@greptileai review |
c404e30 to
50a2e07
Compare
|
@greptileai review — tightened channel guard: isTelegramContext now only true when channel or lastChannel is positively 'telegram', no assumptions when unknown |
50a2e07 to
fbde406
Compare
|
@greptileai review |
1 similar comment
|
@greptileai review |
|
Hey @sebslight @mbelinky @onutc @joshavant @obviyus — this is ready to merge. Spun up a separate bot instance, tested cron and heartbeat delivery to Telegram topics — everything works. Would appreciate a review 🙏 |
|
@greptileai review |
- Cron delivery-target now accepts sessionKey param and looks up thread-specific session entry before falling back to main session. This ensures cron jobs targeting Telegram topics deliver to the correct thread. - Added threadId to heartbeat config (type, zod schema), OutboundTarget type, and plumbed it through resolveHeartbeatDeliveryTarget and heartbeat-runner delivery calls. - Added tests for thread session lookup in delivery-target, threadId passthrough in heartbeat delivery target resolution, and explicitThreadId in resolveSessionDeliveryTarget.
fbde406 to
bf02bbf
Compare
… of target topic (openclaw#19367) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: bf02bbf Co-authored-by: Lukavyi <1013690+Lukavyi@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus
… of target topic (openclaw#19367) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: bf02bbf Co-authored-by: Lukavyi <1013690+Lukavyi@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus
… of target topic (openclaw#19367) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: bf02bbf Co-authored-by: Lukavyi <1013690+Lukavyi@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus
… of target topic (openclaw#19367) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: bf02bbf Co-authored-by: Lukavyi <1013690+Lukavyi@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus
Problem
Cron jobs and heartbeats ignore the configured Telegram topic and deliver to the last active topic instead — whichever thread had the most recent conversation. This causes random thread pollution: scheduled messages appear in the wrong topic.
Two separate issues:
delivery.tois set to63448508:topic:1234, the:topic:suffix is never parsed —messageThreadIdis lost and the message goes to the last active topic instead of the configured one.Solution
Parse
:topic:NNNsuffix from thetofield inresolveSessionDeliveryTarget(). This is a shared resolver used by both cron and heartbeat delivery paths.63448508:topic:1234→chatId=63448508,messageThreadId=123463448508→ plain chat ID, no topic (unchanged behavior)For cron specifically: also look up the thread-specific session entry (via
job.sessionKey) to recoverlastThreadIdfrom session history.:topic:parsing is Telegram-only — guarded by channel context to prevent false positives on Slack or other channels.Config Examples
Cron job — deliver to a topic:
{ "delivery": { "channel": "telegram", "to": "63448508:topic:1234" } }Heartbeat — deliver to a topic:
{ "agents": { "defaults": { "heartbeat": { "target": "telegram", "to": "63448508:topic:1234" } } } }Format:
<chatId>:topic:<messageThreadId>When
channelis"last"or omitted,:topic:is still parsed if the resolved or last-known channel is Telegram. For non-Telegram channels, the value passes through as-is.Changes
delivery-target.tsjob.sessionKey; preservethreadIdwhen explicitly setrun.tsparams.job.sessionKey(not isolated cron session key)targets.ts:topic:NNNfromexplicitTo; addthreadIdExplicitflag; Telegram-only guardheartbeat-runner.tsTests
delivery-target.test.ts— thread session lookup (found, fallback, missing)heartbeat-runner.*.test.ts—:topic:parsing from heartbeatto, plaintowithout topictargets.test.ts—:topic:parsing,explicitThreadIdpriority, non-Telegram skip, absentlastToAll pass:
pnpm test✅Manual Testing
Spun up a separate bot instance (Docker), tested cron and heartbeat delivery to Telegram DM topics — messages land in the correct topic thread.
Closes #19365
Closes #19366
lobster-biscuit
Sign-Off
heartbeat.threadIdconfig field; refactored to parse:topic:NNNfromexplicitToinresolveSessionDeliveryTarget()for consistency with existing Telegram target syntax used elsewhere in the codebaseGreptile Summary
This PR fixes two bugs where cron jobs and heartbeats ignore the configured Telegram topic and deliver messages to the wrong thread. The fix introduces
:topic:NNNparsing in the sharedresolveSessionDeliveryTarget()resolver, guarded to Telegram-only contexts, and adds athreadIdExplicitflag to distinguish explicitly configured thread IDs from session-derived ones, preventing the stale-threadId safety guard indelivery-target.tsfrom dropping them.:topic:NNNparsing added toresolveSessionDeliveryTarget()with a Telegram-only guard (isTelegramContext) to prevent mangling targets on other channelsdelivery-target.tsusesjob.sessionKeyto find the thread-specific session entry (withlastThreadId), falling back to the main sessionthreadIdExplicitflag ensures explicitly configured thread IDs bypass theto === lastTosafety guard that protects against stale session-derived thread IDs leaking to different chatsthreadIdpropagation —delivery.threadIdis now passed through todeliverOutboundPayloadsin both heartbeat send paths:topic:parsing, non-Telegram skip, explicit priority, and first-run scenariosConfidence Score: 5/5
:topic:NNNparsing is properly guarded to Telegram contexts only, thethreadIdExplicitflag cleanly separates explicit vs session-derived thread IDs, and all edge cases (fallback, first-run, non-Telegram channels) are tested. No logic errors or regressions found.Last reviewed commit: fbde406