diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd2f3d9f13..8353c399602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -198,6 +198,7 @@ Docs: https://docs.openclaw.ai - QA-lab/scenarios: raise the `approval-turn-tool-followthrough` per-turn fallback timeouts from 20s/30s to 60s so cold mock-gateway parity runs do not flake on the approval-turn chain. Carries forward the timeout-bump portion of #74290. Thanks @100yenadmin. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup. +- Telegram/groups: include the recent local chat window and nearby reply-target window as generic inbound context so stale reply ancestry does not overshadow the live group conversation. - Plugins/Nix: allow externally configured plugin roots under `/nix/store` to load in `OPENCLAW_NIX_MODE=1` while keeping normal external plugin hardlink rejection unchanged. Thanks @joshp123. - Nextcloud Talk: include the required bot `response` feature in setup, explain missing `--feature response` on rejected sends, and surface missing response capability in doctor/status checks. Fixes #78935. (#79657) Thanks @joshavant. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 9d8ab7cab40..6490db0c699 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -60,7 +60,10 @@ import { resolveInboundMediaFileId, } from "./bot-handlers.media.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; -import type { TelegramMessageContextOptions } from "./bot-message-context.types.js"; +import type { + TelegramMessageContextOptions, + TelegramPromptContextEntry, +} from "./bot-message-context.types.js"; import { parseTelegramNativeCommandCallbackData, RegisterTelegramHandlerParams, @@ -547,6 +550,80 @@ export const registerTelegramHandlers = ({ }; }; + const toPromptContextMessage = ( + node: TelegramCachedMessageNode, + flags?: { replyTarget?: boolean }, + ) => ({ + message_id: node.messageId, + thread_id: node.threadId, + sender: node.sender, + sender_id: node.senderId, + sender_username: node.senderUsername, + timestamp_ms: node.timestamp, + body: node.body, + media_type: node.mediaType, + media_ref: node.mediaRef, + reply_to_id: node.replyToId, + is_reply_target: flags?.replyTarget === true ? true : undefined, + }); + + const buildPromptContextForMessage = ( + msg: Message, + replyChainNodes: TelegramCachedMessageNode[], + ): TelegramPromptContextEntry[] => { + const messageId = typeof msg.message_id === "number" ? String(msg.message_id) : undefined; + const currentNode = messageCache.get({ + accountId, + chatId: msg.chat.id, + messageId, + }); + const threadId = currentNode?.threadId ? Number(currentNode.threadId) : undefined; + const currentWindow = messageCache.recentBefore({ + accountId, + chatId: msg.chat.id, + messageId, + ...(Number.isFinite(threadId) ? { threadId } : {}), + limit: 10, + }); + const replyTargetId = replyChainNodes[0]?.messageId; + const replyTargetWindow = messageCache.around({ + accountId, + chatId: msg.chat.id, + messageId: replyTargetId, + ...(Number.isFinite(threadId) ? { threadId } : {}), + before: 2, + after: 2, + }); + const entries: TelegramPromptContextEntry[] = []; + if (currentWindow.length > 0) { + entries.push({ + label: "Current local chat window", + source: "telegram", + type: "chat_window", + payload: { + order: "chronological", + relation: "before_current_message", + messages: currentWindow.map((node) => toPromptContextMessage(node)), + }, + }); + } + if (replyTargetWindow.length > 0) { + entries.push({ + label: "Nearby reply target window", + source: "telegram", + type: "chat_window", + payload: { + order: "chronological", + relation: "around_reply_target", + messages: replyTargetWindow.map((node) => + toPromptContextMessage(node, { replyTarget: node.messageId === replyTargetId }), + ), + }, + }); + } + return entries; + }; + const resolveReplyMediaForChain = async ( ctx: TelegramContext, chain: TelegramCachedMessageNode[], @@ -598,7 +675,16 @@ export const registerTelegramHandlers = ({ ) => { const replyChainNodes = buildReplyChainForMessage(msg); const { replyMedia, replyChain } = await resolveReplyMediaForChain(ctx, replyChainNodes); - await processMessage(ctx, allMedia, storeAllowFrom, options, replyMedia, replyChain); + const promptContext = buildPromptContextForMessage(msg, replyChainNodes); + await processMessage( + ctx, + allMedia, + storeAllowFrom, + options, + replyMedia, + replyChain, + promptContext, + ); }; const isAllowlistAuthorized = ( diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index e7d7da22046..759ac040258 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -26,6 +26,7 @@ import type { TelegramMediaRef, TelegramMessageContextOptions, TelegramMessageContextSessionRuntimeOverrides, + TelegramPromptContextEntry, } from "./bot-message-context.types.js"; import { buildGroupLabel, @@ -155,6 +156,7 @@ export async function buildTelegramInboundContextPayload(params: { allMedia: TelegramMediaRef[]; replyMedia: TelegramMediaRef[]; replyChain: TelegramReplyChainEntry[]; + promptContext: TelegramPromptContextEntry[]; isGroup: boolean; isForum: boolean; chatId: number | string; @@ -202,6 +204,7 @@ export async function buildTelegramInboundContextPayload(params: { allMedia, replyMedia, replyChain, + promptContext, isGroup, isForum, chatId, @@ -453,6 +456,7 @@ export async function buildTelegramInboundContextPayload(params: { ForwardedDate: visibleForwardOrigin?.date ? visibleForwardOrigin.date * 1000 : undefined, Timestamp: msg.date ? msg.date * 1000 : undefined, WasMentioned: isGroup ? effectiveWasMentioned : undefined, + UntrustedStructuredContext: promptContext.length > 0 ? promptContext : undefined, MediaPath: contextMedia.length > 0 ? contextMedia[0]?.path : undefined, MediaType: contextMedia.length > 0 ? contextMedia[0]?.contentType : undefined, MediaUrl: contextMedia.length > 0 ? contextMedia[0]?.path : undefined, diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index eb4bef9ba8f..fcd1a991d41 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -116,6 +116,7 @@ export const buildTelegramMessageContext = async ({ allMedia, replyMedia = [], replyChain = [], + promptContext = [], storeAllowFrom, options, bot, @@ -580,6 +581,7 @@ export const buildTelegramMessageContext = async ({ allMedia, replyMedia, replyChain, + promptContext, isGroup, isForum, chatId, diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index cc21f06d35a..be75c38fa80 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -7,6 +7,7 @@ import type { TelegramTopicConfig, } from "openclaw/plugin-sdk/config-types"; import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; +import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; import type { TelegramReplyChainEntry } from "./message-cache.js"; @@ -24,6 +25,10 @@ export type TelegramMessageContextOptions = { ingressBuffer?: "inbound-debounce" | "text-fragment"; }; +export type TelegramPromptContextEntry = NonNullable< + MsgContext["UntrustedStructuredContext"] +>[number]; + export type TelegramLogger = { info: (obj: Record, msg: string) => void; }; @@ -72,6 +77,7 @@ export type BuildTelegramMessageContextParams = { allMedia: TelegramMediaRef[]; replyMedia?: TelegramMediaRef[]; replyChain?: TelegramReplyChainEntry[]; + promptContext?: TelegramPromptContextEntry[]; storeAllowFrom: string[]; options?: TelegramMessageContextOptions; bot: Bot; diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index 544ab337106..eb10b383de9 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -14,6 +14,7 @@ import { type TelegramMediaRef, } from "./bot-message-context.js"; import type { TelegramMessageContextOptions } from "./bot-message-context.types.js"; +import type { TelegramPromptContextEntry } from "./bot-message-context.types.js"; import { dispatchTelegramMessage } from "./bot-message-dispatch.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { buildTelegramThreadParams } from "./bot/helpers.js"; @@ -79,6 +80,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep options?: TelegramMessageContextOptions, replyMedia?: TelegramMediaRef[], replyChain?: TelegramReplyChainEntry[], + promptContext?: TelegramPromptContextEntry[], ) => { const ingressReceivedAtMs = typeof options?.receivedAtMs === "number" && Number.isFinite(options.receivedAtMs) @@ -92,6 +94,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep allMedia, replyMedia, replyChain, + promptContext, storeAllowFrom, options, bot, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index e3c856ea682..effc022b03f 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -406,6 +406,7 @@ export type RegisterTelegramHandlerParams = { options?: TelegramMessageContextOptions, replyMedia?: TelegramMediaRef[], replyChain?: import("./message-cache.js").TelegramReplyChainEntry[], + promptContext?: import("./bot-message-context.types.js").TelegramPromptContextEntry[], ) => Promise; logger: ReturnType; }; diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index a772f3d66b2..3ecc835bc93 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1537,6 +1537,119 @@ describe("createTelegramBot", () => { expect(payload.SenderUsername).toBe("ada"); }); + it("adds live chat and reply-target windows for stale group replies", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + const baseCtx = { + me: { id: 999, username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }; + + await handler({ + ...baseCtx, + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "Earlier deployment answer", + date: 1736380200, + message_id: 100, + from: { id: 777, is_bot: true, first_name: "Assistant" }, + }, + }); + await handler({ + ...baseCtx, + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "Lunch after standup?", + date: 1736380800, + message_id: 200, + from: { id: 201, is_bot: false, first_name: "Sam" }, + }, + }); + await handler({ + ...baseCtx, + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "After the incident review.", + date: 1736380860, + message_id: 201, + from: { id: 202, is_bot: false, first_name: "Riley" }, + }, + }); + + replySpy.mockClear(); + await handler({ + ...baseCtx, + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "@openclaw_bot thoughts?", + date: 1736380920, + message_id: 202, + from: { id: 203, is_bot: false, first_name: "Avery" }, + reply_to_message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "Earlier deployment answer", + date: 1736380200, + message_id: 100, + from: { id: 777, is_bot: true, first_name: "Assistant" }, + }, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.UntrustedStructuredContext).toEqual([ + expect.objectContaining({ + label: "Current local chat window", + payload: expect.objectContaining({ + relation: "before_current_message", + messages: expect.arrayContaining([ + expect.objectContaining({ + message_id: "200", + sender: "Sam", + body: "Lunch after standup?", + }), + expect.objectContaining({ + message_id: "201", + sender: "Riley", + body: "After the incident review.", + }), + ]), + }), + }), + expect.objectContaining({ + label: "Nearby reply target window", + payload: expect.objectContaining({ + relation: "around_reply_target", + messages: expect.arrayContaining([ + expect.objectContaining({ + message_id: "100", + sender: "Assistant", + body: "Earlier deployment answer", + is_reply_target: true, + }), + ]), + }), + }), + ]); + }); + it("uses quote text when a Telegram partial reply is received", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); diff --git a/extensions/telegram/src/message-cache.test.ts b/extensions/telegram/src/message-cache.test.ts index 8efe4c217e9..13341b1ccfe 100644 --- a/extensions/telegram/src/message-cache.test.ts +++ b/extensions/telegram/src/message-cache.test.ts @@ -219,4 +219,77 @@ describe("telegram message cache", () => { await rm(persistedPath, { force: true }); } }); + + it("returns recent chat messages before the current message", () => { + const cache = createTelegramMessageCache(); + for (const id of [41, 42, 43, 44]) { + cache.record({ + accountId: "default", + chatId: 7, + threadId: 100, + msg: { + chat: { id: 7, type: "supergroup", title: "Ops" }, + message_thread_id: 100, + message_id: id, + date: 1736380700 + id, + text: `live message ${id}`, + from: { id, is_bot: false, first_name: `User ${id}` }, + } as Message, + }); + } + cache.record({ + accountId: "default", + chatId: 7, + threadId: 200, + msg: { + chat: { id: 7, type: "supergroup", title: "Ops" }, + message_thread_id: 200, + message_id: 142, + date: 1736380743, + text: "different topic", + from: { id: 99, is_bot: false, first_name: "Other" }, + } as Message, + }); + + expect( + cache + .recentBefore({ + accountId: "default", + chatId: 7, + threadId: 100, + messageId: "44", + limit: 2, + }) + .map((entry) => entry.messageId), + ).toEqual(["42", "43"]); + }); + + it("returns nearby messages around a stale reply target", () => { + const cache = createTelegramMessageCache(); + for (const id of [100, 101, 102, 200, 201]) { + cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "group", title: "Ops" }, + message_id: id, + date: 1736380700 + id, + text: `message ${id}`, + from: { id, is_bot: false, first_name: `User ${id}` }, + } as Message, + }); + } + + expect( + cache + .around({ + accountId: "default", + chatId: 7, + messageId: "101", + before: 1, + after: 1, + }) + .map((entry) => entry.messageId), + ).toEqual(["100", "101", "102"]); + }); }); diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts index a0ff1598602..6f15cdb3ad1 100644 --- a/extensions/telegram/src/message-cache.ts +++ b/extensions/telegram/src/message-cache.ts @@ -30,6 +30,21 @@ export type TelegramMessageCache = { chatId: string | number; messageId?: string; }) => TelegramCachedMessageNode | null; + recentBefore: (params: { + accountId: string; + chatId: string | number; + messageId?: string; + threadId?: number; + limit: number; + }) => TelegramCachedMessageNode[]; + around: (params: { + accountId: string; + chatId: string | number; + messageId?: string; + threadId?: number; + before: number; + after: number; + }) => TelegramCachedMessageNode[]; }; type MessageWithExternalReply = Message & { external_reply?: Message }; @@ -51,6 +66,10 @@ function telegramMessageCacheKey(params: { return `${params.accountId}:${params.chatId}:${params.messageId}`; } +function telegramMessageCacheKeyPrefix(params: { accountId: string; chatId: string | number }) { + return `${params.accountId}:${params.chatId}:`; +} + export function resolveTelegramMessageCachePath(storePath: string): string { return `${storePath}.telegram-messages.json`; } @@ -284,6 +303,24 @@ export function createTelegramMessageCache(params?: { return entry; }; + const listChatMessages = (params: { + accountId: string; + chatId: string | number; + threadId?: number; + }) => { + const prefix = telegramMessageCacheKeyPrefix(params); + const threadId = params.threadId != null ? String(params.threadId) : undefined; + return Array.from(messages, ([key, node]) => ({ key, node })) + .filter(({ key, node }) => { + if (!key.startsWith(prefix)) { + return false; + } + return threadId === undefined || node.threadId === threadId; + }) + .map(({ node }) => node) + .sort(compareCachedMessageNodes); + }; + return { record: ({ accountId, chatId, msg, threadId }) => { const entry = normalizeMessageNode(msg, { threadId }); @@ -312,9 +349,50 @@ export function createTelegramMessageCache(params?: { return entry; }, get, + recentBefore: ({ accountId, chatId, messageId, threadId, limit }) => { + if (!messageId || limit <= 0) { + return []; + } + const targetId = Number(messageId); + if (!Number.isFinite(targetId)) { + return []; + } + return listChatMessages({ accountId, chatId, threadId }) + .filter((entry) => { + const entryId = Number(entry.messageId); + return Number.isFinite(entryId) && entryId < targetId; + }) + .slice(-limit); + }, + around: ({ accountId, chatId, messageId, threadId, before, after }) => { + if (!messageId) { + return []; + } + const entries = listChatMessages({ accountId, chatId, threadId }); + const targetIndex = entries.findIndex((entry) => entry.messageId === messageId); + if (targetIndex === -1) { + return []; + } + return entries.slice( + Math.max(0, targetIndex - Math.max(0, before)), + targetIndex + Math.max(0, after) + 1, + ); + }, }; } +function compareCachedMessageNodes( + left: TelegramCachedMessageNode, + right: TelegramCachedMessageNode, +) { + const leftId = Number(left.messageId); + const rightId = Number(right.messageId); + if (Number.isFinite(leftId) && Number.isFinite(rightId)) { + return leftId - rightId; + } + return (left.messageId ?? "").localeCompare(right.messageId ?? ""); +} + export function buildTelegramReplyChain(params: { cache: TelegramMessageCache; accountId: string;