diff --git a/CHANGELOG.md b/CHANGELOG.md index fedb10cf527..9c0ce75c02e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427. - Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah. - CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via `reliability.outputLimits`, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840. +- Telegram/DMs: keep incidental `message_thread_id` reply-with-quote metadata on the flat DM session by default while preserving opt-in DM topic isolation for configured topics. Fixes #75975. Thanks @ProjectEvolutionEVE. - Providers/OpenAI: resolve `keychain::` `OPENAI_API_KEY` refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt. - Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai. - Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 39f05f1c2cf..11d5747bdf8 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -366770fd037ace1092595b351fbd83473ee1ecce188bceb0ab4510a5579a9073 config-baseline.json -2d132b4c2e3b0e0f2524fc1cc889d3be658ad0e40c970b2d367bf27348883658 config-baseline.core.json -f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json +ba41e5775c361dba63fec0441f943106d8cd0cb0f10c10fee36becc1555d5059 config-baseline.json +7b1716d578d22e5b4388f56140b50d326f61327b760f8c580bdd9b971335fb85 config-baseline.core.json +74632b512b6470a155652c7d15b9e430738a05df3b5a85dca16cc4d84dcea764 config-baseline.channel.json fffe0e74eab92a88c3c57952a70bc932438ce3a7f5f9982688437f2cdaee0bcb config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index de278140249..eda354d2b29 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -84befa4ad71bee22d9ea91a6ff689532deb3783143af7488a98a7341d5ce5f25 plugin-sdk-api-baseline.json -046bb0c9bc40bfb2f8a323bf658c45eeeb486571301757abc5472018db7d2189 plugin-sdk-api-baseline.jsonl +1b91ea9cadcedacd0c7e7cf9ca2e48739bd8f99a107cb59ba8b0798d0729b374 plugin-sdk-api-baseline.json +f323d1b6e71b9e65555c13e22dcdad0cd9c9db24243dad4c7da27855d2b69888 plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 9f969df2325..302ae4ddd14 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -260,7 +260,7 @@ curl "https://api.telegram.org/bot/getUpdates" - Routing is deterministic: Telegram inbound replies back to Telegram (the model does not pick channels). - Inbound messages normalize into the shared channel envelope with reply metadata and media placeholders. - Group sessions are isolated by group ID. Forum topics append `:topic:` to keep topics isolated. -- DM messages can carry `message_thread_id`; OpenClaw routes them with thread-aware session keys and preserves thread ID for replies. +- DM messages can carry `message_thread_id`; OpenClaw preserves the thread ID for replies but keeps DMs on the flat session by default. Configure `channels.telegram.direct..threadReplies: "inbound"` or `requireTopic: true` when you intentionally want DM topic session isolation. - Long polling uses grammY runner with per-chat/per-thread sequencing. Overall runner sink concurrency uses `agents.defaults.maxConcurrent`. - Long polling is guarded inside each gateway process so only one active poller can use a bot token at a time. If you still see `getUpdates` 409 conflicts, another OpenClaw gateway, script, or external poller is likely using the same token. - Long-polling watchdog restarts trigger after 120 seconds without completed `getUpdates` liveness by default. Increase `channels.telegram.pollingStallThresholdMs` only if your deployment still sees false polling-stall restarts during long-running work. The value is in milliseconds and is allowed from `30000` to `600000`; per-account overrides are supported. @@ -542,7 +542,7 @@ curl "https://api.telegram.org/bot/getUpdates" **Thread-bound ACP spawn from chat**: `/acp spawn --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly. OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnSessions` to remain enabled (default: `true`). - Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep DM routing but use thread-aware session keys. + Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep DM routing and reply metadata; they only use thread-aware session keys when the DM is configured with `threadReplies: "inbound"`, `threadReplies: "always"`, `requireTopic: true`, or a matching topic config. diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index c3ea8adc732..48b4db1abc6 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -14,7 +14,11 @@ import { import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status"; import { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation"; import type { DmPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import type { TelegramGroupConfig, TelegramTopicConfig } from "openclaw/plugin-sdk/config-types"; +import type { + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "openclaw/plugin-sdk/config-types"; import { buildPluginBindingResolvedText, parsePluginBindingApprovalCustomId, @@ -72,6 +76,7 @@ import { resolveTelegramForumFlag, resolveTelegramForumThreadId, resolveTelegramGroupAllowFromContext, + shouldUseTelegramDmThreadSession, withResolvedTelegramForumFlag, } from "./bot/helpers.js"; import type { TelegramContext, TelegramGetChat } from "./bot/types.js"; @@ -320,7 +325,10 @@ export const registerTelegramHandlers = ({ }); const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; const topicThreadId = resolvedThreadId ?? dmThreadId; - const { topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId); + const { groupConfig, topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId); + const directConfig = !params.isGroup + ? (groupConfig as TelegramDirectConfig | undefined) + : undefined; const { route } = resolveTelegramConversationRoute({ cfg: runtimeCfg, accountId, @@ -338,10 +346,9 @@ export const registerTelegramHandlers = ({ isGroup: params.isGroup, senderId: params.senderId, }); - const threadKeys = - dmThreadId != null - ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` }) - : null; + const threadKeys = shouldUseTelegramDmThreadSession({ dmThreadId, directConfig, topicConfig }) + ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` }) + : null; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, { agentId: route.agentId, diff --git a/extensions/telegram/src/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts index 0c8cfad992f..22250188ef3 100644 --- a/extensions/telegram/src/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -59,12 +59,19 @@ afterEach(() => { }); describe("buildTelegramMessageContext dm thread sessions", () => { - const buildContext = async (message: Record) => + const buildContext = async ( + message: Record, + params?: Pick< + Parameters[0], + "resolveTelegramGroupConfig" + >, + ) => await buildTelegramMessageContextForTest({ message, + ...params, }); - it("uses thread session key for dm topics", async () => { + it("keeps incidental dm message_thread_id on the main session by default", async () => { const ctx = await buildContext({ message_id: 1, chat: { id: 1234, type: "private" }, @@ -74,6 +81,29 @@ describe("buildTelegramMessageContext dm thread sessions", () => { from: { id: 42, first_name: "Alice" }, }); + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); + }); + + it("uses thread session key for configured dm topics", async () => { + const ctx = await buildContext( + { + message_id: 3, + chat: { id: 1234, type: "private" }, + date: 1700000002, + text: "hello", + message_thread_id: 42, + from: { id: 42, first_name: "Alice" }, + }, + { + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireTopic: true }, + topicConfig: undefined, + }), + }, + ); + expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 7e06822be5e..53308ec9937 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -22,6 +22,7 @@ import { extractTelegramForumFlag, resolveTelegramForumFlag, resolveTelegramThreadSpec, + shouldUseTelegramDmThreadSession, } from "./bot/helpers.js"; import type { TelegramGetChat } from "./bot/types.js"; import { @@ -381,11 +382,14 @@ export const buildTelegramMessageContext = async ({ isGroup, senderId, }); - // DMs: use thread suffix for session isolation (works regardless of dmScope) - const threadKeys = - dmThreadId != null - ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` }) - : null; + const useDmThreadSession = shouldUseTelegramDmThreadSession({ + dmThreadId, + directConfig, + topicConfig, + }); + const threadKeys = useDmThreadSession + ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` }) + : null; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; route = { ...route, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 7db4ad146b2..10503764469 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -67,6 +67,7 @@ import { resolveTelegramForumFlag, resolveTelegramGroupAllowFromContext, resolveTelegramThreadSpec, + shouldUseTelegramDmThreadSession, } from "./bot/helpers.js"; import type { TelegramContext, TelegramGetChat } from "./bot/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; @@ -887,13 +888,16 @@ export const registerTelegramNativeCommands = ({ senderId, }); const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; - const threadKeys = - dmThreadId != null - ? (await resolveNativeCommandRuntime()).resolveThreadSessionKeys({ - baseSessionKey, - threadId: `${chatId}:${dmThreadId}`, - }) - : null; + const threadKeys = shouldUseTelegramDmThreadSession({ + dmThreadId, + directConfig: !isGroup ? (groupConfig as TelegramDirectConfig | undefined) : undefined, + topicConfig, + }) + ? (await resolveNativeCommandRuntime()).resolveThreadSessionKeys({ + baseSessionKey, + threadId: `${chatId}:${dmThreadId}`, + }) + : null; cachedTargetSessionKey = threadKeys?.sessionKey ?? baseSessionKey; return cachedTargetSessionKey; }; diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index e7a6a10f1e9..63ed0cd1edc 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -2240,7 +2240,7 @@ describe("createTelegramBot", () => { undefined, ); }); - it("sets command target session key for dm topic commands", async () => { + it("keeps unconfigured dm topic commands on the flat dm session", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); commandSpy.mockClear(); @@ -2279,7 +2279,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:12345:99"); + expect(payload.CommandTargetSessionKey).toBe("agent:main:main"); }); it("allows native DM commands for paired users", async () => { diff --git a/extensions/telegram/src/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts index 7c4de43f81b..330c00f0f63 100644 --- a/extensions/telegram/src/bot/helpers.test.ts +++ b/extensions/telegram/src/bot/helpers.test.ts @@ -13,6 +13,7 @@ import { resolveTelegramForumFlag, resolveTelegramForumThreadId, resetTelegramForumFlagCacheForTest, + shouldUseTelegramDmThreadSession, } from "./helpers.js"; describe("resolveTelegramForumThreadId", () => { @@ -125,6 +126,33 @@ describe("buildTelegramThreadParams", () => { }); }); +describe("shouldUseTelegramDmThreadSession", () => { + it("keeps incidental DM thread ids flat by default", () => { + expect(shouldUseTelegramDmThreadSession({ dmThreadId: 42 })).toBe(false); + }); + + it("uses DM thread sessions for explicit or topic-required configs", () => { + expect( + shouldUseTelegramDmThreadSession({ + dmThreadId: 42, + directConfig: { threadReplies: "inbound" }, + }), + ).toBe(true); + expect( + shouldUseTelegramDmThreadSession({ + dmThreadId: 42, + directConfig: { requireTopic: true }, + }), + ).toBe(true); + expect( + shouldUseTelegramDmThreadSession({ + dmThreadId: 42, + topicConfig: { agentId: "support" }, + }), + ).toBe(true); + }); +}); + describe("buildTelegramRoutingTarget", () => { it.each([ { diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index a5f5378b11a..538f815bfd0 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -241,6 +241,24 @@ export function resolveTelegramThreadSpec(params: { }; } +export function shouldUseTelegramDmThreadSession(params: { + dmThreadId?: number; + directConfig?: TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; +}): boolean { + if (params.dmThreadId == null) { + return false; + } + const explicitPolicy = params.directConfig?.threadReplies; + if (explicitPolicy === "off") { + return false; + } + if (explicitPolicy === "inbound" || explicitPolicy === "always") { + return true; + } + return params.directConfig?.requireTopic === true || params.topicConfig !== undefined; +} + /** * Build thread params for Telegram API calls (messages, media). * diff --git a/extensions/telegram/src/config-schema.test.ts b/extensions/telegram/src/config-schema.test.ts index 37474455704..36a4b426d79 100644 --- a/extensions/telegram/src/config-schema.test.ts +++ b/extensions/telegram/src/config-schema.test.ts @@ -208,6 +208,23 @@ describe("telegram topic agentId schema", () => { expect(res.data.direct?.["123456789"]?.topics?.["99"]?.agentId).toBe("support"); }); + it("accepts DM threadReplies overrides", () => { + const res = TelegramConfigSchema.safeParse({ + direct: { + "123456789": { + threadReplies: "inbound", + }, + }, + }); + + expect(res.success).toBe(true); + if (!res.success) { + console.error(res.error.format()); + return; + } + expect(res.data.direct?.["123456789"]?.threadReplies).toBe("inbound"); + }); + it("accepts empty config without agentId", () => { const res = TelegramConfigSchema.safeParse({ groups: { diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index dd69f297f12..edd9c696532 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -281,6 +281,8 @@ export type AutoTopicLabelConfig = export type TelegramDirectConfig = { /** Per-DM override for DM message policy (open|disabled|allowlist). */ dmPolicy?: DmPolicy; + /** Controls whether Telegram DM message_thread_id values split sessions. Default: off unless topic config requires it. */ + threadReplies?: "off" | "inbound" | "always"; /** Optional tool policy overrides for this DM. */ tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index f878a170ce8..5b399948d0d 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -162,6 +162,7 @@ const AutoTopicLabelSchema = z export const TelegramDirectSchema = z .object({ dmPolicy: DmPolicySchema.optional(), + threadReplies: z.enum(["off", "inbound", "always"]).optional(), tools: ToolPolicySchema, toolsBySender: ToolPolicyBySenderSchema, skills: z.array(z.string()).optional(),