mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:11:34 +00:00
fix(telegram): wire topic mutation policy
This commit is contained in:
@@ -149,6 +149,10 @@ export function createTelegramDraftController(params: {
|
||||
chatId: params.chatId,
|
||||
message,
|
||||
messageId: message.message_id,
|
||||
...(params.threadSpec.id !== undefined
|
||||
? { messageThreadId: params.threadSpec.id }
|
||||
: {}),
|
||||
successfulSendThread: params.threadSpec,
|
||||
});
|
||||
},
|
||||
log: logVerbose,
|
||||
|
||||
@@ -62,7 +62,7 @@ export {
|
||||
resolveTelegramPrimaryMedia,
|
||||
};
|
||||
|
||||
const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
||||
export const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
||||
const TELEGRAM_FORUM_FLAG_CACHE_MAX_CHATS = 1024;
|
||||
const TELEGRAM_FORUM_FLAG_CACHE_TTL_MS = 10 * 60_000;
|
||||
const telegramForumFlagByChatId = new Map<string, { expiresAtMs: number; isForum: boolean }>();
|
||||
|
||||
@@ -23,6 +23,15 @@ describe("telegram actions contract", () => {
|
||||
],
|
||||
});
|
||||
|
||||
it("exposes message resource aliases through the registered adapter", () => {
|
||||
for (const action of ["react", "edit", "delete"] as const) {
|
||||
expect(telegramPlugin.actions?.messageActionTargetAliases?.[action]).toEqual({
|
||||
aliases: ["messageId"],
|
||||
deliveryTargetAliases: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
richMessages: undefined as boolean | undefined,
|
||||
|
||||
@@ -267,6 +267,7 @@ const telegramMessageAdapter = createChannelMessageAdapterFromOutbound<OpenClawC
|
||||
});
|
||||
|
||||
const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
messageActionTargetAliases: telegramMessageActionsImpl.messageActionTargetAliases,
|
||||
resolveExecutionMode: (ctx) =>
|
||||
getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.resolveExecutionMode?.(ctx) ??
|
||||
telegramMessageActionsImpl.resolveExecutionMode?.(ctx) ??
|
||||
|
||||
@@ -119,6 +119,7 @@ describe("recordOutboundMessageForPromptContext", () => {
|
||||
} as const;
|
||||
const callerOnlyThread = await recordAndRead({
|
||||
...common,
|
||||
successfulSendThread: { id: 77, scope: "forum" },
|
||||
message: {
|
||||
chat: { id: -1001, type: "supergroup", title: "QA" },
|
||||
date: 1_736_380_700,
|
||||
@@ -144,6 +145,44 @@ describe("recordOutboundMessageForPromptContext", () => {
|
||||
expect(hasProviderObservedTelegramThreadBinding(providerThread, 77)).toBe(true);
|
||||
});
|
||||
|
||||
it("binds a successful General-topic response from trusted send context", async () => {
|
||||
const cached = await recordAndRead({
|
||||
account: { accountId: "default", name: "Configured Agent" },
|
||||
chatId: -1001,
|
||||
message: {
|
||||
chat: { id: -1001, type: "supergroup", title: "QA" },
|
||||
date: 1_736_380_700,
|
||||
from: { id: 999, is_bot: true, first_name: "OpenClaw" },
|
||||
message_id: 702,
|
||||
text: "Bot replied in General",
|
||||
},
|
||||
messageId: 702,
|
||||
messageThreadId: 1,
|
||||
successfulSendThread: { id: 1, scope: "forum" },
|
||||
});
|
||||
|
||||
expect(hasProviderObservedTelegramThreadBinding(cached, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not infer a General-topic binding for DM thread context", async () => {
|
||||
const cached = await recordAndRead({
|
||||
account: { accountId: "default", name: "Configured Agent" },
|
||||
chatId: 42,
|
||||
message: {
|
||||
chat: { id: 42, type: "private" },
|
||||
date: 1_736_380_700,
|
||||
from: { id: 999, is_bot: true, first_name: "OpenClaw" },
|
||||
message_id: 703,
|
||||
text: "Bot replied in a DM topic",
|
||||
},
|
||||
messageId: 703,
|
||||
messageThreadId: 1,
|
||||
successfulSendThread: { id: 1, scope: "dm" },
|
||||
});
|
||||
|
||||
expect(hasProviderObservedTelegramThreadBinding(cached, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the Telegram bot name when no configured name exists", async () => {
|
||||
const cached = await recordAndRead({
|
||||
account: { accountId: "default", name: "" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Message } from "grammy/types";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { TELEGRAM_GENERAL_TOPIC_ID, type TelegramThreadSpec } from "./bot/helpers.js";
|
||||
import { buildTelegramSelfSenderName } from "./group-history-window.js";
|
||||
import { createTelegramMessageCache, resolveTelegramMessageCacheScope } from "./message-cache.js";
|
||||
import type { TelegramPromptContextProjection } from "./prompt-context-projection.js";
|
||||
@@ -130,11 +131,25 @@ export async function recordOutboundMessageForPromptContext(params: {
|
||||
botUserId?: number;
|
||||
text?: string;
|
||||
messageThreadId?: number;
|
||||
/** Effective server-owned thread for the successful provider send. */
|
||||
successfulSendThread?: TelegramThreadSpec;
|
||||
promptContextTimestampMs?: number;
|
||||
promptContextProjection?: TelegramPromptContextProjection;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const cacheMessage = buildOutboundCacheMessage(params);
|
||||
const providerGeneralTopicId =
|
||||
params.message.message_thread_id === undefined &&
|
||||
params.message.chat?.type === "supergroup" &&
|
||||
params.successfulSendThread?.scope === "forum" &&
|
||||
params.successfulSendThread.id === TELEGRAM_GENERAL_TOPIC_ID
|
||||
? TELEGRAM_GENERAL_TOPIC_ID
|
||||
: undefined;
|
||||
const providerObservedThreadId = params.message.message_thread_id ?? providerGeneralTopicId;
|
||||
const messageThreadId = params.messageThreadId ?? providerGeneralTopicId;
|
||||
const cacheMessage = buildOutboundCacheMessage({
|
||||
...params,
|
||||
...(messageThreadId !== undefined ? { messageThreadId } : {}),
|
||||
});
|
||||
const cache = createTelegramMessageCache({
|
||||
scope: resolveTelegramMessageCacheScope(resolveStorePath(params.cfg.session?.store)),
|
||||
});
|
||||
@@ -146,17 +161,15 @@ export async function recordOutboundMessageForPromptContext(params: {
|
||||
...(params.promptContextProjection
|
||||
? { promptContextProjection: params.promptContextProjection }
|
||||
: {}),
|
||||
...(params.message.message_thread_id !== undefined
|
||||
? { providerObservedThreadId: params.message.message_thread_id }
|
||||
: {}),
|
||||
...(params.messageThreadId !== undefined ? { threadId: params.messageThreadId } : {}),
|
||||
...(providerObservedThreadId !== undefined ? { providerObservedThreadId } : {}),
|
||||
...(messageThreadId !== undefined ? { threadId: messageThreadId } : {}),
|
||||
});
|
||||
const timestamp = resolveOutboundCacheMessageTimestamp(cacheMessage);
|
||||
outboundGroupHistoryRecorders.get(params.account.accountId)?.({
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
text: params.text ?? cacheMessage.text ?? cacheMessage.caption,
|
||||
...(params.messageThreadId !== undefined ? { messageThreadId: params.messageThreadId } : {}),
|
||||
...(messageThreadId !== undefined ? { messageThreadId } : {}),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { markdownToTelegramHtml, telegramHtmlToPlainTextFallback } from "./forma
|
||||
import {
|
||||
buildTelegramConversationContext,
|
||||
createTelegramMessageCache,
|
||||
hasProviderObservedTelegramThreadBinding,
|
||||
resolveTelegramMessageCacheScope,
|
||||
} from "./message-cache.js";
|
||||
import { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js";
|
||||
@@ -982,6 +983,35 @@ describe("sendMessageTelegram", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("records a successful General-topic send when the response omits the thread id", async () => {
|
||||
const storePath = `/tmp/openclaw-telegram-general-context-${process.pid}-${Date.now()}.json`;
|
||||
const chatId = "-1003966283270";
|
||||
botApi.sendMessage.mockResolvedValueOnce({
|
||||
message_id: 1498,
|
||||
date: 1_779_394_741,
|
||||
chat: { id: chatId, type: "supergroup", title: "QA forum" },
|
||||
from: { id: 42, is_bot: true, first_name: "OpenClaw" },
|
||||
text: "Reply in General",
|
||||
});
|
||||
|
||||
await sendMessageTelegram(`${chatId}:topic:1`, "Reply in General", {
|
||||
cfg: { session: { store: storePath } },
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(firstMockCall(botApi.sendMessage, "General-topic send")[2]).not.toHaveProperty(
|
||||
"message_thread_id",
|
||||
);
|
||||
const cached = await createTelegramMessageCache({
|
||||
scope: resolveTelegramMessageCacheScope(storePath),
|
||||
}).get({
|
||||
accountId: "default",
|
||||
chatId,
|
||||
messageId: "1498",
|
||||
});
|
||||
expect(hasProviderObservedTelegramThreadBinding(cached, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("records transcript projection metadata without replacing Telegram time", async () => {
|
||||
const storePath = `/tmp/openclaw-telegram-send-context-override-${process.pid}-${Date.now()}.json`;
|
||||
const cfg = { session: { store: storePath } };
|
||||
|
||||
@@ -840,6 +840,11 @@ async function sendMessageTelegramWithContext(
|
||||
verbose: opts.verbose,
|
||||
gatewayClientScopes: opts.gatewayClientScopes,
|
||||
});
|
||||
const threadSpec = resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
});
|
||||
const reportDelivery = async (
|
||||
messageId: string | number,
|
||||
deliveredChatId: string | number,
|
||||
@@ -865,6 +870,8 @@ async function sendMessageTelegramWithContext(
|
||||
account,
|
||||
...(botUserId !== undefined ? { botUserId } : {}),
|
||||
chatId,
|
||||
...(threadSpec?.id !== undefined ? { messageThreadId: threadSpec.id } : {}),
|
||||
...(threadSpec ? { successfulSendThread: threadSpec } : {}),
|
||||
...params,
|
||||
promptContextProjection: projection,
|
||||
});
|
||||
@@ -880,11 +887,6 @@ async function sendMessageTelegramWithContext(
|
||||
(typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024;
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||
|
||||
const threadSpec = resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
});
|
||||
const singleUseReplyTo =
|
||||
opts.replyToIdSource === "implicit" &&
|
||||
opts.replyToMode !== undefined &&
|
||||
@@ -1659,12 +1661,13 @@ async function sendLocationTelegramWithContext(
|
||||
verbose: opts.verbose,
|
||||
gatewayClientScopes: opts.gatewayClientScopes,
|
||||
});
|
||||
const threadSpec = resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
});
|
||||
const threadParams = buildTelegramThreadReplyParams({
|
||||
thread: resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
}),
|
||||
thread: threadSpec,
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
replyQuoteText: opts.quoteText,
|
||||
useReplyIdAsQuoteSource: true,
|
||||
@@ -1726,6 +1729,8 @@ async function sendLocationTelegramWithContext(
|
||||
message: result,
|
||||
messageId,
|
||||
text: formatLocationText(location),
|
||||
...(threadSpec?.id !== undefined ? { messageThreadId: threadSpec.id } : {}),
|
||||
...(threadSpec ? { successfulSendThread: threadSpec } : {}),
|
||||
...(acceptedParams?.message_thread_id !== undefined
|
||||
? { messageThreadId: acceptedParams.message_thread_id }
|
||||
: {}),
|
||||
|
||||
@@ -24,6 +24,13 @@ const BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES: ReadonlySet<string> = new Set([
|
||||
"slack",
|
||||
]);
|
||||
|
||||
// Telegram owns exact topic/account binding for message mutations only. Other
|
||||
// Telegram reads retain the host gate, including targetless sticker cache reads.
|
||||
const BUNDLED_PROVIDER_READ_GATE_ACTIONS: ReadonlyMap<
|
||||
string,
|
||||
ReadonlySet<ChannelMessageActionName>
|
||||
> = new Map([["telegram", new Set<ChannelMessageActionName>(["react", "edit", "delete"])]]);
|
||||
|
||||
declare const serverOwnedConversationReadOrigin: unique symbol;
|
||||
|
||||
type ServerOwnedConversationReadOrigin = ReturnType<
|
||||
@@ -137,12 +144,14 @@ type MessageActionReadEnforcement =
|
||||
};
|
||||
|
||||
function resolveMessageActionReadEnforcement(params: {
|
||||
action: ChannelMessageActionName;
|
||||
channel: string;
|
||||
pluginOrigin: string | undefined;
|
||||
}): MessageActionReadEnforcement {
|
||||
if (
|
||||
params.pluginOrigin === "bundled" &&
|
||||
BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES.has(params.channel)
|
||||
(BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES.has(params.channel) ||
|
||||
BUNDLED_PROVIDER_READ_GATE_ACTIONS.get(params.channel)?.has(params.action) === true)
|
||||
) {
|
||||
return { kind: "provider-owned" };
|
||||
}
|
||||
@@ -551,6 +560,7 @@ export async function dispatchChannelMessageAction(
|
||||
origin,
|
||||
actionPolicy,
|
||||
enforcement: resolveMessageActionReadEnforcement({
|
||||
action: actionContext.action,
|
||||
channel: actionContext.channel,
|
||||
pluginOrigin: registration.origin,
|
||||
}),
|
||||
|
||||
@@ -530,6 +530,52 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => {
|
||||
expect(handleAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["react", "edit", "delete"] as const)(
|
||||
"delegates Telegram %s topic binding to the bundled provider",
|
||||
async (action) => {
|
||||
setReadPlugin({ channel: "telegram", origin: "bundled" });
|
||||
|
||||
await dispatchChannelMessageAction({
|
||||
channel: "telegram",
|
||||
action,
|
||||
cfg: {} as OpenClawConfig,
|
||||
params: { chatId: "-1001" },
|
||||
accountId: "default",
|
||||
requesterAccountId: "default",
|
||||
conversationReadOrigin: "delegated",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "telegram:-1001:topic:77",
|
||||
currentThreadTs: "77",
|
||||
},
|
||||
});
|
||||
|
||||
expect(handleAction).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not grant Telegram mutation enforcement to an external override", async () => {
|
||||
setReadPlugin({ channel: "telegram", origin: "workspace" });
|
||||
|
||||
await expect(
|
||||
dispatchChannelMessageAction({
|
||||
channel: "telegram",
|
||||
action: "react",
|
||||
cfg: {} as OpenClawConfig,
|
||||
params: { chatId: "-1001" },
|
||||
accountId: "default",
|
||||
requesterAccountId: "default",
|
||||
conversationReadOrigin: "delegated",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "telegram:-1001:topic:77",
|
||||
currentThreadTs: "77",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("requires the exact current conversation and account");
|
||||
expect(handleAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses bundled provider target normalization for equivalent exact-current forms", async () => {
|
||||
const normalizeTarget = vi.fn((raw: string) => {
|
||||
const room = raw
|
||||
|
||||
Reference in New Issue
Block a user