mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 11:00:44 +00:00
fix(telegram): add local chat context windows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user