fix(telegram): wire topic mutation policy

This commit is contained in:
joshavant
2026-07-22 14:43:23 -05:00
committed by Josh Avant
parent 0a6b3ef7df
commit ea66298621
10 changed files with 175 additions and 18 deletions

View File

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

View File

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

View File

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

View File

@@ -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) ??

View File

@@ -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: "" },

View File

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

View File

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

View File

@@ -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 }
: {}),

View File

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

View File

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