mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 07:22:03 +08:00
Compare commits
1 Commits
v2026.6.9
...
codex/tele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dedb8e40 |
@@ -31,7 +31,11 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
||||
import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "../button-types.js";
|
||||
import { splitTelegramCaption } from "../caption.js";
|
||||
import { renderTelegramHtmlText } from "../format.js";
|
||||
import {
|
||||
renderTelegramHtmlText,
|
||||
splitTelegramHtmlChunks,
|
||||
telegramHtmlToPlainTextFallback,
|
||||
} from "../format.js";
|
||||
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
|
||||
import {
|
||||
splitTelegramRichMessageTextChunks,
|
||||
@@ -56,6 +60,7 @@ import {
|
||||
|
||||
const VOICE_FORBIDDEN_MARKER = "VOICE_MESSAGES_FORBIDDEN";
|
||||
const CAPTION_TOO_LONG_RE = /caption is too long/i;
|
||||
const TELEGRAM_LEGACY_TEXT_LIMIT = 4096;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
@@ -82,8 +87,19 @@ function buildChunkTextResolver(params: {
|
||||
chunkMode: ChunkMode;
|
||||
tableMode?: MarkdownTableMode;
|
||||
skipEntityDetection?: boolean;
|
||||
chatType: "direct" | "group";
|
||||
}): ChunkTextFn {
|
||||
return (markdown: string) => {
|
||||
if (params.chatType === "group") {
|
||||
return splitTelegramHtmlChunks(
|
||||
renderTelegramHtmlText(markdown, { tableMode: params.tableMode }),
|
||||
Math.min(params.textLimit, TELEGRAM_LEGACY_TEXT_LIMIT),
|
||||
).map((text) => ({
|
||||
text,
|
||||
textMode: "html",
|
||||
plainText: telegramHtmlToPlainTextFallback(text),
|
||||
}));
|
||||
}
|
||||
return splitTelegramRichMessageTextChunks({
|
||||
text: markdown,
|
||||
textLimit: params.textLimit,
|
||||
@@ -95,6 +111,23 @@ function buildChunkTextResolver(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReplyChatType(params: {
|
||||
chatId: string;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
isGroup?: boolean;
|
||||
}) {
|
||||
if (params.isGroup === true) {
|
||||
return "group";
|
||||
}
|
||||
if (params.thread?.scope === "dm") {
|
||||
return "direct";
|
||||
}
|
||||
if (params.thread) {
|
||||
return "group";
|
||||
}
|
||||
return params.chatId.trim().startsWith("-") ? "group" : "direct";
|
||||
}
|
||||
|
||||
function markDelivered(progress: DeliveryProgress): void {
|
||||
progress.hasDelivered = true;
|
||||
progress.deliveredCount += 1;
|
||||
@@ -161,6 +194,7 @@ async function deliverTextReply(params: {
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chatType?: "direct" | "group";
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
@@ -193,6 +227,7 @@ async function deliverTextReply(params: {
|
||||
tableMode: params.tableMode,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
chatType: params.chatType,
|
||||
},
|
||||
);
|
||||
if (firstDeliveredMessageId == null) {
|
||||
@@ -214,6 +249,7 @@ async function sendPendingFollowUpText(params: {
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chatType?: "direct" | "group";
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
@@ -235,6 +271,7 @@ async function sendPendingFollowUpText(params: {
|
||||
tableMode: params.tableMode,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
chatType: params.chatType,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -280,6 +317,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
tableMode?: MarkdownTableMode;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
chatType?: "direct" | "group";
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
const chunks = filterEmptyTelegramTextChunks(opts.chunkText(opts.text));
|
||||
@@ -300,6 +338,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
tableMode: opts.tableMode,
|
||||
silent: opts.silent,
|
||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||
chatType: opts.chatType,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = messageId;
|
||||
@@ -334,6 +373,7 @@ async function deliverMediaReply(params: {
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
chatType?: "direct" | "group";
|
||||
}): Promise<{ firstDeliveredMessageId?: number; visibleFallbackText?: string }> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
let visibleFallbackText: string | undefined;
|
||||
@@ -484,6 +524,7 @@ async function deliverMediaReply(params: {
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
chatType: params.chatType,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = fallbackMessageId;
|
||||
@@ -514,6 +555,7 @@ async function deliverMediaReply(params: {
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
chatType: params.chatType,
|
||||
});
|
||||
visibleFallbackText = fallbackText;
|
||||
}
|
||||
@@ -563,6 +605,7 @@ async function deliverMediaReply(params: {
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
tableMode: params.tableMode,
|
||||
chatType: params.chatType,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
@@ -722,6 +765,11 @@ export async function deliverReplies(params: {
|
||||
const transcriptMirror = params.transcriptMirror;
|
||||
const deliveredContents: Array<{ text: string; mediaUrls: string[] }> = [];
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const replyChatType = resolveReplyChatType({
|
||||
chatId: params.chatId,
|
||||
thread: params.thread,
|
||||
isGroup: params.mirrorIsGroup,
|
||||
});
|
||||
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
|
||||
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
|
||||
const chunkText = buildChunkTextResolver({
|
||||
@@ -729,6 +777,7 @@ export async function deliverReplies(params: {
|
||||
chunkMode: params.chunkMode ?? "length",
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.linkPreview === false,
|
||||
chatType: replyChatType,
|
||||
});
|
||||
const candidateReplies: ReplyPayload[] = [];
|
||||
for (const reply of params.replies) {
|
||||
@@ -832,6 +881,9 @@ export async function deliverReplies(params: {
|
||||
presentation,
|
||||
interactive,
|
||||
}),
|
||||
{
|
||||
chatType: replyChatType,
|
||||
},
|
||||
);
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
if (mediaList.length === 0) {
|
||||
@@ -850,6 +902,7 @@ export async function deliverReplies(params: {
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
tableMode: params.tableMode,
|
||||
chatType: replyChatType,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
@@ -870,6 +923,7 @@ export async function deliverReplies(params: {
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
chatType: replyChatType,
|
||||
replyQuoteMessageId: replyQuote.messageId,
|
||||
replyQuoteText: replyQuote.text,
|
||||
replyQuotePosition: replyQuote.position,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { withTelegramApiErrorLogging } from "../api-logging.js";
|
||||
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "../format.js";
|
||||
import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js";
|
||||
import {
|
||||
buildTelegramSendParams,
|
||||
@@ -23,6 +24,7 @@ import type { TelegramThreadSpec } from "./helpers.js";
|
||||
export { buildTelegramSendParams } from "../reply-parameters.js";
|
||||
|
||||
const QUOTE_PARAM_RE = /\bquote not found\b|\bQUOTE_TEXT_INVALID\b|\bquote text invalid\b/i;
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
@@ -33,6 +35,13 @@ function isTelegramQuoteParamError(err: unknown): boolean {
|
||||
return QUOTE_PARAM_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isTelegramHtmlParseError(err: unknown): boolean {
|
||||
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
|
||||
return PARSE_ERR_RE.test(err.description);
|
||||
}
|
||||
return PARSE_ERR_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function createTelegramDeliverySendRetry() {
|
||||
return createTelegramRetryRunner({
|
||||
shouldRetry: (err) => isSafeToRetrySendError(err) || isTelegramRateLimitError(err),
|
||||
@@ -104,6 +113,7 @@ export async function sendTelegramText(
|
||||
tableMode?: MarkdownTableMode;
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
chatType?: "direct" | "group";
|
||||
},
|
||||
): Promise<number> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
@@ -117,15 +127,70 @@ export async function sendTelegramText(
|
||||
});
|
||||
const richParams = toTelegramRichMessageContextParams(baseParams);
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
const normalizedChatId = chatId.trim();
|
||||
const shouldUseRichText =
|
||||
opts?.chatType !== "group" &&
|
||||
(opts?.thread?.scope === "dm" || (!opts?.thread && !normalizedChatId.startsWith("-")));
|
||||
|
||||
if (!text.trim()) {
|
||||
throw new Error("Message must be non-empty for Telegram sends");
|
||||
}
|
||||
if (!shouldUseRichText) {
|
||||
const htmlText = renderTelegramHtmlText(text, {
|
||||
textMode,
|
||||
tableMode: opts?.tableMode,
|
||||
});
|
||||
const htmlParams: Record<string, unknown> = {
|
||||
parse_mode: "HTML",
|
||||
...(opts?.linkPreview === false ? { link_preview_options: { is_disabled: true } } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...baseParams,
|
||||
};
|
||||
const plainParams = { ...htmlParams };
|
||||
delete plainParams.parse_mode;
|
||||
const sendLegacy = async (
|
||||
operation: string,
|
||||
body: string,
|
||||
requestParams: Record<string, unknown>,
|
||||
) =>
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation,
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams,
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, body, {
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
let res: Awaited<ReturnType<typeof sendLegacy>>;
|
||||
try {
|
||||
res = await sendLegacy("sendMessage", htmlText, htmlParams);
|
||||
} catch (err) {
|
||||
if (!isTelegramHtmlParseError(err)) {
|
||||
throw err;
|
||||
}
|
||||
runtime.log?.(
|
||||
`telegram sendMessage failed with HTML parse error; retrying as plain text: ${formatErrorMessage(
|
||||
err,
|
||||
)}`,
|
||||
);
|
||||
res = await sendLegacy(
|
||||
"sendMessage (plain)",
|
||||
telegramHtmlToPlainTextFallback(htmlText),
|
||||
plainParams,
|
||||
);
|
||||
}
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
return res.message_id;
|
||||
}
|
||||
|
||||
const richMessage = buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts?.linkPreview === false,
|
||||
tableMode: opts?.tableMode,
|
||||
});
|
||||
const richRawApi = getTelegramRichRawApi(bot.api);
|
||||
|
||||
if (!text.trim()) {
|
||||
throw new Error("Message must be non-empty for Telegram sends");
|
||||
}
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendRichMessage",
|
||||
runtime,
|
||||
|
||||
@@ -166,6 +166,10 @@ function firstSendText(mock: ReturnType<typeof vi.fn>) {
|
||||
return text as string;
|
||||
}
|
||||
|
||||
function rawSendRichMessageMock(bot: Bot): ReturnType<typeof vi.fn> {
|
||||
return (bot.api.raw as unknown as { sendRichMessage: ReturnType<typeof vi.fn> }).sendRichMessage;
|
||||
}
|
||||
|
||||
function createSendMessageHarness(messageId = 4) {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
@@ -833,6 +837,98 @@ describe("deliverReplies", () => {
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), { skip_entity_detection: true });
|
||||
});
|
||||
|
||||
it("uses Bot API sendMessage instead of rich messages for group replies", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 63,
|
||||
chat: { id: "-1001234567890" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverReplies({
|
||||
...baseDeliveryParams,
|
||||
chatId: "-1001234567890",
|
||||
replies: [{ text: "hi Mason" }],
|
||||
runtime,
|
||||
bot,
|
||||
thread: { id: 456, scope: "forum" },
|
||||
});
|
||||
|
||||
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith("-1001234567890", "hi Mason", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats mirrored group replies as group sends even when Telegram uses a positive chat id", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 64,
|
||||
chat: { id: "584667058" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverReplies({
|
||||
...baseDeliveryParams,
|
||||
chatId: "584667058",
|
||||
mirrorIsGroup: true,
|
||||
mirrorGroupId: "-5278454993",
|
||||
replies: [
|
||||
{
|
||||
text: "hi Mason",
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
thread: { scope: "none" },
|
||||
});
|
||||
|
||||
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith("584667058", "hi Mason", {
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("chunks mirrored group replies for the Bot API sendMessage limit", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 65, chat: { id: "584667058" } })
|
||||
.mockResolvedValueOnce({ message_id: 66, chat: { id: "584667058" } });
|
||||
const bot = createBot({ sendMessage });
|
||||
const longText = "a".repeat(4100);
|
||||
|
||||
await deliverReplies({
|
||||
...baseDeliveryParams,
|
||||
chatId: "584667058",
|
||||
mirrorIsGroup: true,
|
||||
mirrorGroupId: "-5278454993",
|
||||
replies: [{ text: longText }],
|
||||
runtime,
|
||||
bot,
|
||||
thread: { scope: "none" },
|
||||
textLimit: 100_000,
|
||||
});
|
||||
|
||||
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
const firstText = firstSendText(sendMessage);
|
||||
const secondText = mockCallArg(sendMessage, 1, 1);
|
||||
expect(typeof secondText).toBe("string");
|
||||
expect(firstText.length).toBeLessThanOrEqual(4096);
|
||||
expect((secondText as string).length).toBeLessThanOrEqual(4096);
|
||||
expect(`${firstText}${secondText as string}`).toBe(longText);
|
||||
});
|
||||
|
||||
it("includes message_thread_id for DM topics", async () => {
|
||||
const { runtime, sendMessage, bot } = createSendMessageHarness();
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import type { InlineKeyboardButton, InlineKeyboardMarkup } from "grammy/types";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
|
||||
export type TelegramInlineKeyboardChatType = "direct" | "group" | "unknown";
|
||||
|
||||
function toInlineKeyboardButton(
|
||||
button: TelegramInlineButtons[number][number] | undefined,
|
||||
chatType: TelegramInlineKeyboardChatType,
|
||||
): InlineKeyboardButton | undefined {
|
||||
if (!button?.text) {
|
||||
return undefined;
|
||||
@@ -19,6 +22,11 @@ function toInlineKeyboardButton(
|
||||
: { text: button.text, callback_data: button.callback_data };
|
||||
}
|
||||
if (button.web_app?.url) {
|
||||
if (chatType === "group") {
|
||||
return button.style
|
||||
? { text: button.text, url: button.web_app.url, style: button.style }
|
||||
: { text: button.text, url: button.web_app.url };
|
||||
}
|
||||
return button.style
|
||||
? { text: button.text, web_app: { url: button.web_app.url }, style: button.style }
|
||||
: { text: button.text, web_app: { url: button.web_app.url } };
|
||||
@@ -28,14 +36,16 @@ function toInlineKeyboardButton(
|
||||
|
||||
export function buildInlineKeyboard(
|
||||
buttons?: TelegramInlineButtons,
|
||||
options?: { chatType?: TelegramInlineKeyboardChatType },
|
||||
): InlineKeyboardMarkup | undefined {
|
||||
if (!buttons?.length) {
|
||||
return undefined;
|
||||
}
|
||||
const chatType = options?.chatType ?? "unknown";
|
||||
const rows = buttons
|
||||
.map((row) =>
|
||||
row
|
||||
.map(toInlineKeyboardButton)
|
||||
.map((button) => toInlineKeyboardButton(button, chatType))
|
||||
.filter((button): button is InlineKeyboardButton => Boolean(button)),
|
||||
)
|
||||
.filter((row) => row.length > 0);
|
||||
|
||||
@@ -53,6 +53,7 @@ const {
|
||||
createForumTopicTelegram,
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageReplyMarkupTelegram,
|
||||
editMessageTelegram,
|
||||
pinMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
@@ -1357,6 +1358,127 @@ describe("sendMessageTelegram", () => {
|
||||
expect(chunks.join("")).toContain("<figure>");
|
||||
});
|
||||
|
||||
it("uses Bot API sendMessage for group text so Telegram clients can render it", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({
|
||||
message_id: 61,
|
||||
chat: { id: "-1001234567890" },
|
||||
});
|
||||
|
||||
await sendMessageTelegram("telegram:group:-1001234567890:topic:456", "hi Mason", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(botApi.sendMessage).toHaveBeenCalledWith("-1001234567890", "hi Mason", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back private-chat web app buttons to url buttons for group sends", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({
|
||||
message_id: 61,
|
||||
chat: { id: "-1001234567890" },
|
||||
});
|
||||
|
||||
await sendMessageTelegram("telegram:group:-1001234567890:topic:456", "OpenClaw status", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(botApi.sendMessage).toHaveBeenCalledWith("-1001234567890", "OpenClaw status", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 456,
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps private-chat web app buttons after resolving a nonnumeric target to a direct chat", async () => {
|
||||
const getChat = vi.fn().mockResolvedValue({ id: 123 });
|
||||
const sendRichMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 62,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const sendMessage = vi.fn();
|
||||
const api = {
|
||||
getChat,
|
||||
sendMessage,
|
||||
raw: { sendRichMessage },
|
||||
} as unknown as TelegramApiOverride;
|
||||
|
||||
await sendMessageTelegram("https://t.me/direct_user", "OpenClaw status", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(getChat).toHaveBeenCalledWith("@direct_user");
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: "123",
|
||||
rich_message: { html: "OpenClaw status" },
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps private-chat web app buttons for direct rich sends", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({
|
||||
message_id: 62,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
|
||||
await sendMessageTelegram("123", "OpenClaw status", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(richSendCallParams()[0]?.reply_markup).toEqual({
|
||||
inline_keyboard: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back private-chat web app buttons to url buttons after resolving a group target", async () => {
|
||||
const getChat = vi.fn().mockResolvedValue({ id: -1001234567890 });
|
||||
const sendRichMessage = vi.fn();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 63,
|
||||
chat: { id: "-1001234567890" },
|
||||
});
|
||||
const api = {
|
||||
getChat,
|
||||
sendMessage,
|
||||
raw: { sendRichMessage },
|
||||
} as unknown as TelegramApiOverride;
|
||||
|
||||
await sendMessageTelegram("https://t.me/mychannel", "OpenClaw status", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
||||
expect(sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith("-1001234567890", "OpenClaw status", {
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when Telegram text send returns no message_id", async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
chat: { id: "123" },
|
||||
@@ -2593,7 +2715,7 @@ describe("sendMessageTelegram", () => {
|
||||
expect(logs).toContain("accountId=ops");
|
||||
expect(logs).toContain(`chatId=${chatId}`);
|
||||
expect(logs).toContain("messageId=321");
|
||||
expect(logs).toContain("operation=sendRichMessage");
|
||||
expect(logs).toContain("operation=sendMessage");
|
||||
expect(logs).toContain("threadId=271");
|
||||
expect(logs).toContain("replyToMessageId=123");
|
||||
expect(logs).toContain("silent=true");
|
||||
@@ -3500,6 +3622,63 @@ describe("editMessageTelegram", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back private-chat web app buttons to url buttons when editing resolved group text", async () => {
|
||||
const api = {
|
||||
getChat: vi.fn().mockResolvedValue({ id: -1001234567890 }),
|
||||
raw: {
|
||||
editMessageText: vi.fn().mockResolvedValue({ message_id: 1, chat: { id: -1001234567890 } }),
|
||||
},
|
||||
} as unknown as TelegramApiOverride;
|
||||
|
||||
await editMessageTelegram("https://t.me/mychannel", 1, "Updated", {
|
||||
token: "tok",
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
api,
|
||||
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(api.getChat).toHaveBeenCalledWith("@mychannel");
|
||||
expect(api.raw?.editMessageText).toHaveBeenCalledWith({
|
||||
chat_id: "-1001234567890",
|
||||
message_id: 1,
|
||||
rich_message: { html: "Updated" },
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMessageReplyMarkupTelegram", () => {
|
||||
it("falls back private-chat web app buttons to url buttons when editing resolved group markup", async () => {
|
||||
const api = {
|
||||
getChat: vi.fn().mockResolvedValue({ id: -1001234567890 }),
|
||||
editMessageReplyMarkup: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ message_id: 1, chat: { id: -1001234567890 } }),
|
||||
} as unknown as TelegramApiOverride;
|
||||
|
||||
await editMessageReplyMarkupTelegram(
|
||||
"https://t.me/mychannel",
|
||||
1,
|
||||
[[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
|
||||
{
|
||||
token: "tok",
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
api,
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(api.getChat).toHaveBeenCalledWith("@mychannel");
|
||||
expect(api.editMessageReplyMarkup).toHaveBeenCalledWith("-1001234567890", 1, {
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPollTelegram", () => {
|
||||
|
||||
@@ -21,7 +21,11 @@ import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { splitTelegramCaption } from "./caption.js";
|
||||
import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js";
|
||||
import { resolveTelegramTransport } from "./fetch.js";
|
||||
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
|
||||
import {
|
||||
renderTelegramHtmlText,
|
||||
splitTelegramHtmlChunks,
|
||||
telegramHtmlToPlainTextFallback,
|
||||
} from "./format.js";
|
||||
import { buildInlineKeyboard } from "./inline-keyboard.js";
|
||||
import {
|
||||
isRecoverableTelegramNetworkError,
|
||||
@@ -44,7 +48,6 @@ import {
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
toTelegramRichMessageContextParams,
|
||||
type TelegramEditRichMessageTextParams,
|
||||
type TelegramRichMessageContextParams,
|
||||
type TelegramRichTextChunk,
|
||||
} from "./rich-message.js";
|
||||
import {
|
||||
@@ -203,6 +206,7 @@ const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity
|
||||
const MESSAGE_DELETE_NOOP_RE =
|
||||
/message to delete not found|message can't be deleted|MESSAGE_ID_INVALID|MESSAGE_DELETE_FORBIDDEN/i;
|
||||
const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i;
|
||||
const TELEGRAM_LEGACY_TEXT_LIMIT = 4096;
|
||||
const sendLogger = createSubsystemLogger("telegram/send");
|
||||
const diagLogger = createSubsystemLogger("telegram/diagnostic");
|
||||
const telegramClientOptionsCache = new Map<string, ApiClientOptions | undefined>();
|
||||
@@ -573,13 +577,14 @@ export async function sendMessageTelegram(
|
||||
const mediaMaxBytes =
|
||||
opts.maxBytes ??
|
||||
(typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024;
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||
const resolvedChatType = parseTelegramTarget(chatId).chatType;
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons, { chatType: resolvedChatType });
|
||||
|
||||
const threadParams = buildTelegramThreadReplyParams({
|
||||
thread: resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
chatType: resolvedChatType,
|
||||
}),
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
replyQuoteText: opts.quoteText,
|
||||
@@ -612,42 +617,94 @@ export async function sendMessageTelegram(
|
||||
tableMode,
|
||||
};
|
||||
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
|
||||
const useRichTextSend = resolvedChatType !== "group";
|
||||
const textTransportLimit = useRichTextSend
|
||||
? TELEGRAM_RICH_TEXT_LIMIT
|
||||
: TELEGRAM_LEGACY_TEXT_LIMIT;
|
||||
const textLimit = Math.min(
|
||||
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
|
||||
fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT,
|
||||
fallbackLimit: textTransportLimit,
|
||||
}),
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
textTransportLimit,
|
||||
);
|
||||
const chunkMode = resolveChunkMode(cfg, "telegram", account.accountId);
|
||||
|
||||
type TelegramTextParams = Record<string, unknown> & {
|
||||
message_thread_id?: number;
|
||||
reply_markup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
};
|
||||
|
||||
const sendTelegramTextChunk = async (
|
||||
chunk: TelegramRichTextChunk,
|
||||
params?: TelegramRichMessageContextParams,
|
||||
params?: TelegramTextParams,
|
||||
) => {
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
const richParams = {
|
||||
...params,
|
||||
if (useRichTextSend) {
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
const richParams = {
|
||||
...params,
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
};
|
||||
const result = await requestWithChatNotFound(
|
||||
() =>
|
||||
richRawApi.sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, richMessageOptions),
|
||||
...richParams,
|
||||
}),
|
||||
"richMessage",
|
||||
);
|
||||
return { result, acceptedParams: params, operation: "sendRichMessage" };
|
||||
}
|
||||
|
||||
const legacyParams: Record<string, unknown> = {
|
||||
parse_mode: "HTML",
|
||||
...threadParams,
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
...(params?.reply_markup ? { reply_markup: params.reply_markup } : {}),
|
||||
};
|
||||
const result = await requestWithChatNotFound(
|
||||
() =>
|
||||
richRawApi.sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, richMessageOptions),
|
||||
...richParams,
|
||||
}),
|
||||
"richMessage",
|
||||
);
|
||||
return { result, acceptedParams: params };
|
||||
if (account.config.linkPreview === false) {
|
||||
legacyParams.link_preview_options = { is_disabled: true };
|
||||
}
|
||||
const plainParams = { ...legacyParams };
|
||||
delete plainParams.parse_mode;
|
||||
const result = await withTelegramHtmlParseFallback({
|
||||
label: "sendMessage",
|
||||
verbose: opts.verbose,
|
||||
requestHtml: (label) =>
|
||||
requestWithChatNotFound(
|
||||
() =>
|
||||
api.sendMessage(
|
||||
chatId,
|
||||
chunk.text,
|
||||
legacyParams as Parameters<TelegramApi["sendMessage"]>[2],
|
||||
) as Promise<TelegramMessageLike>,
|
||||
label,
|
||||
),
|
||||
requestPlain: (label) =>
|
||||
requestWithChatNotFound(
|
||||
() =>
|
||||
api.sendMessage(
|
||||
chatId,
|
||||
telegramHtmlToPlainTextFallback(chunk.text),
|
||||
plainParams as Parameters<TelegramApi["sendMessage"]>[2],
|
||||
) as Promise<TelegramMessageLike>,
|
||||
label,
|
||||
),
|
||||
});
|
||||
return { result, acceptedParams: threadParams, operation: "sendMessage" };
|
||||
};
|
||||
|
||||
const buildTextParams = (isLastChunk: boolean) =>
|
||||
hasRichThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...richThreadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
useRichTextSend
|
||||
? hasRichThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...richThreadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined
|
||||
: isLastChunk && replyMarkup
|
||||
? { reply_markup: replyMarkup }
|
||||
: undefined;
|
||||
|
||||
const sendTelegramTextChunks = async (
|
||||
chunks: TelegramRichTextChunk[],
|
||||
@@ -656,16 +713,18 @@ export async function sendMessageTelegram(
|
||||
let lastMessageId = "";
|
||||
let lastChatId = chatId;
|
||||
let lastAcceptedParams: TelegramThreadScopedParams | undefined;
|
||||
let lastOperation = "";
|
||||
let sentChunkCount = 0;
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunk = chunks[index];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const { result: res, acceptedParams } = await sendTelegramTextChunk(
|
||||
chunk,
|
||||
buildTextParams(index === chunks.length - 1),
|
||||
);
|
||||
const {
|
||||
result: res,
|
||||
acceptedParams,
|
||||
operation,
|
||||
} = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1));
|
||||
const messageId = resolveTelegramMessageIdOrThrow(res, context);
|
||||
recordSentMessage(chatId, messageId, cfg);
|
||||
await recordOutboundMessageForPromptContext({
|
||||
@@ -682,6 +741,7 @@ export async function sendMessageTelegram(
|
||||
lastMessageId = String(messageId);
|
||||
lastChatId = String(res?.chat?.id ?? chatId);
|
||||
lastAcceptedParams = acceptedParams;
|
||||
lastOperation = operation;
|
||||
sentChunkCount += 1;
|
||||
}
|
||||
if (lastMessageId) {
|
||||
@@ -689,7 +749,7 @@ export async function sendMessageTelegram(
|
||||
accountId: account.accountId,
|
||||
chatId: lastChatId,
|
||||
messageId: lastMessageId,
|
||||
operation: "sendRichMessage",
|
||||
operation: lastOperation,
|
||||
deliveryKind: "text",
|
||||
messageThreadId: lastAcceptedParams?.message_thread_id,
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
@@ -701,6 +761,13 @@ export async function sendMessageTelegram(
|
||||
};
|
||||
|
||||
const buildChunkedTextPlan = (rawText: string): TelegramRichTextChunk[] => {
|
||||
if (!useRichTextSend) {
|
||||
return splitTelegramHtmlChunks(renderHtmlText(rawText), textLimit).map((chunkText) => ({
|
||||
text: chunkText,
|
||||
textMode: "html",
|
||||
plainText: telegramHtmlToPlainTextFallback(chunkText),
|
||||
}));
|
||||
}
|
||||
return splitTelegramRichMessageTextChunks({
|
||||
text: rawText,
|
||||
textLimit,
|
||||
@@ -1290,13 +1357,16 @@ export async function editMessageReplyMarkupTelegram(
|
||||
gatewayClientScopes: opts.gatewayClientScopes,
|
||||
});
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const resolvedChatType = parseTelegramTarget(chatId).chatType;
|
||||
const requestWithDiag = createTelegramRequestWithDiag({
|
||||
cfg,
|
||||
account,
|
||||
retry: opts.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
|
||||
const replyMarkup = buildInlineKeyboard(buttons, { chatType: resolvedChatType }) ?? {
|
||||
inline_keyboard: [],
|
||||
};
|
||||
try {
|
||||
await requestWithDiag(
|
||||
() => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }),
|
||||
@@ -1334,6 +1404,7 @@ export async function editMessageTelegram(
|
||||
gatewayClientScopes: opts.gatewayClientScopes,
|
||||
});
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const resolvedChatType = parseTelegramTarget(chatId).chatType;
|
||||
const requestWithDiag = createTelegramRequestWithDiag({
|
||||
cfg,
|
||||
account,
|
||||
@@ -1369,7 +1440,9 @@ export async function editMessageTelegram(
|
||||
// - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove)
|
||||
// - otherwise → send built inline keyboard
|
||||
const shouldTouchButtons = opts.buttons !== undefined;
|
||||
const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
|
||||
const builtKeyboard = shouldTouchButtons
|
||||
? buildInlineKeyboard(opts.buttons, { chatType: resolvedChatType })
|
||||
: undefined;
|
||||
const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
|
||||
|
||||
const textEditParams: Pick<TelegramEditRichMessageTextParams, "reply_markup"> = {};
|
||||
|
||||
@@ -607,6 +607,46 @@ function rememberWrittenSessionEntries(
|
||||
};
|
||||
}
|
||||
|
||||
function refreshSessionEntriesCacheFromCurrentFile(filePath: string): {
|
||||
snapshot: SessionFileSnapshot | undefined;
|
||||
cacheRefreshed: boolean;
|
||||
} {
|
||||
const resolvedPath = resolve(filePath);
|
||||
let beforeReadSnapshot: SessionFileSnapshot;
|
||||
try {
|
||||
beforeReadSnapshot = readSessionFileSnapshot(resolvedPath);
|
||||
} catch {
|
||||
sessionEntriesCache.delete(resolvedPath);
|
||||
return { snapshot: undefined, cacheRefreshed: false };
|
||||
}
|
||||
if (beforeReadSnapshot.size > MAX_CACHED_SESSION_BYTES) {
|
||||
sessionEntriesCache.delete(resolvedPath);
|
||||
return { snapshot: beforeReadSnapshot, cacheRefreshed: false };
|
||||
}
|
||||
|
||||
let content: string;
|
||||
let afterReadSnapshot: SessionFileSnapshot;
|
||||
try {
|
||||
content = readFileSync(resolvedPath, "utf8");
|
||||
afterReadSnapshot = readSessionFileSnapshot(resolvedPath);
|
||||
} catch {
|
||||
sessionEntriesCache.delete(resolvedPath);
|
||||
return { snapshot: undefined, cacheRefreshed: false };
|
||||
}
|
||||
if (!isSameSessionFileSnapshot(beforeReadSnapshot, afterReadSnapshot)) {
|
||||
sessionEntriesCache.delete(resolvedPath);
|
||||
return { snapshot: afterReadSnapshot, cacheRefreshed: false };
|
||||
}
|
||||
|
||||
rememberSessionEntries(
|
||||
resolvedPath,
|
||||
afterReadSnapshot,
|
||||
parseJsonlEntries(content),
|
||||
content.endsWith("\n"),
|
||||
);
|
||||
return { snapshot: afterReadSnapshot, cacheRefreshed: true };
|
||||
}
|
||||
|
||||
function rememberAppendedSessionEntry(
|
||||
filePath: string,
|
||||
previousSnapshot: SessionFileSnapshot | undefined,
|
||||
@@ -674,6 +714,15 @@ function rememberAppendedSessionEntry(
|
||||
return { snapshot, cacheAdvanced: true };
|
||||
}
|
||||
|
||||
function shouldRefreshSessionPrefixAfterSerialization(entry: SessionEntry): boolean {
|
||||
return (
|
||||
entry.type === "custom" ||
|
||||
entry.type === "custom_message" ||
|
||||
entry.type === "compaction" ||
|
||||
entry.type === "branch_summary"
|
||||
);
|
||||
}
|
||||
|
||||
function publishRememberedSessionFileSnapshot(
|
||||
filePath: string,
|
||||
snapshot: SessionFileSnapshot | undefined,
|
||||
@@ -1327,7 +1376,13 @@ export class SessionManager {
|
||||
// user code and can mutate the transcript; the cache must validate the
|
||||
// prefix state that immediately precedes the exact bytes being appended.
|
||||
const serializedEntry = serializeJsonlEntry(entry);
|
||||
const beforeAppendSnapshot = readSessionFileSnapshotIfExists(this.sessionFile);
|
||||
let beforeAppendSnapshot = readSessionFileSnapshotIfExists(this.sessionFile);
|
||||
let previousSnapshot = this.sessionFileSnapshot;
|
||||
if (shouldRefreshSessionPrefixAfterSerialization(entry)) {
|
||||
const refreshedPrefix = refreshSessionEntriesCacheFromCurrentFile(this.sessionFile);
|
||||
beforeAppendSnapshot = refreshedPrefix.snapshot;
|
||||
previousSnapshot = refreshedPrefix.cacheRefreshed ? refreshedPrefix.snapshot : undefined;
|
||||
}
|
||||
const cacheOwnedAppend = Boolean(
|
||||
beforeAppendSnapshot &&
|
||||
canAdvanceOwnedSessionEntryCache({
|
||||
@@ -1340,7 +1395,7 @@ export class SessionManager {
|
||||
});
|
||||
const rememberedAppend = rememberAppendedSessionEntry(
|
||||
this.sessionFile,
|
||||
this.sessionFileSnapshot,
|
||||
previousSnapshot,
|
||||
beforeAppendSnapshot,
|
||||
serializedAppend,
|
||||
cacheOwnedAppend,
|
||||
|
||||
Reference in New Issue
Block a user