Files
Evandro Fonseca Junior d1d04001d0
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s
chore: fork @llblab/[email protected] as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
2026-07-15 14:18:16 -03:00

916 lines
28 KiB
TypeScript

/**
* Telegram reply delivery helpers
* Zones: telegram outbound, native rich markdown, UI/compat rendering transport
* Owns native assistant replies, rendered UI delivery, reply transport wiring, and plain text replies
*/
import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
import {
getTelegramTargetThreadParams,
type TelegramTarget,
} from "./target.ts";
import type {
TelegramInputRichMessage,
TelegramReplyParameters,
TelegramSendRichMessageBody,
TelegramSentMessage,
} from "./telegram-api.ts";
import {
renderTelegramMessage,
type TelegramRenderedChunk,
type TelegramRenderMode,
} from "./rendering.ts";
export {
renderTelegramMessage,
type TelegramRenderedChunk,
type TelegramRenderMode,
};
export function renderTelegramMarkdownToHtmlDraft(markdown: string): string {
return renderTelegramMessage(markdown, { mode: "markdown" })
.map((chunk) => chunk.text)
.join("\n");
}
export const TELEGRAM_RICH_MESSAGE_MAX_CHARS = 32768;
export const TELEGRAM_RICH_MESSAGE_MAX_BLOCKS = 500;
// --- Reply Dedup ---
/** Non-persistent reply deduplication for a single agent turn.
* First reply to a prompt gets `reply_parameters.message_id`;
* subsequent replies in the same turn skip it to avoid stacking
* duplicate reply headers in the chat viewport. */
export interface ReplyDedupRuntime {
/** Returns true if this is the first reply for the given prompt
* message id in the current turn. Side-effect: marks it replied. */
shouldReply(promptMessageId: number): boolean;
/** Reset the tracker when a new prompt enters the queue. */
reset(): void;
}
export function createReplyDedupRuntime(): ReplyDedupRuntime {
const replied = new Map<number, boolean>();
return {
shouldReply(promptMessageId: number): boolean {
if (replied.has(promptMessageId)) return false;
replied.set(promptMessageId, true);
return true;
},
reset(): void {
replied.clear();
},
};
}
// --- Transport-level dedup ---
const lastRepliedToMessageIdByTarget = new Map<string, number>();
function getReplyDedupTargetKey(
chatId: number,
target?: TelegramTarget,
): string {
const threadId = target?.threadId;
return typeof threadId === "number"
? `${chatId}:thread:${threadId}`
: `${chatId}:private`;
}
export function resetTransportReplyDedup(): void {
lastRepliedToMessageIdByTarget.clear();
}
export function buildTelegramReplyParameters(
chatId: number,
messageId: number | undefined,
target?: TelegramTarget,
): TelegramReplyParameters | undefined {
if (messageId === undefined || messageId <= 0) return undefined;
const key = getReplyDedupTargetKey(chatId, target);
if (lastRepliedToMessageIdByTarget.get(key) === messageId) {
return undefined;
}
lastRepliedToMessageIdByTarget.set(key, messageId);
return {
message_id: messageId,
allow_sending_without_reply: true,
};
}
export function buildTelegramMultipartReplyParameters(
chatId: number,
messageId: number | undefined,
target?: TelegramTarget,
): string | undefined {
const parameters = buildTelegramReplyParameters(chatId, messageId, target);
return parameters ? JSON.stringify(parameters) : undefined;
}
function getAgentMessageField(message: unknown, field: string): unknown {
if (typeof message !== "object" || message === null || !(field in message)) {
return undefined;
}
return Reflect.get(message, field);
}
export function isAssistantAgentMessage(message: unknown): boolean {
return getAgentMessageField(message, "role") === "assistant";
}
function extractAgentTextContent(content: unknown): string {
const blocks = Array.isArray(content) ? content : [];
return blocks
.filter(
(block): block is { type: string; text?: string } =>
typeof block === "object" && block !== null && "type" in block,
)
.filter((block) => block.type === "text" && typeof block.text === "string")
.map((block) => block.text as string)
.join("")
.trim();
}
export function getAgentMessageText(message: unknown): string {
return extractAgentTextContent(getAgentMessageField(message, "content"));
}
export function extractLatestAssistantMessageText(
messages: readonly unknown[],
): {
text?: string;
stopReason?: string;
errorMessage?: string;
} {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message || !isAssistantAgentMessage(message)) continue;
const rawStopReason = getAgentMessageField(message, "stopReason");
const rawErrorMessage = getAgentMessageField(message, "errorMessage");
const stopReason =
typeof rawStopReason === "string" ? rawStopReason : undefined;
const errorMessage =
typeof rawErrorMessage === "string" ? rawErrorMessage : undefined;
const text = getAgentMessageText(message);
return { text: text || undefined, stopReason, errorMessage };
}
return {};
}
export interface TelegramReplyOwnershipRecorder {
record: (input: {
chatId: number;
messageId: number;
target?: TelegramTarget;
}) => void;
}
export interface TelegramReplyDeliveryDeps<TReplyMarkup> {
recordOwnership?: TelegramReplyOwnershipRecorder["record"];
sendMessage: (body: {
chat_id: number;
text: string;
parse_mode?: "HTML";
reply_markup?: TReplyMarkup;
reply_parameters?: TelegramReplyParameters;
reply_to_message_id?: number;
message_thread_id?: number;
}) => Promise<TelegramSentMessage>;
editMessage: (body: {
chat_id: number;
message_id: number;
text?: string;
rich_message?: TelegramInputRichMessage;
parse_mode?: "HTML";
reply_markup?: TReplyMarkup;
message_thread_id?: number;
}) => Promise<unknown>;
}
export interface TelegramReplyTargetOptions {
target?: TelegramTarget;
replyToMessageId?: number;
}
export interface TelegramReplyTransport<TReplyMarkup> {
sendRenderedChunks: (
chatId: number,
chunks: TelegramRenderedChunk[],
options?: TelegramReplyTargetOptions & {
replyMarkup?: TReplyMarkup;
},
) => Promise<number | undefined>;
editRenderedMessage: (
chatId: number,
messageId: number,
chunks: TelegramRenderedChunk[],
options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined>;
}
export function buildTelegramReplyTransport<TReplyMarkup>(
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
): TelegramReplyTransport<TReplyMarkup> {
return {
sendRenderedChunks: async (chatId, chunks, options) => {
return sendTelegramRenderedChunks(chatId, chunks, deps, options);
},
editRenderedMessage: async (chatId, messageId, chunks, options) => {
return editTelegramRenderedMessage(
chatId,
messageId,
chunks,
deps,
options,
);
},
};
}
export async function sendTelegramRenderedChunks<TReplyMarkup>(
chatId: number,
chunks: TelegramRenderedChunk[],
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
options?: TelegramReplyTargetOptions & {
replyMarkup?: TReplyMarkup;
},
): Promise<number | undefined> {
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
let lastMessageId: number | undefined;
for (const [index, chunk] of chunks.entries()) {
const replyParameters =
index === 0
? buildTelegramReplyParameters(
chatId,
options?.replyToMessageId,
options?.target,
)
: undefined;
const body = {
chat_id: chatId,
text: chunk.text,
parse_mode: chunk.parseMode,
reply_markup:
index === chunks.length - 1 ? options?.replyMarkup : undefined,
...(replyParameters ? { reply_parameters: replyParameters } : {}),
...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
};
const sent = await deps.sendMessage(body);
lastMessageId = sent.message_id;
deps.recordOwnership?.({
chatId,
messageId: sent.message_id,
target: options?.target,
});
}
return lastMessageId;
}
export async function editTelegramRenderedMessage<TReplyMarkup>(
chatId: number,
messageId: number,
chunks: TelegramRenderedChunk[],
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
): Promise<number | undefined> {
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
if (chunks.length === 0) return messageId;
const [firstChunk, ...remainingChunks] = chunks;
deps.recordOwnership?.({ chatId, messageId, target: options?.target });
await deps.editMessage({
chat_id: chatId,
message_id: messageId,
text: firstChunk.text,
parse_mode: firstChunk.parseMode,
reply_markup:
remainingChunks.length === 0 ? options?.replyMarkup : undefined,
...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
});
if (remainingChunks.length > 0) {
return sendTelegramRenderedChunks(chatId, remainingChunks, deps, {
replyMarkup: options?.replyMarkup,
target: options?.target,
});
}
return messageId;
}
export interface TelegramTextReplyOptions extends TelegramReplyTargetOptions {
parseMode?: "HTML";
}
export interface TelegramReplyRuntimeDeps<TReplyMarkup = unknown> {
renderTelegramMessage: (
text: string,
options?: { mode?: TelegramRenderMode },
) => TelegramRenderedChunk[];
sendRenderedChunks: (
chunks: TelegramRenderedChunk[],
options?: { replyMarkup?: TReplyMarkup } & TelegramReplyTargetOptions,
) => Promise<number | undefined>;
}
export async function sendTelegramPlainReply(
text: string,
deps: TelegramReplyRuntimeDeps,
options?: TelegramTextReplyOptions,
): Promise<number | undefined> {
const chunks = deps.renderTelegramMessage(text, {
mode: options?.parseMode === "HTML" ? "html" : "plain",
});
return deps.sendRenderedChunks(chunks, {
target: options?.target,
replyToMessageId: options?.replyToMessageId,
});
}
function normalizeIndentedTelegramNativeMarkdownList(line: string): string {
return line.replace(
/^( +|\t+)([-*+] |\d+\. )/,
(_match, indent: string, marker: string) => {
const visibleIndent = indent
.replace(/ /g, "\u00A0")
.replace(/\t/g, "\u00A0\u00A0");
return `${visibleIndent}${marker}`;
},
);
}
function normalizeTelegramNativeMarkdownLine(line: string): string {
let result = normalizeIndentedTelegramNativeMarkdownList(
line.replace(/^( {0,3}>)[ \t]/, "$1"),
);
const codeSpans: string[] = [];
result = result.replace(/`+[^`]*`+/g, (code) => {
const token = `\u0000${codeSpans.length}\u0000`;
codeSpans.push(code);
return token;
});
result = result.replace(
/(^|[^\\$])\$([A-Z][A-Z0-9]{1,})(?!\$)(?=\b|[.,;:)/-])/g,
(_match, prefix: string, ticker: string) => `${prefix}\\$${ticker}`,
);
return result.replace(
/\u0000(\d+)\u0000/g,
(_match, index) => codeSpans[Number(index)] ?? "",
);
}
function hasClosingDisplayMathDelimiter(
lines: readonly string[],
startIndex: number,
): boolean {
let fence: { marker: "`" | "~"; length: number } | undefined;
for (let index = startIndex + 1; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!fence && line.trim() === "$$") return true;
if (!fence && fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
continue;
}
if (
fence &&
new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
) {
fence = undefined;
}
}
return false;
}
export function normalizeTelegramNativeMarkdown(markdown: string): string {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
let fence: { marker: "`" | "~"; length: number } | undefined;
let displayMath = false;
return lines
.map((line, index) => {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
const inFence = fence !== undefined;
if (!inFence && line.trim() === "$$") {
if (displayMath) {
displayMath = false;
return "```";
}
if (hasClosingDisplayMathDelimiter(lines, index)) {
displayMath = true;
return "```math";
}
}
if (displayMath) return line;
if (!inFence && fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
fence = {
marker: markerText[0] as "`" | "~",
length: markerText.length,
};
return line;
}
if (
inFence &&
new RegExp(`^ {0,3}${fence?.marker}{${fence?.length},}\\s*$`).test(line)
) {
fence = undefined;
return line;
}
if (!inFence) return normalizeTelegramNativeMarkdownLine(line);
return line;
})
.join("\n");
}
export function splitTelegramNativeMarkdown(markdown: string): string[] {
const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown);
if (
normalizedMarkdown.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
countTelegramNativeMarkdownBlocks(normalizedMarkdown) <=
TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
) {
return [normalizedMarkdown];
}
const chunks: string[] = [];
let current = "";
let currentBlockCount = 0;
for (const rawBlock of splitTelegramNativeMarkdownBlocks(
normalizedMarkdown,
)) {
for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
const blockCount = countTelegramNativeMarkdownBlocks(block);
const candidate = current ? `${current}\n\n${block}` : block;
const exceedsChars = candidate.length > TELEGRAM_RICH_MESSAGE_MAX_CHARS;
const exceedsBlocks =
currentBlockCount + blockCount > TELEGRAM_RICH_MESSAGE_MAX_BLOCKS;
if (!exceedsChars && !exceedsBlocks) {
current = candidate;
currentBlockCount += blockCount;
continue;
}
if (current) chunks.push(current.trimEnd());
if (
block.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
blockCount <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
) {
current = block;
currentBlockCount = blockCount;
continue;
}
chunks.push(...splitTelegramNativeMarkdownLongBlock(block));
current = "";
currentBlockCount = 0;
}
}
if (current) chunks.push(current.trimEnd());
return chunks;
}
function splitTelegramNativeMarkdownBlocks(markdown: string): string[] {
const blocks: string[] = [];
const current: string[] = [];
let fence: { marker: "`" | "~"; length: number } | undefined;
const flush = (): void => {
if (current.length === 0) return;
blocks.push(current.join("\n"));
current.length = 0;
};
for (const line of markdown.split("\n")) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!fence && line.trim().length === 0) {
flush();
continue;
}
current.push(line);
if (!fence && fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
continue;
}
if (
fence &&
new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
) {
fence = undefined;
}
}
flush();
return blocks;
}
function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
if (
countTelegramNativeMarkdownBlocks(block) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
) {
return [block];
}
const chunks: string[] = [];
let current: string[] = [];
let fence: { marker: "`" | "~"; length: number } | undefined;
for (const line of block.split("\n")) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!fence && current.length >= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS) {
chunks.push(current.join("\n"));
current = [];
}
current.push(line);
if (!fence && fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
continue;
}
if (
fence &&
new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
) {
fence = undefined;
}
}
if (current.length > 0) chunks.push(current.join("\n"));
return chunks;
}
function countTelegramNativeMarkdownBlocks(block: string): number {
if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1;
const lines = block.split("\n").filter((line) => line.trim().length > 0);
if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
return Math.max(1, lines.length);
}
return 1;
}
function splitTelegramNativeMarkdownLongBlock(block: string): string[] {
return (
splitTelegramNativeMarkdownLongFenceBlock(block) ??
splitTelegramNativeMarkdownLongWrappedInlineBlock(block) ??
splitTelegramNativeMarkdownLongPlainBlock(block)
);
}
function splitTelegramNativeMarkdownLongPlainBlock(block: string): string[] {
const chunks: string[] = [];
let remaining = block;
while (remaining.length > TELEGRAM_RICH_MESSAGE_MAX_CHARS) {
const window = remaining.slice(0, TELEGRAM_RICH_MESSAGE_MAX_CHARS + 1);
const splitIndex = findTelegramNativeMarkdownSplitIndex(window);
chunks.push(remaining.slice(0, splitIndex).trimEnd());
remaining = remaining.slice(splitIndex).trimStart();
}
if (remaining.length > 0) chunks.push(remaining);
return chunks;
}
function splitTelegramNativeMarkdownLongFenceBlock(
block: string,
): string[] | undefined {
const lines = block.split("\n");
const opening = lines[0] ?? "";
const closing = lines[lines.length - 1] ?? "";
const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/);
if (!openingMatch || !closing || lines.length < 2) return undefined;
const markerText = openingMatch[1] ?? "```";
const marker = markerText[0] as "`" | "~";
if (
!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)
) {
return undefined;
}
const maxContentLength =
TELEGRAM_RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2;
if (maxContentLength <= 0) return undefined;
const content = lines.slice(1, -1).join("\n");
return splitTelegramNativeMarkdownWrappedContent(
content,
maxContentLength,
(chunk) =>
`${opening}\n${chunk}${chunk.endsWith("\n") ? "" : "\n"}${closing}`,
);
}
function splitTelegramNativeMarkdownLongWrappedInlineBlock(
block: string,
): string[] | undefined {
const delimiter = ["**", "__", "~~", "`", "*", "_"].find(
(candidate) =>
block.startsWith(candidate) &&
block.endsWith(candidate) &&
block.length > candidate.length * 2,
);
if (!delimiter) return undefined;
const maxContentLength =
TELEGRAM_RICH_MESSAGE_MAX_CHARS - delimiter.length * 2;
if (maxContentLength <= 0) return undefined;
return splitTelegramNativeMarkdownWrappedContent(
block.slice(delimiter.length, -delimiter.length),
maxContentLength,
(chunk) => `${delimiter}${chunk}${delimiter}`,
);
}
function splitTelegramNativeMarkdownWrappedContent(
content: string,
maxContentLength: number,
wrap: (chunk: string) => string,
): string[] {
const chunks: string[] = [];
let remaining = content;
while (remaining.length > maxContentLength) {
const window = remaining.slice(0, maxContentLength + 1);
const splitIndex = findTelegramNativeMarkdownSplitIndex(
window,
maxContentLength,
);
chunks.push(wrap(remaining.slice(0, splitIndex)));
remaining = remaining.slice(splitIndex);
}
if (remaining.length > 0) chunks.push(wrap(remaining));
return chunks;
}
function findTelegramNativeMarkdownSplitIndex(
text: string,
hardLimit = TELEGRAM_RICH_MESSAGE_MAX_CHARS,
): number {
const paragraphIndex = text.lastIndexOf("\n\n", hardLimit);
if (paragraphIndex > 0) return paragraphIndex + 2;
const lineIndex = text.lastIndexOf("\n", hardLimit);
if (lineIndex > 0) return lineIndex + 1;
const spaceIndex = text.lastIndexOf(" ", hardLimit);
if (spaceIndex > 0) return spaceIndex + 1;
return hardLimit;
}
export async function sendTelegramNativeMarkdownReply<TReplyMarkup = unknown>(
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
deps: {
recordOwnership?: TelegramReplyOwnershipRecorder["record"];
sendRichMessage: (
body: TelegramSendRichMessageBody,
) => Promise<TelegramSentMessage>;
},
options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
): Promise<number | undefined> {
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
let lastMessageId: number | undefined;
const chunks = splitTelegramNativeMarkdown(markdown);
for (const [index, chunk] of chunks.entries()) {
const replyParameters =
index === 0
? buildTelegramReplyParameters(
chatId,
replyToMessageId,
options?.target,
)
: undefined;
const sent = await deps.sendRichMessage({
chat_id: chatId,
rich_message: { markdown: chunk, skip_entity_detection: true },
reply_markup:
index === chunks.length - 1 ? options?.replyMarkup : undefined,
...(replyParameters ? { reply_parameters: replyParameters } : {}),
...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
});
lastMessageId = sent.message_id;
deps.recordOwnership?.({
chatId,
messageId: sent.message_id,
target: options?.target,
});
}
return lastMessageId;
}
// UI/compat regular-message runtime for bridge-owned text and interactive
// surfaces. Assistant and guest Markdown delivery bypass this path and use
// native Rich Message helpers above.
export type TelegramAssistantRenderingMode = "rich" | "html";
export interface TelegramRenderedMessageRuntimeDeps<TReplyMarkup> {
renderTelegramMessage: (
text: string,
options?: { mode?: TelegramRenderMode },
) => TelegramRenderedChunk[];
replyTransport: TelegramReplyTransport<TReplyMarkup>;
recordOwnership?: TelegramReplyOwnershipRecorder["record"];
getAssistantRenderingMode?: () => TelegramAssistantRenderingMode;
sendRichMessage: (
body: TelegramSendRichMessageBody,
) => Promise<TelegramSentMessage>;
}
export interface TelegramRenderedMessageRuntime<TReplyMarkup> {
sendTextReply: (
chatId: number,
replyToMessageId: number | undefined,
text: string,
options?: TelegramTextReplyOptions,
) => Promise<number | undefined>;
sendMarkdownReply: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined>;
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: TelegramRenderMode,
replyMarkup: TReplyMarkup,
) => Promise<void>;
sendInteractiveMessage: (
chatId: number,
text: string,
mode: TelegramRenderMode,
replyMarkup: TReplyMarkup,
options?: TelegramReplyTargetOptions,
) => Promise<number | undefined>;
}
export interface TelegramRenderedMessageDeliveryRuntime<
TReplyMarkup,
> extends TelegramRenderedMessageRuntime<TReplyMarkup> {
replyTransport: TelegramReplyTransport<TReplyMarkup>;
}
export interface TelegramRenderedMessageDeliveryRuntimeDeps<
TReplyMarkup,
> extends TelegramReplyDeliveryDeps<TReplyMarkup> {
renderTelegramMessage?: (
text: string,
options?: { mode?: TelegramRenderMode },
) => TelegramRenderedChunk[];
getAssistantRenderingMode?: () => TelegramAssistantRenderingMode;
sendRichMessage: (
body: TelegramSendRichMessageBody,
) => Promise<TelegramSentMessage>;
}
export function createTelegramRenderedMessageDeliveryRuntime<TReplyMarkup>(
deps: TelegramRenderedMessageDeliveryRuntimeDeps<TReplyMarkup>,
): TelegramRenderedMessageDeliveryRuntime<TReplyMarkup> {
const replyTransport = buildTelegramReplyTransport({
recordOwnership: deps.recordOwnership,
sendMessage: deps.sendMessage,
editMessage: deps.editMessage,
});
return {
replyTransport,
...createTelegramRenderedMessageRuntime({
renderTelegramMessage:
deps.renderTelegramMessage ?? renderTelegramMessage,
replyTransport,
recordOwnership: deps.recordOwnership,
getAssistantRenderingMode: deps.getAssistantRenderingMode,
sendRichMessage: deps.sendRichMessage,
}),
};
}
export function createTelegramRenderedMessageRuntime<TReplyMarkup>(
deps: TelegramRenderedMessageRuntimeDeps<TReplyMarkup>,
): TelegramRenderedMessageRuntime<TReplyMarkup> {
return {
sendTextReply: async (chatId, replyToMessageId, text, options) => {
return sendTelegramPlainReply(
text,
{
renderTelegramMessage: deps.renderTelegramMessage,
sendRenderedChunks: (chunks, chunkOptions) =>
deps.replyTransport.sendRenderedChunks(chatId, chunks, {
target: chunkOptions?.target,
replyToMessageId:
chunkOptions?.replyToMessageId ?? replyToMessageId,
}),
},
options,
);
},
sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
const renderingMode = deps.getAssistantRenderingMode?.() ?? "rich";
if (renderingMode === "html") {
return deps.replyTransport.sendRenderedChunks(
chatId,
deps.renderTelegramMessage(markdown, { mode: "markdown" }),
{
replyMarkup: options?.replyMarkup,
target: options?.target,
replyToMessageId,
},
);
}
return sendTelegramNativeMarkdownReply(
chatId,
replyToMessageId,
markdown,
{
recordOwnership: deps.recordOwnership,
sendRichMessage: deps.sendRichMessage,
},
options,
);
},
editInteractiveMessage: async (
chatId,
messageId,
text,
mode,
replyMarkup,
) => {
await deps.replyTransport.editRenderedMessage(
chatId,
messageId,
deps.renderTelegramMessage(text, { mode }),
{ replyMarkup },
);
},
sendInteractiveMessage: async (
chatId,
text,
mode,
replyMarkup,
options,
) => {
return deps.replyTransport.sendRenderedChunks(
chatId,
deps.renderTelegramMessage(text, { mode }),
{
replyMarkup,
target: options?.target,
replyToMessageId: options?.replyToMessageId,
},
);
},
};
}
// --- Dedup-wrapped Reply Wrappers ---
/** Wrap a sendTextReply with reply dedup so only the first message
* in a turn carries reply metadata. */
export function dedupSendTextReply(
dedup: ReplyDedupRuntime,
inner: (
chatId: number,
replyToMessageId: number | undefined,
text: string,
options?: TelegramTextReplyOptions,
) => Promise<number | undefined>,
): (
chatId: number,
replyToMessageId: number,
text: string,
options?: TelegramTextReplyOptions,
) => Promise<number | undefined> {
return async (chatId, replyToMessageId, text, options) => {
const effectiveReplyTo = dedup.shouldReply(replyToMessageId)
? replyToMessageId
: undefined;
return inner(chatId, effectiveReplyTo, text, options);
};
}
/** Wrap a sendMarkdownReply with reply dedup. */
export function dedupSendMarkdownReply<TReplyMarkup = unknown>(
dedup: ReplyDedupRuntime,
inner: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined>,
): (
chatId: number,
replyToMessageId: number,
markdown: string,
options?: { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined> {
return async (chatId, replyToMessageId, markdown, options) => {
const effectiveReplyTo = dedup.shouldReply(replyToMessageId)
? replyToMessageId
: undefined;
return inner(chatId, effectiveReplyTo, markdown, options);
};
}
/**
* Guest reply sender: answers guest queries with native Rich Markdown content.
* Guest queries use InlineQueryResult input_message_content rather than chat
* sendRichMessage, so this stays as a dedicated guest transport adapter.
*/
export function createGuestMarkdownReplySender(deps: {
answerGuestQuery: (
guestQueryId: string,
text?: string,
options?: { parseMode?: string; richMessage?: TelegramInputRichMessage },
) => Promise<void>;
}) {
return async (guestQueryId: string, markdown: string) => {
const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
await deps.answerGuestQuery(guestQueryId, undefined, {
richMessage: { markdown: richMarkdown, skip_entity_detection: true },
});
};
}