Skip to content

fix(telegram): cron and heartbeat messages land in wrong chat instead of target topic#19367

Merged
obviyus merged 2 commits intoopenclaw:mainfrom
Lukavyi:fix-cron-thread-delivery
Feb 18, 2026
Merged

fix(telegram): cron and heartbeat messages land in wrong chat instead of target topic#19367
obviyus merged 2 commits intoopenclaw:mainfrom
Lukavyi:fix-cron-thread-delivery

Conversation

@Lukavyi
Copy link
Contributor

@Lukavyi Lukavyi commented Feb 17, 2026

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:

  1. Cron: even when delivery.to is set to 63448508:topic:1234, the :topic: suffix is never parsed — messageThreadId is lost and the message goes to the last active topic instead of the configured one.
  2. Heartbeat: has no way to specify a target topic at all — no config field, no syntax support.

Solution

Parse :topic:NNN suffix from the to field in resolveSessionDeliveryTarget(). This is a shared resolver used by both cron and heartbeat delivery paths.

  • 63448508:topic:1234chatId=63448508, messageThreadId=1234
  • 63448508 → plain chat ID, no topic (unchanged behavior)

For cron specifically: also look up the thread-specific session entry (via job.sessionKey) to recover lastThreadId from 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 channel is "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

File What
delivery-target.ts Look up thread session via job.sessionKey; preserve threadId when explicitly set
run.ts Pass params.job.sessionKey (not isolated cron session key)
targets.ts Parse :topic:NNN from explicitTo; add threadIdExplicit flag; Telegram-only guard
heartbeat-runner.ts Thread ID flows through shared resolver (no separate field needed)

Tests

  • delivery-target.test.ts — thread session lookup (found, fallback, missing)
  • heartbeat-runner.*.test.ts:topic: parsing from heartbeat to, plain to without topic
  • targets.test.ts:topic: parsing, explicitThreadId priority, non-Telegram skip, absent lastTo

All 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

  • Models used: Claude Opus 4 (claude-opus-4-6)
  • Submitter effort: Diagnosed root cause via code tracing, implemented fix, wrote tests, iterated on API design (removed separate threadId field in favor of :topic: parsing in shared resolver)
  • Agent notes: Initial implementation added a separate heartbeat.threadId config field; refactored to parse :topic:NNN from explicitTo in resolveSessionDeliveryTarget() for consistency with existing Telegram target syntax used elsewhere in the codebase

Greptile 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:NNN parsing in the shared resolveSessionDeliveryTarget() resolver, guarded to Telegram-only contexts, and adds a threadIdExplicit flag to distinguish explicitly configured thread IDs from session-derived ones, preventing the stale-threadId safety guard in delivery-target.ts from dropping them.

  • :topic:NNN parsing added to resolveSessionDeliveryTarget() with a Telegram-only guard (isTelegramContext) to prevent mangling targets on other channels
  • Thread session lookup in delivery-target.ts uses job.sessionKey to find the thread-specific session entry (with lastThreadId), falling back to the main session
  • threadIdExplicit flag ensures explicitly configured thread IDs bypass the to === lastTo safety guard that protects against stale session-derived thread IDs leaking to different chats
  • Heartbeat threadId propagationdelivery.threadId is now passed through to deliverOutboundPayloads in both heartbeat send paths
  • Comprehensive test coverage for all new paths: thread session lookup, :topic: parsing, non-Telegram skip, explicit priority, and first-run scenarios

Confidence Score: 5/5

  • This PR is safe to merge — it fixes a clear bug with well-scoped changes, comprehensive tests, and proper guards to prevent regressions on non-Telegram channels.
  • The changes are well-structured and limited in scope: :topic:NNN parsing is properly guarded to Telegram contexts only, the threadIdExplicit flag 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.
  • No files require special attention.

Last reviewed commit: fbde406

@openclaw-barnacle openclaw-barnacle bot added channel: slack Channel integration: slack app: web-ui App: web-ui agents Agent runtime and tooling size: L labels Feb 17, 2026
@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch from bb08866 to 539a260 Compare February 17, 2026 18:36
@openclaw-barnacle openclaw-barnacle bot added size: M and removed channel: slack Channel integration: slack app: web-ui App: web-ui agents Agent runtime and tooling size: L labels Feb 17, 2026
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

30 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 17, 2026

Additional Comments (3)

ui/src/ui/views/chat.ts
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:

                    @click=${() => {
                      props.onDelete!();
                    }}
Prompt To Fix With AI
This 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.

ui/src/ui/app-render.ts
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
Prompt To Fix With AI
This 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.

src/slack/monitor/media.ts
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.

Prompt To Fix With AI
This 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.

@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

/review

@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

@greptile-apps[bot] /review

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch from 539a260 to c333be0 Compare February 17, 2026 18:55
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

@greptile-apps[bot] review

@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch 4 times, most recently from 5efa8a6 to b48031a Compare February 17, 2026 21:40
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

@greptileai review

@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch from b48031a to ef02875 Compare February 17, 2026 21:46
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +89 to +93
if (rawExplicitTo && rawExplicitTo.includes(":topic:")) {
const parsed = parseTelegramTarget(rawExplicitTo);
explicitTo = parsed.chatId;
parsedThreadId = parsed.messageThreadId;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +137 to +151
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);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 17, 2026

Additional Comments (1)

src/cron/isolated-agent/delivery-target.ts
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).

Prompt To Fix With AI
This 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.

@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch 2 times, most recently from 4f48d62 to 3096c6e Compare February 17, 2026 21:53
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

@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

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +33 to +99
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();
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 17, 2026

Additional Comments (1)

src/cron/isolated-agent/delivery-target.ts
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" === undefinedfalse, 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:

// 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 AI
This 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.

@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch from 3096c6e to 1216cfc Compare February 17, 2026 22:09
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

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

@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch from 1216cfc to c404e30 Compare February 17, 2026 22:14
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

@greptileai review

@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch from c404e30 to 50a2e07 Compare February 17, 2026 22:22
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

@greptileai review — tightened channel guard: isTelegramContext now only true when channel or lastChannel is positively 'telegram', no assumptions when unknown

@Lukavyi Lukavyi force-pushed the fix-cron-thread-delivery branch from 50a2e07 to fbde406 Compare February 17, 2026 22:40
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

@greptileai review

1 similar comment
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

@greptileai review

@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 17, 2026

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 🙏

@Lukavyi Lukavyi changed the title fix: cron delivery resolves thread session + heartbeat threadId support fix(telegram): cron and heartbeat messages land in wrong chat instead of target topic Feb 18, 2026
@obviyus obviyus self-assigned this Feb 18, 2026
@Lukavyi
Copy link
Contributor Author

Lukavyi commented Feb 18, 2026

@greptileai review

Lukavyi and others added 2 commits February 18, 2026 15:13
- 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.
@obviyus obviyus force-pushed the fix-cron-thread-delivery branch from fbde406 to bf02bbf Compare February 18, 2026 09:59
@openclaw-barnacle openclaw-barnacle bot added docs Improvements or additions to documentation gateway Gateway runtime labels Feb 18, 2026
@obviyus obviyus merged commit d833dcd into openclaw:main Feb 18, 2026
24 checks passed
@obviyus
Copy link
Contributor

obviyus commented Feb 18, 2026

Merged via squash.

Thanks @Lukavyi!

NOVA-Openclaw pushed a commit to NOVA-Openclaw/nova-openclaw that referenced this pull request Feb 18, 2026
… 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
HenryChenV pushed a commit to HenryChenV/openclaw that referenced this pull request Feb 20, 2026
… 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
rodrigogs pushed a commit to rodrigogs/openclaw that referenced this pull request Feb 20, 2026
… 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
zooqueen pushed a commit to hanzoai/bot that referenced this pull request Mar 6, 2026
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation gateway Gateway runtime size: M

Projects

None yet

2 participants