Skip to content

[Bug]: Telegram DM with forum/topics enabled loses thread routing after v2026.2.15 #17980

@mekenthompson

Description

@mekenthompson

Summary

The v2026.2.15 release introduced a regression where DM chats with forum/topics enabled (chat.is_forum=true) no longer receive message_thread_id in outbound messages, breaking thread routing. Messages intended for specific topic threads are sent to the main chat instead.

Background

Telegram supports enabling forum/topics mode on bot DM chats (not just groups). This is the natural way to organize ongoing conversations with a bot by topic — email digests, code reviews, triage outputs, cron job results, etc. all go to their own named threads.

Root Cause

PR #17235 (fix(telegram): ignore messageThreadId for DM chats) fixed a valid bug (#14653) where non-forum DM chats received spurious message_thread_id values causing 400 Bad Request: message thread not found errors.

However, the fix over-corrected. Two functions are involved:

1. resolveTelegramThreadSpec in src/telegram/bot/helpers.ts

This function resolves the thread scope for inbound messages. On both v2026.2.14 and v2026.2.15, the non-group path is identical:

// Current (v2026.2.14 and v2026.2.15) — same behavior
if (!params.isGroup) {
  if (params.messageThreadId == null) {
    return { scope: "dm" };
  }
  return { id: params.messageThreadId, scope: "dm" };
}

The problem: when a DM has isForum=true, this still returns scope: "dm" instead of scope: "forum". The isForum param is only checked in the isGroup branch.

2. buildTelegramThreadParams in src/telegram/bot/helpers.ts

This is where the regression was introduced. The v2026.2.15 change:

// v2026.2.14 — works for forum DMs (passes thread_id through)
if (!thread?.id) return undefined;
const normalized = Math.trunc(thread.id);
if (normalized === TELEGRAM_GENERAL_TOPIC_ID && thread.scope === "forum") {
  return undefined;
}
return { message_thread_id: normalized };

// v2026.2.15 — breaks forum DMs (blanket-strips all DM thread_ids)
if (thread?.id == null) return undefined;
const normalized = Math.trunc(thread.id);
if (thread.scope === "dm") return undefined;       // ← this line
if (normalized === TELEGRAM_GENERAL_TOPIC_ID) return undefined;
return { message_thread_id: normalized };

Because resolveTelegramThreadSpec tags forum DM threads as scope: "dm", and buildTelegramThreadParams now strips all scope: "dm" thread IDs, forum DM threads are silently dropped.

Proposed Fix

Check isForum in the non-group path of resolveTelegramThreadSpec:

if (!params.isGroup) {
  // DM with forum/topics enabled — treat like a forum, not a flat DM
  if (params.isForum && params.messageThreadId != null) {
    return { id: params.messageThreadId, scope: "forum" };
  }
  return { scope: "dm" };
}

This way:

No changes needed to buildTelegramThreadParams — it already handles scope: "forum" correctly (passes thread IDs through, strips General topic ID=1).

Proposed Tests

The following test cases should be added to src/telegram/bot/helpers.test.ts to prevent future regressions:

resolveTelegramThreadSpec — DM forum cases

describe("resolveTelegramThreadSpec — DM with forum/topics", () => {
  it("returns scope 'forum' for DM with isForum=true and messageThreadId", () => {
    expect(resolveTelegramThreadSpec({
      isGroup: false,
      isForum: true,
      messageThreadId: 42,
    })).toEqual({ id: 42, scope: "forum" });
  });

  it("returns scope 'dm' for DM with isForum=true but no messageThreadId", () => {
    expect(resolveTelegramThreadSpec({
      isGroup: false,
      isForum: true,
      messageThreadId: null,
    })).toEqual({ scope: "dm" });
  });

  it("returns scope 'dm' for DM with isForum=false and messageThreadId (stale thread)", () => {
    // Non-forum DM with stale thread ID should NOT get scope: "forum"
    expect(resolveTelegramThreadSpec({
      isGroup: false,
      isForum: false,
      messageThreadId: 42,
    })).toEqual({ scope: "dm" });
  });

  it("returns scope 'dm' for DM with isForum=undefined (legacy behavior)", () => {
    expect(resolveTelegramThreadSpec({
      isGroup: false,
      messageThreadId: 42,
    })).toEqual({ scope: "dm" });
  });
});

buildTelegramThreadParams — forum thread passthrough

describe("buildTelegramThreadParams — forum scope threads", () => {
  it("passes through non-General thread IDs for forum scope", () => {
    expect(buildTelegramThreadParams({ id: 42, scope: "forum" }))
      .toEqual({ message_thread_id: 42 });
  });

  it("strips General topic (id=1) for forum scope", () => {
    expect(buildTelegramThreadParams({ id: 1, scope: "forum" }))
      .toBeUndefined();
  });

  it("strips all thread IDs for dm scope (non-forum DMs)", () => {
    expect(buildTelegramThreadParams({ id: 42, scope: "dm" }))
      .toBeUndefined();
  });
});

Integration-level: end-to-end DM forum thread routing

describe("DM forum topic routing (e2e)", () => {
  it("preserves message_thread_id when replying in a DM forum topic", () => {
    // Simulate inbound message from a DM chat with is_forum=true
    // and message_thread_id=42 (a named topic)
    // Assert: outbound sendMessage includes message_thread_id=42
  });

  it("omits message_thread_id when replying in a non-forum DM", () => {
    // Simulate inbound message from a normal DM chat (is_forum=false)
    // with a stale message_thread_id=1
    // Assert: outbound sendMessage does NOT include message_thread_id
  });
});

Impact

Anyone using forum/topics on a Telegram bot DM chat loses all thread routing on v2026.2.15. Messages land in the main chat instead of their target topics. This affects:

  • Sub-agent announce deliveries
  • Cron job thread-targeted sends
  • Proactive message tool sends with threadId
  • Reply routing for messages received in specific topics

Environment

  • OpenClaw v2026.2.15
  • Telegram bot DM with forum/topics enabled
  • HA addon

Workaround

Roll back to v2026.2.14 (pre-regression) or switch to a private supergroup with topics (group path handles isForum correctly).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions