mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
feat(telegram): deliver native rich messages
This commit is contained in:
parent
dd66f5cbe1
commit
d7f4cf03ab
7 changed files with 1588 additions and 170 deletions
323
lib/index.js
323
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -35,6 +35,25 @@ function preserveProviderMetadata(target, source) {
|
|||
return target;
|
||||
}
|
||||
|
||||
function inputRichMessage(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('A Telegram rich message is required');
|
||||
}
|
||||
const formats = ['markdown', 'html', 'blocks'].filter((key) => value[key] !== undefined);
|
||||
if (formats.length !== 1) {
|
||||
throw new TypeError('A Telegram rich message requires exactly one format');
|
||||
}
|
||||
const selected = formats[0];
|
||||
if ((selected === 'markdown' || selected === 'html')
|
||||
&& (typeof value[selected] !== 'string' || !value[selected].trim())) {
|
||||
throw new TypeError('Telegram rich message text is invalid');
|
||||
}
|
||||
if (selected === 'blocks' && !Array.isArray(value.blocks)) {
|
||||
throw new TypeError('Telegram rich message blocks are invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function telegramArtifactProviderError(cause, mediaLabel = 'document') {
|
||||
const providerCode = Number(cause?.providerCode);
|
||||
const status = Number(cause?.status);
|
||||
|
|
@ -183,6 +202,35 @@ export class TelegramApi {
|
|||
}, { signal });
|
||||
}
|
||||
|
||||
async sendRichMessage({
|
||||
chatId,
|
||||
richMessage,
|
||||
replyToMessageId,
|
||||
messageThreadId,
|
||||
signal,
|
||||
}) {
|
||||
return this.#call('sendRichMessage', {
|
||||
chat_id: chatId,
|
||||
rich_message: inputRichMessage(richMessage),
|
||||
...(replyToMessageId ? {
|
||||
reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true },
|
||||
} : {}),
|
||||
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
async sendRichMessageDraft({ chatId, draftId, richMessage, messageThreadId, signal }) {
|
||||
if (!Number.isSafeInteger(draftId) || draftId === 0) {
|
||||
throw new TypeError('Telegram rich message draft id must be a non-zero integer');
|
||||
}
|
||||
return this.#call('sendRichMessageDraft', {
|
||||
chat_id: chatId,
|
||||
draft_id: draftId,
|
||||
rich_message: inputRichMessage(richMessage),
|
||||
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
|
||||
return this.#sendArtifact('sendDocument', 'document', 'document', {
|
||||
chatId,
|
||||
|
|
@ -239,6 +287,9 @@ export class TelegramApi {
|
|||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
if (error?.deliveryOutcome === 'unknown') {
|
||||
throw uncertainTelegramDelivery(error?.cause ?? error, mediaLabel);
|
||||
}
|
||||
if (error?.code?.startsWith?.('telegram-')) {
|
||||
throw telegramArtifactProviderError(error, mediaLabel);
|
||||
}
|
||||
|
|
@ -246,12 +297,17 @@ export class TelegramApi {
|
|||
}
|
||||
}
|
||||
|
||||
async editMessageText({ chatId, messageId, text, signal }) {
|
||||
async editMessageText({ chatId, messageId, text, richMessage, signal }) {
|
||||
if ((text === undefined) === (richMessage === undefined)) {
|
||||
throw new TypeError('Telegram message edit requires exactly one of text or richMessage');
|
||||
}
|
||||
return this.#call('editMessageText', {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
...(text === undefined ? { rich_message: inputRichMessage(richMessage) } : {
|
||||
text,
|
||||
link_preview_options: { is_disabled: true },
|
||||
}),
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
|
|
@ -283,6 +339,7 @@ export class TelegramApi {
|
|||
}
|
||||
|
||||
async #call(method, payload, { signal, timeoutMs = 15_000, multipart = false } = {}) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
const url = new URL(this.#baseUrl);
|
||||
url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`;
|
||||
let response;
|
||||
|
|
@ -295,8 +352,14 @@ export class TelegramApi {
|
|||
redirect: 'error',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
||||
throw new Error(`Telegram ${method} transport failed`);
|
||||
const transport = new Error(`Telegram ${method} transport failed`, { cause: error });
|
||||
transport.code = error?.name === 'TimeoutError'
|
||||
? 'telegram-timeout'
|
||||
: error?.name === 'AbortError'
|
||||
? 'telegram-aborted-after-dispatch'
|
||||
: 'telegram-transport-error';
|
||||
transport.deliveryOutcome = 'unknown';
|
||||
throw transport;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
|
|
@ -304,6 +367,8 @@ export class TelegramApi {
|
|||
} catch {
|
||||
const error = new Error(`Telegram ${method} returned invalid JSON`);
|
||||
error.status = response?.status;
|
||||
error.code = 'telegram-response-invalid';
|
||||
error.deliveryOutcome = 'unknown';
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok || body?.ok !== true) {
|
||||
|
|
|
|||
147
src/channels/telegram/telegram-rich-message.mjs
Normal file
147
src/channels/telegram/telegram-rich-message.mjs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
export const TELEGRAM_RICH_TEXT_LIMIT = 30_000;
|
||||
export const TELEGRAM_REGULAR_TEXT_LIMIT = 4_000;
|
||||
|
||||
function textValue(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TypeError('Telegram message text must be a non-empty string');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function escapedCharacterLength(value) {
|
||||
if (value === '&') return 5;
|
||||
if (value === '<' || value === '>') return 4;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function escapedLength(value) {
|
||||
return Array.from(value).reduce((total, character) => (
|
||||
total + escapedCharacterLength(character)
|
||||
), 0);
|
||||
}
|
||||
|
||||
function preferredCut(points, offset, hardEnd, limit) {
|
||||
const minimum = offset + Math.floor(limit * 0.5);
|
||||
for (let index = hardEnd - 1; index >= minimum; index -= 1) {
|
||||
if (points[index] === '\n' || points[index] === ' ') return index + 1;
|
||||
}
|
||||
return hardEnd;
|
||||
}
|
||||
|
||||
function splitText(value, limit, characterLength = () => 1) {
|
||||
if (typeof value !== 'string') throw new TypeError('Telegram message text must be a string');
|
||||
if (!value) return [];
|
||||
const points = Array.from(value);
|
||||
const chunks = [];
|
||||
let offset = 0;
|
||||
while (offset < points.length) {
|
||||
let end = offset;
|
||||
let length = 0;
|
||||
while (end < points.length) {
|
||||
const nextLength = characterLength(points[end]);
|
||||
if (length + nextLength > limit) break;
|
||||
length += nextLength;
|
||||
end += 1;
|
||||
}
|
||||
if (end === offset) throw new Error('Telegram message character exceeds the chunk limit');
|
||||
if (end < points.length) end = preferredCut(points, offset, end, limit);
|
||||
chunks.push(points.slice(offset, end).join(''));
|
||||
offset = end;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function fenceMarkers(markdown) {
|
||||
return [...markdown.matchAll(/^```[^\n]*(?:\n|$)/gm)];
|
||||
}
|
||||
|
||||
function assertCompleteFences(markdown) {
|
||||
const markers = fenceMarkers(markdown);
|
||||
if (markers.length % 2 !== 0) {
|
||||
throw new Error('Telegram Rich Markdown contains an unfinished code fence');
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
function escapedMarkdown(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
function plainRichChunks(source, limit) {
|
||||
return splitText(source, limit, escapedCharacterLength).map((chunk) => ({
|
||||
source: chunk,
|
||||
markdown: escapedMarkdown(chunk),
|
||||
}));
|
||||
}
|
||||
|
||||
function fencedRichChunks(source, opening, closing, limit) {
|
||||
const body = source.slice(opening.length, source.length - closing.length);
|
||||
const wrapperLength = escapedLength(opening)
|
||||
+ Math.max(escapedLength(closing), escapedLength('```'))
|
||||
+ 1;
|
||||
const bodyLimit = limit - wrapperLength;
|
||||
if (bodyLimit < 1) throw new Error('Telegram Rich Markdown code fence exceeds the limit');
|
||||
const bodyChunks = splitText(body, bodyLimit, escapedCharacterLength);
|
||||
return bodyChunks.map((bodyChunk, index) => {
|
||||
const last = index === bodyChunks.length - 1;
|
||||
const separator = bodyChunk.endsWith('\n') ? '' : '\n';
|
||||
const renderedClosing = last ? closing : '```';
|
||||
const sourceChunk = `${index === 0 ? opening : ''}${bodyChunk}${last ? closing : ''}`;
|
||||
const markdown = escapedMarkdown(`${opening}${bodyChunk}${separator}${renderedClosing}`);
|
||||
if (Array.from(markdown).length > limit) {
|
||||
throw new Error('Telegram Rich Markdown code block exceeds the limit');
|
||||
}
|
||||
return { source: sourceChunk, markdown };
|
||||
});
|
||||
}
|
||||
|
||||
export function toTelegramRichMarkdown(value) {
|
||||
const markdown = textValue(value);
|
||||
assertCompleteFences(markdown);
|
||||
// Rich Markdown also accepts HTML tags. Escape entities first so encoded or
|
||||
// raw model output cannot be interpreted as Telegram-specific markup.
|
||||
return escapedMarkdown(markdown);
|
||||
}
|
||||
|
||||
export function splitTelegramRichMarkdown(value, limit = TELEGRAM_RICH_TEXT_LIMIT) {
|
||||
if (!Number.isInteger(limit) || limit < 128 || limit > 32_768) {
|
||||
throw new TypeError('Telegram Rich Markdown limit is invalid');
|
||||
}
|
||||
const source = textValue(value);
|
||||
const markers = assertCompleteFences(source);
|
||||
const complete = escapedMarkdown(source);
|
||||
if (Array.from(complete).length <= limit) return [{ source, markdown: complete }];
|
||||
|
||||
const chunks = [];
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < markers.length; index += 2) {
|
||||
const opening = markers[index];
|
||||
const closing = markers[index + 1];
|
||||
if (opening.index > cursor) {
|
||||
chunks.push(...plainRichChunks(source.slice(cursor, opening.index), limit));
|
||||
}
|
||||
const end = closing.index + closing[0].length;
|
||||
const fenced = source.slice(opening.index, end);
|
||||
const rendered = escapedMarkdown(fenced);
|
||||
if (Array.from(rendered).length <= limit) {
|
||||
chunks.push({ source: fenced, markdown: rendered });
|
||||
} else {
|
||||
chunks.push(...fencedRichChunks(fenced, opening[0], closing[0], limit));
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < source.length) chunks.push(...plainRichChunks(source.slice(cursor), limit));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function splitTelegramRegularText(value, limit = TELEGRAM_REGULAR_TEXT_LIMIT) {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 4_096) {
|
||||
throw new TypeError('Telegram regular message limit is invalid');
|
||||
}
|
||||
// Keep Telegram's established 4000 UTF-16-unit boundary without ever cutting
|
||||
// a surrogate pair in half.
|
||||
return splitText(textValue(value), limit, (character) => character.length);
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
import { randomInt } from 'node:crypto';
|
||||
|
||||
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||
import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
|
||||
import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
|
||||
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
|
||||
import {
|
||||
splitTelegramRegularText,
|
||||
splitTelegramRichMarkdown,
|
||||
toTelegramRichMarkdown,
|
||||
} from './telegram-rich-message.mjs';
|
||||
import {
|
||||
TELEGRAM_ACCESS_MODES,
|
||||
normalizeTelegramAccessPolicy,
|
||||
|
|
@ -149,6 +157,7 @@ export function normalizeTelegramUpdate(update, {
|
|||
addressed,
|
||||
replyTarget: {
|
||||
chatId,
|
||||
chatType: message.chat.type,
|
||||
replyToMessageId: messageId,
|
||||
messageThreadId,
|
||||
},
|
||||
|
|
@ -166,17 +175,105 @@ export function telegramInboundAllowed(message, {
|
|||
&& allowedPrivateUserIds.has(String(message.senderId));
|
||||
}
|
||||
|
||||
function telegramMessageId(value) {
|
||||
return Number.isSafeInteger(value?.message_id) ? String(value.message_id) : null;
|
||||
}
|
||||
|
||||
function telegramFailure(error) {
|
||||
const providerCode = Number(error?.providerCode);
|
||||
const status = Number(error?.status);
|
||||
const unknown = error?.deliveryOutcome === 'unknown'
|
||||
|| providerCode >= 500
|
||||
|| status >= 500
|
||||
|| ['telegram-timeout', 'telegram-aborted-after-dispatch', 'telegram-transport-error',
|
||||
'telegram-response-invalid'].includes(error?.code);
|
||||
return {
|
||||
outcome: unknown ? 'unknown' : 'failed',
|
||||
reason: typeof error?.code === 'string' && error.code
|
||||
? error.code
|
||||
: unknown ? 'telegram-delivery-uncertain' : 'telegram-provider-rejected',
|
||||
};
|
||||
}
|
||||
|
||||
function deliveryResult(presentation, providerMessageIds, deliveryOutcome = 'sent', reason) {
|
||||
return {
|
||||
presentation,
|
||||
providerMessageIds: [...new Set(providerMessageIds)],
|
||||
deliveryOutcome,
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
class TelegramDeliveryStream {
|
||||
#update;
|
||||
#finish;
|
||||
#fail;
|
||||
#logger;
|
||||
#providerMessageIds;
|
||||
#closed = false;
|
||||
#lastUpdate = null;
|
||||
|
||||
constructor({ update, finish, fail, providerMessageIds = [], presentation, logger }) {
|
||||
this.#update = update;
|
||||
this.#finish = finish;
|
||||
this.#fail = fail;
|
||||
this.#providerMessageIds = providerMessageIds;
|
||||
this.presentation = presentation;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
get providerMessageIds() {
|
||||
return [...this.#providerMessageIds];
|
||||
}
|
||||
|
||||
async update(value) {
|
||||
if (this.#closed) return undefined;
|
||||
const block = createTextDeliveryBlock(value);
|
||||
const key = `${block.format}:${block.text}`;
|
||||
if (key === this.#lastUpdate) return undefined;
|
||||
this.#lastUpdate = key;
|
||||
try {
|
||||
return await this.#update(block);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-im:telegram] rich stream update failed:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async finish(value) {
|
||||
if (this.#closed) throw new Error('Message stream is already closed');
|
||||
this.#closed = true;
|
||||
const result = await this.#finish(createTextDeliveryBlock(value));
|
||||
this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
|
||||
return result;
|
||||
}
|
||||
|
||||
async fail(text) {
|
||||
if (this.#closed) return undefined;
|
||||
this.#closed = true;
|
||||
const result = await this.#fail(createTextDeliveryBlock(text, 'plain'));
|
||||
this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
|
||||
return result;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.#closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export class TelegramBotClient {
|
||||
#api;
|
||||
#signal;
|
||||
#logger;
|
||||
|
||||
constructor({ api, signal }) {
|
||||
constructor({ api, signal, logger = console }) {
|
||||
this.#api = api;
|
||||
this.#signal = signal;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
async sendText(target, text) {
|
||||
const chunks = splitMessageText(text, 4_000);
|
||||
const chunks = splitTelegramRegularText(text);
|
||||
const providerMessageIds = [];
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const result = await this.#api.sendMessage({
|
||||
|
|
@ -221,6 +318,265 @@ export class TelegramBotClient {
|
|||
});
|
||||
}
|
||||
|
||||
async #sendPlain(target, text, { placeholderMessageId } = {}) {
|
||||
const chunks = splitTelegramRegularText(text);
|
||||
const providerMessageIds = placeholderMessageId === undefined
|
||||
? []
|
||||
: [String(placeholderMessageId)];
|
||||
let firstUnsent = 0;
|
||||
if (placeholderMessageId !== undefined) {
|
||||
try {
|
||||
await this.#api.editMessageText({
|
||||
chatId: target.chatId,
|
||||
messageId: placeholderMessageId,
|
||||
text: chunks[0],
|
||||
signal: this.#signal,
|
||||
});
|
||||
firstUnsent = 1;
|
||||
} catch (error) {
|
||||
const failure = telegramFailure(error);
|
||||
if (failure.outcome === 'unknown') {
|
||||
return deliveryResult('text-fallback', providerMessageIds, 'unknown', failure.reason);
|
||||
}
|
||||
|
||||
const fallback = await this.#sendPlain(target, text);
|
||||
const terminalText = fallback.deliveryOutcome === 'sent'
|
||||
? '回复已发送。'
|
||||
: fallback.deliveryOutcome === 'unknown'
|
||||
? '回复发送结果未能确认。'
|
||||
: '消息发送失败,请稍后重试。';
|
||||
try {
|
||||
await this.#api.editMessageText({
|
||||
chatId: target.chatId,
|
||||
messageId: placeholderMessageId,
|
||||
text: terminalText,
|
||||
signal: this.#signal,
|
||||
});
|
||||
} catch (terminalError) {
|
||||
this.#logger.warn?.(
|
||||
'[dsh-im:telegram] unable to replace the processing placeholder:',
|
||||
terminalError,
|
||||
);
|
||||
}
|
||||
return deliveryResult(
|
||||
'text-fallback',
|
||||
[...providerMessageIds, ...fallback.providerMessageIds],
|
||||
fallback.deliveryOutcome,
|
||||
fallback.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = firstUnsent; index < chunks.length; index += 1) {
|
||||
try {
|
||||
const result = await this.#api.sendMessage({
|
||||
chatId: target.chatId,
|
||||
text: chunks[index],
|
||||
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
const id = telegramMessageId(result);
|
||||
if (id) providerMessageIds.push(id);
|
||||
} catch (error) {
|
||||
const failure = telegramFailure(error);
|
||||
return deliveryResult(
|
||||
'text-fallback',
|
||||
providerMessageIds,
|
||||
failure.outcome,
|
||||
failure.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
return deliveryResult('text-fallback', providerMessageIds);
|
||||
}
|
||||
|
||||
async #sendRich(target, block) {
|
||||
if (block.format === 'plain') return this.#sendPlain(target, block.text);
|
||||
|
||||
let chunks;
|
||||
try {
|
||||
chunks = splitTelegramRichMarkdown(block.text);
|
||||
} catch {
|
||||
return this.#sendPlain(target, block.text);
|
||||
}
|
||||
const providerMessageIds = [];
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
try {
|
||||
const result = await this.#api.sendRichMessage({
|
||||
chatId: target.chatId,
|
||||
richMessage: { markdown: chunk.markdown },
|
||||
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
const id = telegramMessageId(result);
|
||||
if (id) providerMessageIds.push(id);
|
||||
} catch (error) {
|
||||
const failure = telegramFailure(error);
|
||||
if (failure.outcome === 'unknown') {
|
||||
return deliveryResult(
|
||||
'telegram-rich-final',
|
||||
providerMessageIds,
|
||||
'unknown',
|
||||
failure.reason,
|
||||
);
|
||||
}
|
||||
const remaining = chunks.slice(index).map((part) => part.source).join('');
|
||||
const fallback = await this.#sendPlain({
|
||||
...target,
|
||||
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
||||
}, remaining);
|
||||
return deliveryResult(
|
||||
fallback.presentation,
|
||||
[...providerMessageIds, ...fallback.providerMessageIds],
|
||||
fallback.deliveryOutcome,
|
||||
fallback.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
return deliveryResult('telegram-rich-final', providerMessageIds);
|
||||
}
|
||||
|
||||
async #editRich(target, messageId, block) {
|
||||
if (block.format === 'plain') {
|
||||
return this.#sendPlain(target, block.text, {
|
||||
placeholderMessageId: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
let chunks;
|
||||
try {
|
||||
chunks = splitTelegramRichMarkdown(block.text);
|
||||
} catch {
|
||||
return this.#sendPlain(target, block.text, { placeholderMessageId: messageId });
|
||||
}
|
||||
const providerMessageIds = [String(messageId)];
|
||||
try {
|
||||
await this.#api.editMessageText({
|
||||
chatId: target.chatId,
|
||||
messageId,
|
||||
richMessage: { markdown: chunks[0].markdown },
|
||||
signal: this.#signal,
|
||||
});
|
||||
} catch (error) {
|
||||
const failure = telegramFailure(error);
|
||||
if (failure.outcome === 'unknown') {
|
||||
return deliveryResult(
|
||||
'telegram-rich-final',
|
||||
providerMessageIds,
|
||||
'unknown',
|
||||
failure.reason,
|
||||
);
|
||||
}
|
||||
return this.#sendPlain(target, block.text, { placeholderMessageId: messageId });
|
||||
}
|
||||
|
||||
for (const [offset, chunk] of chunks.slice(1).entries()) {
|
||||
try {
|
||||
const result = await this.#api.sendRichMessage({
|
||||
chatId: target.chatId,
|
||||
richMessage: { markdown: chunk.markdown },
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
const id = telegramMessageId(result);
|
||||
if (id) providerMessageIds.push(id);
|
||||
} catch (error) {
|
||||
const failure = telegramFailure(error);
|
||||
if (failure.outcome === 'unknown') {
|
||||
return deliveryResult(
|
||||
'telegram-rich-final',
|
||||
providerMessageIds,
|
||||
'unknown',
|
||||
failure.reason,
|
||||
);
|
||||
}
|
||||
const remaining = chunks.slice(offset + 1).map((part) => part.source).join('');
|
||||
const fallback = await this.#sendPlain({
|
||||
...target,
|
||||
replyToMessageId: undefined,
|
||||
}, remaining);
|
||||
return deliveryResult(
|
||||
fallback.presentation,
|
||||
[...providerMessageIds, ...fallback.providerMessageIds],
|
||||
fallback.deliveryOutcome,
|
||||
fallback.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
return deliveryResult('telegram-rich-final', providerMessageIds);
|
||||
}
|
||||
|
||||
sendDelivery(target, value) {
|
||||
return this.#sendRich(target, createTextDeliveryBlock(value));
|
||||
}
|
||||
|
||||
async openDeliveryStream(target) {
|
||||
if (target.chatType === 'private') {
|
||||
const draftId = randomInt(1, 2_147_483_647);
|
||||
const updateDraft = async (block) => {
|
||||
const richMessage = { markdown: toTelegramRichMarkdown(block.text) };
|
||||
await this.#api.sendRichMessageDraft({
|
||||
chatId: target.chatId,
|
||||
draftId,
|
||||
richMessage,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
return deliveryResult('telegram-rich-draft', []);
|
||||
};
|
||||
const stream = new TelegramDeliveryStream({
|
||||
update: updateDraft,
|
||||
finish: (block) => this.#sendRich(target, block),
|
||||
fail: (block) => this.#sendPlain(target, block.text),
|
||||
presentation: 'telegram-rich-draft',
|
||||
logger: this.#logger,
|
||||
});
|
||||
await stream.update(createTextDeliveryBlock('正在处理…', 'plain'));
|
||||
return stream;
|
||||
}
|
||||
|
||||
const placeholder = await this.#api.sendMessage({
|
||||
chatId: target.chatId,
|
||||
text: '正在处理…',
|
||||
replyToMessageId: target.replyToMessageId,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
const messageId = placeholder?.message_id;
|
||||
if (!Number.isSafeInteger(messageId)) {
|
||||
throw new Error('Telegram did not return a placeholder message id');
|
||||
}
|
||||
return new TelegramDeliveryStream({
|
||||
update: async (block) => {
|
||||
if (block.format === 'plain') {
|
||||
await this.#api.editMessageText({
|
||||
chatId: target.chatId,
|
||||
messageId,
|
||||
text: block.text,
|
||||
signal: this.#signal,
|
||||
});
|
||||
return deliveryResult('text-fallback', [String(messageId)]);
|
||||
}
|
||||
await this.#api.editMessageText({
|
||||
chatId: target.chatId,
|
||||
messageId,
|
||||
richMessage: { markdown: toTelegramRichMarkdown(block.text) },
|
||||
signal: this.#signal,
|
||||
});
|
||||
return deliveryResult('telegram-rich-draft', [String(messageId)]);
|
||||
},
|
||||
finish: (block) => this.#editRich(target, messageId, block),
|
||||
fail: (block) => this.#sendPlain(target, block.text, {
|
||||
placeholderMessageId: messageId,
|
||||
}),
|
||||
providerMessageIds: [String(messageId)],
|
||||
presentation: 'telegram-regular',
|
||||
logger: this.#logger,
|
||||
});
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const stream = createEditableMessageStream({
|
||||
limit: 4_000,
|
||||
|
|
@ -360,7 +716,11 @@ export class TelegramRuntime {
|
|||
error,
|
||||
);
|
||||
}
|
||||
const client = new TelegramBotClient({ api, signal: controller.signal });
|
||||
const client = new TelegramBotClient({
|
||||
api,
|
||||
signal: controller.signal,
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#bridge = new TelegramHarnessBridge({
|
||||
bot: client,
|
||||
harness: this.#harness,
|
||||
|
|
|
|||
|
|
@ -170,9 +170,15 @@ test('Slack, Telegram, and Discord stream receipts retain the initial and remain
|
|||
key: 'telegram-stream',
|
||||
bot: telegram,
|
||||
answer: 'T'.repeat(4_001),
|
||||
replyTarget: { chatId: 42, replyToMessageId: 7, messageThreadId: 8 },
|
||||
replyTarget: {
|
||||
chatId: 42,
|
||||
chatType: 'supergroup',
|
||||
replyToMessageId: 7,
|
||||
messageThreadId: 8,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(telegramReceipt.providerMessageIds, ['301', '302']);
|
||||
assert.deepEqual(telegramReceipt.providerMessageIds, ['301']);
|
||||
assert.equal(telegramReceipt.presentation, 'telegram-rich-final');
|
||||
|
||||
let discordId = 0;
|
||||
const discord = new DiscordBotClient({
|
||||
|
|
|
|||
824
test/channels/telegram/rich-message.test.mjs
Normal file
824
test/channels/telegram/rich-message.test.mjs
Normal file
|
|
@ -0,0 +1,824 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import { TelegramApi } from '../../../src/channels/telegram/telegram-api.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
import {
|
||||
splitTelegramRegularText,
|
||||
splitTelegramRichMarkdown,
|
||||
toTelegramRichMarkdown,
|
||||
} from '../../../src/channels/telegram/telegram-rich-message.mjs';
|
||||
import { TelegramBotClient } from '../../../src/channels/telegram/telegram-runtime.mjs';
|
||||
|
||||
const TOKEN = '123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456';
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function rejected(code = 400) {
|
||||
const error = new Error(`Telegram ${code}`);
|
||||
error.code = `telegram-${code}`;
|
||||
error.status = code;
|
||||
error.providerCode = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function uncertain(code = 'telegram-timeout') {
|
||||
const error = new Error('Telegram delivery is uncertain');
|
||||
error.code = code;
|
||||
error.deliveryOutcome = 'unknown';
|
||||
return error;
|
||||
}
|
||||
|
||||
function memoryState() {
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
return {
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, value) => sessions.set(key, value),
|
||||
clearSession: async (key) => sessions.delete(key),
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
};
|
||||
}
|
||||
|
||||
async function committedArtifact(t) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-rich-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => 'telegram-rich-artifact' });
|
||||
t.after(() => registry.clear());
|
||||
await writeFile(join(workspace, 'result.txt'), 'rich result');
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'session-rich-artifact', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: 'rpc-rich-artifact' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'call-rich-artifact',
|
||||
rootCallId: 'call-rich-artifact',
|
||||
token: Symbol('call-rich-artifact'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: 'result.txt' }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take('session-rich-artifact', 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('Telegram Rich Markdown keeps structure while escaping raw and encoded HTML', () => {
|
||||
const source = [
|
||||
'# 标题 😀',
|
||||
'',
|
||||
'- 第一项',
|
||||
' - 嵌套项',
|
||||
'',
|
||||
'```js',
|
||||
'const tag = "<script>";',
|
||||
'```',
|
||||
'',
|
||||
'| 名称 | 值 |',
|
||||
'| --- | --- |',
|
||||
'| 公式 | $E = mc^2$ |',
|
||||
'',
|
||||
'[链接](https://example.com/?a=1&b=2)',
|
||||
'<tg-thinking>hidden</tg-thinking>',
|
||||
'<tg-thinking>encoded</tg-thinking>',
|
||||
].join('\n');
|
||||
|
||||
const rich = toTelegramRichMarkdown(source);
|
||||
assert.match(rich, /^# 标题 😀/);
|
||||
assert.match(rich, /```js/);
|
||||
assert.match(rich, /\| 公式 \| \$E = mc\^2\$ \|/);
|
||||
assert.match(rich, /\[链接\]\(https:\/\/example\.com\/\?a=1&b=2\)/);
|
||||
assert.doesNotMatch(rich, /<tg-thinking>/);
|
||||
assert.match(rich, /<tg-thinking>hidden<\/tg-thinking>/);
|
||||
assert.match(rich, /&lt;tg-thinking&gt;/);
|
||||
});
|
||||
|
||||
test('Telegram splitters preserve every space, newline, Emoji, and long fenced-code character', () => {
|
||||
const body = ` const emoji = '😀';\n\n${'中'.repeat(33_000)} \n`;
|
||||
const source = ` ## 代码 😀\n\n\`\`\`js\n${body}\`\`\`\n\n 尾部 \n`;
|
||||
const chunks = splitTelegramRichMarkdown(source);
|
||||
|
||||
assert.ok(chunks.length >= 2);
|
||||
assert.ok(chunks.every((chunk) => Array.from(chunk.markdown).length <= 30_000));
|
||||
assert.equal(chunks.map((chunk) => chunk.source).join(''), source);
|
||||
assert.ok(chunks.filter((chunk) => chunk.markdown.includes('```js\n')).length >= 2);
|
||||
assert.equal(chunks.some((chunk) => chunk.source.includes('😀')), true);
|
||||
assert.throws(
|
||||
() => splitTelegramRichMarkdown('```js\nunfinished'),
|
||||
/unfinished code fence/,
|
||||
);
|
||||
const plain = ` 开头 😀\n\n${'中'.repeat(4_001)} \n尾部 `;
|
||||
const plainChunks = splitTelegramRegularText(plain);
|
||||
assert.equal(plainChunks.map((chunk) => chunk).join(''), plain);
|
||||
assert.ok(plainChunks.every((chunk) => Array.from(chunk).length <= 4_000));
|
||||
});
|
||||
|
||||
test('Telegram API uses the documented Rich Message, Draft, and rich edit fields', async () => {
|
||||
const calls = [];
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ method: url.pathname.split('/').at(-1), body: JSON.parse(options.body) });
|
||||
return jsonResponse({ ok: true, result: { message_id: 701 } });
|
||||
},
|
||||
});
|
||||
|
||||
await api.sendRichMessage({
|
||||
chatId: -100123,
|
||||
richMessage: { markdown: '# final' },
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
});
|
||||
await api.sendRichMessageDraft({
|
||||
chatId: 42,
|
||||
draftId: 9,
|
||||
richMessage: { markdown: 'partial' },
|
||||
messageThreadId: 66,
|
||||
});
|
||||
await api.editMessageText({
|
||||
chatId: -100123,
|
||||
messageId: 701,
|
||||
richMessage: { markdown: '# edited' },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{
|
||||
method: 'sendRichMessage',
|
||||
body: {
|
||||
chat_id: -100123,
|
||||
rich_message: { markdown: '# final' },
|
||||
reply_parameters: { message_id: 44, allow_sending_without_reply: true },
|
||||
message_thread_id: 55,
|
||||
},
|
||||
}, {
|
||||
method: 'sendRichMessageDraft',
|
||||
body: {
|
||||
chat_id: 42,
|
||||
draft_id: 9,
|
||||
rich_message: { markdown: 'partial' },
|
||||
message_thread_id: 66,
|
||||
},
|
||||
}, {
|
||||
method: 'editMessageText',
|
||||
body: {
|
||||
chat_id: -100123,
|
||||
message_id: 701,
|
||||
rich_message: { markdown: '# edited' },
|
||||
},
|
||||
}]);
|
||||
await assert.rejects(() => api.sendRichMessage({
|
||||
chatId: 42,
|
||||
richMessage: { markdown: 'one', html: 'two' },
|
||||
}), /exactly one format/);
|
||||
await assert.rejects(() => api.sendRichMessageDraft({
|
||||
chatId: 42,
|
||||
draftId: 0,
|
||||
richMessage: { markdown: 'draft' },
|
||||
}), /non-zero integer/);
|
||||
});
|
||||
|
||||
test('Telegram private stream reuses one non-zero Draft id and persists one Rich final', async () => {
|
||||
const drafts = [];
|
||||
const finals = [];
|
||||
const plain = [];
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendRichMessageDraft: async (payload) => { drafts.push(payload); return true; },
|
||||
sendRichMessage: async (payload) => {
|
||||
finals.push(payload);
|
||||
return { message_id: 801 };
|
||||
},
|
||||
sendMessage: async (payload) => { plain.push(payload); return { message_id: 802 }; },
|
||||
},
|
||||
logger: { warn() {} },
|
||||
});
|
||||
const target = {
|
||||
chatId: 42,
|
||||
chatType: 'private',
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 66,
|
||||
};
|
||||
|
||||
const stream = await client.openDeliveryStream(target);
|
||||
const draftResult = await stream.update({
|
||||
kind: 'text', text: '## partial', format: 'markdown',
|
||||
});
|
||||
const result = await stream.finish({ kind: 'text', text: '## final', format: 'markdown' });
|
||||
|
||||
assert.equal(drafts.length, 2);
|
||||
assert.ok(Number.isSafeInteger(drafts[0].draftId) && drafts[0].draftId !== 0);
|
||||
assert.equal(drafts[1].draftId, drafts[0].draftId);
|
||||
assert.deepEqual(drafts.map((draft) => draft.messageThreadId), [66, 66]);
|
||||
assert.equal(finals.length, 1);
|
||||
assert.equal(finals[0].replyToMessageId, 44);
|
||||
assert.equal(finals[0].messageThreadId, 66);
|
||||
assert.deepEqual(finals[0].richMessage, { markdown: '## final' });
|
||||
assert.equal(plain.length, 0);
|
||||
assert.deepEqual(draftResult, {
|
||||
presentation: 'telegram-rich-draft',
|
||||
providerMessageIds: [],
|
||||
deliveryOutcome: 'sent',
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
presentation: 'telegram-rich-final',
|
||||
providerMessageIds: ['801'],
|
||||
deliveryOutcome: 'sent',
|
||||
});
|
||||
});
|
||||
|
||||
test('Telegram group and Topic stream keeps one placeholder and finalizes it in place', async () => {
|
||||
const created = [];
|
||||
const edited = [];
|
||||
const drafts = [];
|
||||
const richSends = [];
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendMessage: async (payload) => { created.push(payload); return { message_id: 901 }; },
|
||||
editMessageText: async (payload) => { edited.push(payload); return { message_id: 901 }; },
|
||||
sendRichMessageDraft: async (payload) => { drafts.push(payload); return true; },
|
||||
sendRichMessage: async (payload) => { richSends.push(payload); return { message_id: 902 }; },
|
||||
},
|
||||
});
|
||||
const target = {
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
};
|
||||
|
||||
const stream = await client.openDeliveryStream(target);
|
||||
await stream.update({ kind: 'text', text: '正在使用工具…', format: 'plain' });
|
||||
await stream.update({ kind: 'text', text: '## partial', format: 'markdown' });
|
||||
const result = await stream.finish({ kind: 'text', text: '## final', format: 'markdown' });
|
||||
|
||||
assert.equal(created.length, 1);
|
||||
assert.equal(created[0].replyToMessageId, 44);
|
||||
assert.equal(created[0].messageThreadId, 55);
|
||||
assert.equal(drafts.length, 0);
|
||||
assert.equal(richSends.length, 0);
|
||||
assert.equal(edited.length, 3);
|
||||
assert.equal(edited[0].text, '正在使用工具…');
|
||||
assert.deepEqual(edited[1].richMessage, { markdown: '## partial' });
|
||||
assert.deepEqual(edited[2].richMessage, { markdown: '## final' });
|
||||
assert.ok(edited.every((call) => call.messageId === 901));
|
||||
assert.deepEqual(result, {
|
||||
presentation: 'telegram-rich-final',
|
||||
providerMessageIds: ['901'],
|
||||
deliveryOutcome: 'sent',
|
||||
});
|
||||
});
|
||||
|
||||
test('Telegram long Rich final records the placeholder and every remainder message id', async () => {
|
||||
const edits = [];
|
||||
const remainders = [];
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendMessage: async () => ({ message_id: 951 }),
|
||||
editMessageText: async (payload) => {
|
||||
edits.push(payload);
|
||||
return { message_id: 951 };
|
||||
},
|
||||
sendRichMessage: async (payload) => {
|
||||
remainders.push(payload);
|
||||
return { message_id: 952 + remainders.length };
|
||||
},
|
||||
},
|
||||
});
|
||||
const stream = await client.openDeliveryStream({
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
});
|
||||
const answer = `## long\n\n${'中'.repeat(31_000)} 😀`;
|
||||
const result = await stream.finish({ kind: 'text', text: answer, format: 'markdown' });
|
||||
|
||||
assert.equal(edits.length, 1);
|
||||
assert.ok(remainders.length >= 1);
|
||||
assert.ok(remainders.every((call) => call.messageThreadId === 55));
|
||||
assert.deepEqual(result.providerMessageIds, [
|
||||
'951',
|
||||
...remainders.map((_, index) => String(953 + index)),
|
||||
]);
|
||||
assert.equal(result.deliveryOutcome, 'sent');
|
||||
});
|
||||
|
||||
test('Telegram final delivery degrades a definite Rich rejection directly to plain text', async () => {
|
||||
const calls = [];
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendRichMessage: async (payload) => {
|
||||
calls.push({ method: 'rich', payload });
|
||||
throw rejected(400);
|
||||
},
|
||||
sendMessage: async (payload) => {
|
||||
calls.push({ method: 'plain', payload });
|
||||
return { message_id: 1001 };
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await client.sendDelivery({
|
||||
chatId: 42,
|
||||
chatType: 'private',
|
||||
replyToMessageId: 44,
|
||||
}, { kind: 'text', text: '<b>**answer**</b>', format: 'markdown' });
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.method), ['rich', 'plain']);
|
||||
assert.equal(calls[0].payload.richMessage.markdown, '<b>**answer**</b>');
|
||||
assert.equal(calls[1].payload.text, '<b>**answer**</b>');
|
||||
assert.ok(calls.every((call) => call.payload.replyToMessageId === 44));
|
||||
assert.deepEqual(result, {
|
||||
presentation: 'text-fallback',
|
||||
providerMessageIds: ['1001'],
|
||||
deliveryOutcome: 'sent',
|
||||
});
|
||||
});
|
||||
|
||||
test('Telegram conversion failure skips Rich and keeps the exact source in plain fallback', async () => {
|
||||
let richCalls = 0;
|
||||
const regular = [];
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendRichMessage: async () => { richCalls += 1; return { message_id: 1050 }; },
|
||||
sendMessage: async (payload) => { regular.push(payload); return { message_id: 1051 }; },
|
||||
},
|
||||
});
|
||||
const result = await client.sendDelivery({ chatId: 42, replyToMessageId: 44 }, {
|
||||
kind: 'text', text: '```js\nunfinished <tag>', format: 'markdown',
|
||||
});
|
||||
|
||||
assert.equal(richCalls, 0);
|
||||
assert.equal(regular.length, 1);
|
||||
assert.equal(regular[0].text, '```js\nunfinished <tag>');
|
||||
assert.equal(Object.hasOwn(regular[0], 'parseMode'), false);
|
||||
assert.equal(result.presentation, 'text-fallback');
|
||||
});
|
||||
|
||||
test('Telegram group fallback edits the same placeholder and never sends a second final', async () => {
|
||||
const edits = [];
|
||||
let sends = 0;
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendMessage: async () => { sends += 1; return { message_id: 1101 }; },
|
||||
editMessageText: async (payload) => {
|
||||
edits.push(payload);
|
||||
if (payload.richMessage) throw rejected(400);
|
||||
return { message_id: 1101 };
|
||||
},
|
||||
sendRichMessage: async () => assert.fail('short final must stay on the placeholder'),
|
||||
},
|
||||
});
|
||||
const stream = await client.openDeliveryStream({
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
});
|
||||
const result = await stream.finish({ kind: 'text', text: '# final', format: 'markdown' });
|
||||
|
||||
assert.equal(sends, 1);
|
||||
assert.equal(edits.length, 2);
|
||||
assert.ok(edits.every((edit) => edit.messageId === 1101));
|
||||
assert.deepEqual(edits.map((edit) => (edit.richMessage ? 'rich' : 'plain')), ['rich', 'plain']);
|
||||
assert.equal(result.presentation, 'text-fallback');
|
||||
assert.equal(result.deliveryOutcome, 'sent');
|
||||
assert.deepEqual(result.providerMessageIds, ['1101']);
|
||||
});
|
||||
|
||||
test('Telegram replaces a rejected placeholder or records every failed terminalization attempt', async () => {
|
||||
const sends = [];
|
||||
const edits = [];
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendMessage: async (payload) => {
|
||||
sends.push(payload);
|
||||
return { message_id: sends.length === 1 ? 1111 : 1112 };
|
||||
},
|
||||
editMessageText: async (payload) => {
|
||||
edits.push(payload);
|
||||
if (payload.richMessage || payload.text === '# final') throw rejected(400);
|
||||
return { message_id: 1111 };
|
||||
},
|
||||
},
|
||||
logger: { warn() {} },
|
||||
});
|
||||
const stream = await client.openDeliveryStream({
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
});
|
||||
const result = await stream.finish({ kind: 'text', text: '# final', format: 'markdown' });
|
||||
|
||||
assert.deepEqual(sends.map((call) => call.text), ['正在处理…', '# final']);
|
||||
assert.deepEqual(edits.map((call) => (
|
||||
call.richMessage ? 'rich' : call.text
|
||||
)), ['rich', '# final', '回复已发送。']);
|
||||
assert.deepEqual(result.providerMessageIds, ['1111', '1112']);
|
||||
assert.equal(result.deliveryOutcome, 'sent');
|
||||
|
||||
const failedEdits = [];
|
||||
let failedSends = 0;
|
||||
const failedClient = new TelegramBotClient({
|
||||
api: {
|
||||
sendMessage: async (payload) => {
|
||||
failedSends += 1;
|
||||
if (failedSends === 1) return { message_id: 1121 };
|
||||
throw rejected(403);
|
||||
},
|
||||
editMessageText: async (payload) => {
|
||||
failedEdits.push(payload);
|
||||
throw rejected(403);
|
||||
},
|
||||
},
|
||||
logger: { warn() {} },
|
||||
});
|
||||
const failedStream = await failedClient.openDeliveryStream({
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
messageThreadId: 55,
|
||||
});
|
||||
const failedResult = await failedStream.finish({
|
||||
kind: 'text', text: '# final', format: 'markdown',
|
||||
});
|
||||
|
||||
assert.equal(failedSends, 2);
|
||||
assert.deepEqual(failedEdits.map((call) => (
|
||||
call.richMessage ? 'rich' : call.text
|
||||
)), ['rich', '# final', '消息发送失败,请稍后重试。']);
|
||||
assert.equal(failedResult.deliveryOutcome, 'failed');
|
||||
assert.deepEqual(failedResult.providerMessageIds, ['1121']);
|
||||
});
|
||||
|
||||
test('Telegram uncertain final delivery records unknown and never sends a fallback copy', async () => {
|
||||
let fallbacks = 0;
|
||||
const direct = new TelegramBotClient({
|
||||
api: {
|
||||
sendRichMessage: async () => { throw uncertain(); },
|
||||
sendMessage: async () => { fallbacks += 1; return { message_id: 1201 }; },
|
||||
},
|
||||
});
|
||||
const directResult = await direct.sendDelivery({
|
||||
chatId: 42,
|
||||
chatType: 'private',
|
||||
replyToMessageId: 44,
|
||||
}, { kind: 'text', text: '# final', format: 'markdown' });
|
||||
assert.equal(fallbacks, 0);
|
||||
assert.equal(directResult.deliveryOutcome, 'unknown');
|
||||
assert.equal(directResult.reason, 'telegram-timeout');
|
||||
|
||||
const edits = [];
|
||||
const group = new TelegramBotClient({
|
||||
api: {
|
||||
sendMessage: async () => ({ message_id: 1202 }),
|
||||
editMessageText: async (payload) => {
|
||||
edits.push(payload);
|
||||
throw uncertain('telegram-500');
|
||||
},
|
||||
sendRichMessage: async () => assert.fail('must not send a fallback final'),
|
||||
},
|
||||
});
|
||||
const stream = await group.openDeliveryStream({
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
messageThreadId: 55,
|
||||
});
|
||||
const groupResult = await stream.finish({ kind: 'text', text: '# final', format: 'markdown' });
|
||||
assert.equal(edits.length, 1);
|
||||
assert.equal(groupResult.deliveryOutcome, 'unknown');
|
||||
assert.deepEqual(groupResult.providerMessageIds, ['1202']);
|
||||
});
|
||||
|
||||
test('Telegram distinguishes pre-dispatch abort from an uncertain in-flight abort', async () => {
|
||||
const before = new AbortController();
|
||||
const beforeReason = new DOMException('stopped before dispatch', 'AbortError');
|
||||
before.abort(beforeReason);
|
||||
let beforeCalls = 0;
|
||||
const beforeClient = new TelegramBotClient({
|
||||
api: new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => {
|
||||
beforeCalls += 1;
|
||||
return jsonResponse({ ok: true, result: { message_id: 1251 } });
|
||||
},
|
||||
}),
|
||||
signal: before.signal,
|
||||
});
|
||||
const beforeResult = await beforeClient.sendDelivery({ chatId: 42 }, {
|
||||
kind: 'text', text: '# final', format: 'markdown',
|
||||
});
|
||||
assert.equal(beforeCalls, 0);
|
||||
assert.equal(beforeResult.deliveryOutcome, 'failed');
|
||||
|
||||
const after = new AbortController();
|
||||
let afterCalls = 0;
|
||||
let dispatchStarted;
|
||||
const started = new Promise((resolve) => { dispatchStarted = resolve; });
|
||||
const afterClient = new TelegramBotClient({
|
||||
api: new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (_url, { signal }) => {
|
||||
afterCalls += 1;
|
||||
dispatchStarted();
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
}),
|
||||
signal: after.signal,
|
||||
});
|
||||
const inFlight = afterClient.sendDelivery({ chatId: 42 }, {
|
||||
kind: 'text', text: '# final', format: 'markdown',
|
||||
});
|
||||
await started;
|
||||
after.abort(new DOMException('stopped after dispatch', 'AbortError'));
|
||||
const afterResult = await inFlight;
|
||||
assert.equal(afterCalls, 1);
|
||||
assert.equal(afterResult.deliveryOutcome, 'unknown');
|
||||
assert.equal(afterResult.reason, 'telegram-aborted-after-dispatch');
|
||||
});
|
||||
|
||||
test('Telegram keeps an uncertain Rich receipt and still delivers the registered result file', async (t) => {
|
||||
const artifact = await committedArtifact(t);
|
||||
const sentFiles = [];
|
||||
const stream = {
|
||||
providerMessageIds: [],
|
||||
async update() {},
|
||||
async finish() {
|
||||
return {
|
||||
presentation: 'telegram-rich-final',
|
||||
providerMessageIds: [],
|
||||
deliveryOutcome: 'unknown',
|
||||
reason: 'telegram-timeout',
|
||||
};
|
||||
},
|
||||
cancel() {},
|
||||
};
|
||||
const bridge = new TelegramHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => assert.fail('must not replace an uncertain Rich final'),
|
||||
sendTyping: async () => {},
|
||||
openDeliveryStream: async () => stream,
|
||||
sendFile: async (_target, file) => {
|
||||
sentFiles.push(file.fileName);
|
||||
return { message_id: 1302 };
|
||||
},
|
||||
},
|
||||
state: memoryState(),
|
||||
harness: {
|
||||
createSession: async () => 'session-rich-artifact',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '## final with file';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept({
|
||||
messageId: 'rich-artifact-inbound',
|
||||
senderId: 'rich-user',
|
||||
kind: 'direct',
|
||||
conversationId: 'rich-user-artifact',
|
||||
content: 'return a file',
|
||||
addressed: true,
|
||||
replyTarget: { chatId: 42, chatType: 'private', replyToMessageId: 44 },
|
||||
});
|
||||
|
||||
assert.deepEqual(sentFiles, ['result.txt']);
|
||||
assert.equal(receipt.presentation, 'telegram-text-and-files');
|
||||
assert.equal(receipt.deliveryOutcome, 'unknown');
|
||||
assert.equal(receipt.reason, 'telegram-timeout');
|
||||
assert.deepEqual(receipt.providerMessageIds, ['1302']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'telegram-rich-artifact', outcome: 'sent' }]);
|
||||
});
|
||||
|
||||
test('Telegram Rich text and a result file keep the placeholder and file message ids', async (t) => {
|
||||
const artifact = await committedArtifact(t);
|
||||
const sent = [];
|
||||
const edited = [];
|
||||
const files = [];
|
||||
let prompts = 0;
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendChatAction: async () => true,
|
||||
sendMessage: async (payload) => {
|
||||
sent.push(payload);
|
||||
return { message_id: 1351 };
|
||||
},
|
||||
editMessageText: async (payload) => {
|
||||
edited.push(payload);
|
||||
return { message_id: 1351 };
|
||||
},
|
||||
sendDocument: async (payload) => {
|
||||
files.push(payload);
|
||||
return { message_id: 1352 };
|
||||
},
|
||||
},
|
||||
});
|
||||
const bridge = new TelegramHarnessBridge({
|
||||
bot: client,
|
||||
state: memoryState(),
|
||||
harness: {
|
||||
createSession: async () => 'session-rich-mixed',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
prompts += 1;
|
||||
await options.onArtifact(artifact);
|
||||
return '## final with file';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept({
|
||||
messageId: 'rich-mixed-inbound',
|
||||
senderId: 'rich-user',
|
||||
kind: 'group',
|
||||
conversationId: '-100123:55',
|
||||
content: 'return text and a file',
|
||||
addressed: true,
|
||||
replyTarget: {
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prompts, 1);
|
||||
assert.deepEqual(sent.map((call) => call.text), ['正在处理…']);
|
||||
assert.deepEqual(edited.map((call) => call.richMessage), [{ markdown: '## final with file' }]);
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0].replyToMessageId, 44);
|
||||
assert.equal(files[0].messageThreadId, 55);
|
||||
assert.equal(receipt.presentation, 'telegram-text-and-files');
|
||||
assert.equal(receipt.deliveryOutcome, 'sent');
|
||||
assert.deepEqual(receipt.providerMessageIds, ['1351', '1352']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'telegram-rich-artifact', outcome: 'sent' }]);
|
||||
});
|
||||
|
||||
test('shared bridge marks assistant updates and final as markdown but status as plain', async () => {
|
||||
const blocks = [];
|
||||
const stream = {
|
||||
providerMessageIds: ['1301'],
|
||||
async update(block) { blocks.push(block); },
|
||||
async finish(block) {
|
||||
blocks.push(block);
|
||||
return {
|
||||
presentation: 'telegram-rich-final',
|
||||
providerMessageIds: ['1301'],
|
||||
deliveryOutcome: 'sent',
|
||||
};
|
||||
},
|
||||
cancel() {},
|
||||
};
|
||||
const bridge = new TelegramHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => assert.fail('semantic final must not use the legacy path'),
|
||||
sendTyping: async () => {},
|
||||
openDeliveryStream: async () => stream,
|
||||
},
|
||||
state: memoryState(),
|
||||
harness: {
|
||||
createSession: async () => 'session-rich',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onUpdate({ type: 'tool', name: '搜索' });
|
||||
await options.onUpdate({ type: 'text', text: '## partial' });
|
||||
return '## final';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept({
|
||||
messageId: 'rich-inbound',
|
||||
senderId: 'rich-user',
|
||||
kind: 'direct',
|
||||
conversationId: 'rich-user',
|
||||
content: 'answer richly',
|
||||
addressed: true,
|
||||
replyTarget: { chatId: 42, chatType: 'private', replyToMessageId: 44 },
|
||||
});
|
||||
|
||||
assert.deepEqual(blocks, [{
|
||||
kind: 'text', text: '正在使用搜索…', format: 'plain',
|
||||
}, {
|
||||
kind: 'text', text: '## partial', format: 'markdown',
|
||||
}, {
|
||||
kind: 'text', text: '## final', format: 'markdown',
|
||||
}]);
|
||||
assert.equal(receipt.presentation, 'telegram-rich-final');
|
||||
assert.equal(receipt.deliveryOutcome, 'sent');
|
||||
});
|
||||
|
||||
test('Telegram stop runs one Prompt and replaces the group placeholder with one terminal message', async () => {
|
||||
const sent = [];
|
||||
const edits = [];
|
||||
let prompts = 0;
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendChatAction: async () => true,
|
||||
sendMessage: async (payload) => { sent.push(payload); return { message_id: 1391 }; },
|
||||
editMessageText: async (payload) => { edits.push(payload); return { message_id: 1391 }; },
|
||||
},
|
||||
});
|
||||
const bridge = new TelegramHarnessBridge({
|
||||
bot: client,
|
||||
state: memoryState(),
|
||||
harness: {
|
||||
createSession: async () => 'session-rich-stop',
|
||||
ask: async () => {
|
||||
prompts += 1;
|
||||
const error = new Error('stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept({
|
||||
messageId: 'rich-stop-inbound',
|
||||
senderId: 'rich-user',
|
||||
kind: 'group',
|
||||
conversationId: '-100123:55',
|
||||
content: 'stop safely',
|
||||
addressed: true,
|
||||
replyTarget: {
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prompts, 1);
|
||||
assert.deepEqual(sent.map((call) => call.text), ['正在处理…']);
|
||||
assert.deepEqual(edits.map((call) => call.text), ['已停止。']);
|
||||
assert.ok(edits.every((call) => !call.richMessage));
|
||||
});
|
||||
|
||||
test('Telegram group processing failure replaces its placeholder instead of leaving it stuck', async () => {
|
||||
const sent = [];
|
||||
const edits = [];
|
||||
let prompts = 0;
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendChatAction: async () => true,
|
||||
sendMessage: async (payload) => { sent.push(payload); return { message_id: 1401 }; },
|
||||
editMessageText: async (payload) => { edits.push(payload); return { message_id: 1401 }; },
|
||||
},
|
||||
});
|
||||
const bridge = new TelegramHarnessBridge({
|
||||
bot: client,
|
||||
state: memoryState(),
|
||||
logger: { warn() {}, error() {} },
|
||||
harness: {
|
||||
createSession: async () => 'session-rich-error',
|
||||
ask: async () => {
|
||||
prompts += 1;
|
||||
throw new Error('private Harness diagnostic');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept({
|
||||
messageId: 'rich-error-inbound',
|
||||
senderId: 'rich-user',
|
||||
kind: 'group',
|
||||
conversationId: '-100123:55',
|
||||
content: 'fail safely',
|
||||
addressed: true,
|
||||
replyTarget: {
|
||||
chatId: -100123,
|
||||
chatType: 'supergroup',
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prompts, 1);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].text, '正在处理…');
|
||||
assert.equal(edits.length, 1);
|
||||
assert.equal(edits[0].messageId, 1401);
|
||||
assert.equal(edits[0].text, '消息处理失败,请稍后重试。');
|
||||
assert.equal(Object.hasOwn(edits[0], 'richMessage'), false);
|
||||
});
|
||||
|
|
@ -952,6 +952,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(privateMessage.kind, 'direct');
|
||||
assert.equal(privateMessage.addressed, true);
|
||||
assert.equal(privateMessage.replyTarget.chatType, 'private');
|
||||
assert.deepEqual(privateMessage.connectionTestTarget, { chatId: 88, messageThreadId: undefined });
|
||||
|
||||
const groupMessage = normalizeTelegramUpdate({
|
||||
|
|
@ -967,6 +968,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
assert.equal(groupMessage.kind, 'group');
|
||||
assert.equal(groupMessage.addressed, true);
|
||||
assert.equal(groupMessage.content, 'run this');
|
||||
assert.equal(groupMessage.replyTarget.chatType, 'supergroup');
|
||||
|
||||
const topicOne = normalizeTelegramUpdate({
|
||||
update_id: 12,
|
||||
|
|
@ -1380,6 +1382,8 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
|
|||
const fakeApi = {
|
||||
getMe: async () => ({ id: 123456789, is_bot: true }),
|
||||
getWebhookInfo: async () => ({ url: '' }),
|
||||
setMyCommands: async () => true,
|
||||
setChatMenuButton: async () => true,
|
||||
getUpdates: async ({ timeout, signal }) => {
|
||||
if (timeout === 0) return [];
|
||||
if (!delivered) {
|
||||
|
|
@ -1391,6 +1395,8 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
|
|||
});
|
||||
},
|
||||
sendChatAction: async () => true,
|
||||
sendRichMessageDraft: async () => true,
|
||||
sendRichMessage: async () => ({ message_id: nextMessageId++ }),
|
||||
sendMessage: async () => ({ message_id: nextMessageId++ }),
|
||||
editMessageText: async () => true,
|
||||
};
|
||||
|
|
@ -1491,6 +1497,13 @@ test('Telegram runtime keeps polling while a Harness question waits for its answ
|
|||
if (text.includes('请选择测试环境')) questionSent.resolve();
|
||||
return { message_id: messageId };
|
||||
},
|
||||
sendRichMessageDraft: async () => true,
|
||||
sendRichMessage: async ({ richMessage }) => {
|
||||
if (richMessage.markdown === '已选择生产环境') finalReplySent.resolve();
|
||||
const messageId = nextOutboundMessageId;
|
||||
nextOutboundMessageId += 1;
|
||||
return { message_id: messageId };
|
||||
},
|
||||
editMessageText: async ({ text }) => {
|
||||
if (text === '已选择生产环境') finalReplySent.resolve();
|
||||
return true;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue