fix(telegram): add local chat context windows

This commit is contained in:
Ayaan Zaidi
2026-05-09 09:56:37 +05:30
parent 40fd42206f
commit 4cdf19eabe
10 changed files with 369 additions and 2 deletions

View File

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

View File

@@ -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 = (

View File

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

View File

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

View File

@@ -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<string, unknown>, msg: string) => void;
};
@@ -72,6 +77,7 @@ export type BuildTelegramMessageContextParams = {
allMedia: TelegramMediaRef[];
replyMedia?: TelegramMediaRef[];
replyChain?: TelegramReplyChainEntry[];
promptContext?: TelegramPromptContextEntry[];
storeAllowFrom: string[];
options?: TelegramMessageContextOptions;
bot: Bot;

View File

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

View File

@@ -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<void>;
logger: ReturnType<typeof getChildLogger>;
};

View File

@@ -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<string, unknown>) => Promise<void>;
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();

View File

@@ -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"]);
});
});

View File

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