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)
758 lines
25 KiB
TypeScript
758 lines
25 KiB
TypeScript
/**
|
|
* Telegram outbound attachment helpers
|
|
* Zones: telegram outbound, pi agent tool, filesystem
|
|
* Owns telegram_attach registration, outbound attachment queueing, and delivery so Telegram file output stays in one domain module
|
|
*/
|
|
|
|
import { stat } from "node:fs/promises";
|
|
import { basename } from "node:path";
|
|
|
|
import { Type } from "@sinclair/typebox";
|
|
|
|
import type { ExtensionAPI } from "./pi.ts";
|
|
import { buildTelegramMultipartReplyParameters } from "./replies.ts";
|
|
import {
|
|
getTelegramTargetThreadParams,
|
|
type TelegramTarget,
|
|
} from "./target.ts";
|
|
|
|
const MAX_ATTACHMENTS_PER_TURN = 10;
|
|
|
|
export const TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
|
|
export function getTelegramOutboundAttachmentByteLimitFromEnv(
|
|
env: NodeJS.ProcessEnv,
|
|
names: string[],
|
|
defaultValue = TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES,
|
|
): number {
|
|
for (const name of names) {
|
|
const rawValue = env[name]?.trim();
|
|
if (!rawValue) continue;
|
|
const parsed = Number(rawValue);
|
|
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
export const TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES =
|
|
getTelegramOutboundAttachmentByteLimitFromEnv(process.env, [
|
|
"PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES",
|
|
"TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES",
|
|
]);
|
|
|
|
export interface TelegramOutboundAttachmentToolResult {
|
|
content: Array<{ type: "text"; text: string }>;
|
|
details: { paths: string[] };
|
|
}
|
|
|
|
export interface TelegramOutboundAttachmentRuntimeEventRecorderPort {
|
|
recordRuntimeEvent?: (
|
|
category: string,
|
|
error: unknown,
|
|
details?: Record<string, unknown>,
|
|
) => void;
|
|
}
|
|
|
|
export interface TelegramOutboundAttachmentToolRegistrationDeps extends TelegramOutboundAttachmentRuntimeEventRecorderPort {
|
|
maxAttachmentsPerTurn?: number;
|
|
maxAttachmentSizeBytes?: number;
|
|
getActiveTurn: () => TelegramOutboundAttachmentQueueTargetView | undefined;
|
|
getDefaultChatId?: () => number | undefined;
|
|
getDefaultTarget?: () => TelegramTarget | undefined;
|
|
canSendDirect?: () => boolean;
|
|
sendMultipart?: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
|
|
statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
|
|
}
|
|
|
|
export interface TelegramOutboundMessagePlan {
|
|
markdown: string;
|
|
replyMarkup?: unknown;
|
|
}
|
|
|
|
export interface TelegramOutboundMessageToolRegistrationDeps extends TelegramOutboundAttachmentRuntimeEventRecorderPort {
|
|
getDefaultChatId: () => number | undefined;
|
|
getDefaultTarget?: () => TelegramTarget | undefined;
|
|
canSendDirect: () => boolean;
|
|
planMessage: (markdown: string) => TelegramOutboundMessagePlan;
|
|
sendMarkdownMessage: (
|
|
chatId: number,
|
|
markdown: string,
|
|
options?: { replyMarkup?: unknown; target?: TelegramTarget },
|
|
) => Promise<number | undefined>;
|
|
}
|
|
|
|
export interface TelegramQueuedOutboundAttachmentView {
|
|
path: string;
|
|
fileName: string;
|
|
}
|
|
|
|
export interface TelegramOutboundAttachmentQueueTargetView {
|
|
queuedAttachments: TelegramQueuedOutboundAttachmentView[];
|
|
guestQueryId?: string;
|
|
}
|
|
|
|
export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
|
|
chatId: number;
|
|
replyToMessageId: number;
|
|
target?: TelegramTarget;
|
|
}
|
|
|
|
export type TelegramGuestCachedAttachmentResult =
|
|
| {
|
|
type: "document";
|
|
id: string;
|
|
title: string;
|
|
document_file_id: string;
|
|
caption?: string;
|
|
}
|
|
| {
|
|
type: "photo";
|
|
id: string;
|
|
photo_file_id: string;
|
|
caption?: string;
|
|
}
|
|
| {
|
|
type: "audio";
|
|
id: string;
|
|
audio_file_id: string;
|
|
caption?: string;
|
|
}
|
|
| {
|
|
type: "voice";
|
|
id: string;
|
|
voice_file_id: string;
|
|
title: string;
|
|
caption?: string;
|
|
};
|
|
|
|
interface TelegramGuestStagingMessage {
|
|
message_id?: number;
|
|
document?: { file_id?: string };
|
|
photo?: Array<{ file_id?: string; file_size?: number }>;
|
|
audio?: { file_id?: string };
|
|
voice?: { file_id?: string };
|
|
}
|
|
|
|
function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
|
|
const normalized = path.toLowerCase();
|
|
return (
|
|
normalized.endsWith(".jpg") ||
|
|
normalized.endsWith(".jpeg") ||
|
|
normalized.endsWith(".png") ||
|
|
normalized.endsWith(".webp") ||
|
|
normalized.endsWith(".gif")
|
|
);
|
|
}
|
|
|
|
function getTelegramGuestAttachmentTransport(path: string): {
|
|
method: "sendDocument" | "sendPhoto" | "sendAudio" | "sendVoice";
|
|
fileField: "document" | "photo" | "audio" | "voice";
|
|
} {
|
|
const normalized = path.toLowerCase();
|
|
if (
|
|
normalized.endsWith(".jpg") ||
|
|
normalized.endsWith(".jpeg") ||
|
|
normalized.endsWith(".png")
|
|
) {
|
|
return { method: "sendPhoto", fileField: "photo" };
|
|
}
|
|
if (normalized.endsWith(".ogg") || normalized.endsWith(".opus")) {
|
|
return { method: "sendVoice", fileField: "voice" };
|
|
}
|
|
if (normalized.endsWith(".mp3")) {
|
|
return { method: "sendAudio", fileField: "audio" };
|
|
}
|
|
return { method: "sendDocument", fileField: "document" };
|
|
}
|
|
|
|
function formatTelegramOutboundAttachmentSizeLimitError(
|
|
size: number,
|
|
maxSize: number,
|
|
path?: string,
|
|
): string {
|
|
const message = `Attachment exceeds size limit (${size} bytes > ${maxSize} bytes)`;
|
|
return path ? `${message}: ${path}` : message;
|
|
}
|
|
|
|
function formatTelegramOutboundAttachmentToolResultText(
|
|
count: number,
|
|
mode: "queued" | "sent" = "queued",
|
|
): string {
|
|
// Pi's compact tool rows need one leading newline to visually separate header and result.
|
|
const verb = mode === "queued" ? "Queued" : "Sent";
|
|
return ["", `${verb} ${count} Telegram attachment(s).`].join("\n");
|
|
}
|
|
|
|
function formatTelegramOutboundMessageToolResultText(chatId: number): string {
|
|
return ["", `Sent Telegram message to ${chatId}.`].join("\n");
|
|
}
|
|
|
|
function getTelegramMultipartTargetFields(
|
|
target: TelegramTarget | undefined,
|
|
): Record<string, string> {
|
|
if (!target) return {};
|
|
return Object.fromEntries(
|
|
Object.entries(getTelegramTargetThreadParams(target)).map(
|
|
([key, value]) => [key, String(value)],
|
|
),
|
|
);
|
|
}
|
|
|
|
function assertTelegramDirectDeliveryAllowed(
|
|
canSendDirect: (() => boolean) | undefined,
|
|
): void {
|
|
if (canSendDirect?.()) return;
|
|
throw new Error(
|
|
"Telegram direct delivery requires this Pi instance to own /telegram-connect or be registered with the Telegram multi-instance bus",
|
|
);
|
|
}
|
|
|
|
function resolveTelegramOutboundTarget(options: {
|
|
chatId?: number;
|
|
threadId?: number;
|
|
target?: TelegramTarget;
|
|
getDefaultChatId?: () => number | undefined;
|
|
getDefaultTarget?: () => TelegramTarget | undefined;
|
|
}): { chatId: number; target?: TelegramTarget } {
|
|
if (options.target)
|
|
return { chatId: options.target.chatId, target: options.target };
|
|
if (options.chatId !== undefined) {
|
|
return {
|
|
chatId: options.chatId,
|
|
target:
|
|
options.threadId !== undefined
|
|
? { chatId: options.chatId, threadId: options.threadId }
|
|
: undefined,
|
|
};
|
|
}
|
|
const defaultTarget = options.getDefaultTarget?.();
|
|
if (defaultTarget) {
|
|
return { chatId: defaultTarget.chatId, target: defaultTarget };
|
|
}
|
|
const defaultChatId = options.getDefaultChatId?.();
|
|
if (defaultChatId === undefined) {
|
|
throw new Error(
|
|
"Telegram chat_id is required when no paired/default Telegram chat is available",
|
|
);
|
|
}
|
|
return { chatId: defaultChatId };
|
|
}
|
|
|
|
async function buildTelegramOutboundAttachmentViews(options: {
|
|
paths: string[];
|
|
maxAttachmentSizeBytes?: number;
|
|
statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
|
|
}): Promise<TelegramQueuedOutboundAttachmentView[]> {
|
|
const pendingAttachments: TelegramQueuedOutboundAttachmentView[] = [];
|
|
for (const inputPath of options.paths) {
|
|
const stats = await (options.statPath ?? stat)(inputPath);
|
|
if (!stats.isFile()) {
|
|
throw new Error(`Not a file: ${inputPath}`);
|
|
}
|
|
if (
|
|
options.maxAttachmentSizeBytes !== undefined &&
|
|
stats.size !== undefined &&
|
|
stats.size > options.maxAttachmentSizeBytes
|
|
) {
|
|
throw new Error(
|
|
formatTelegramOutboundAttachmentSizeLimitError(
|
|
stats.size,
|
|
options.maxAttachmentSizeBytes,
|
|
inputPath,
|
|
),
|
|
);
|
|
}
|
|
pendingAttachments.push({
|
|
path: inputPath,
|
|
fileName: basename(inputPath),
|
|
});
|
|
}
|
|
return pendingAttachments;
|
|
}
|
|
|
|
export function registerTelegramOutboundAttachmentTool(
|
|
pi: ExtensionAPI,
|
|
deps: TelegramOutboundAttachmentToolRegistrationDeps,
|
|
): void {
|
|
const maxAttachmentsPerTurn =
|
|
deps.maxAttachmentsPerTurn ?? MAX_ATTACHMENTS_PER_TURN;
|
|
const maxAttachmentSizeBytes =
|
|
deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES;
|
|
pi.registerTool({
|
|
name: "telegram_attach",
|
|
label: "Telegram Attach",
|
|
description:
|
|
"Queue one or more local files for the active Telegram reply, or send them immediately to Telegram when no Telegram turn is active.",
|
|
promptSnippet:
|
|
"Queue files for the active Telegram reply; outside Telegram turns, send files directly to Telegram.",
|
|
promptGuidelines: [
|
|
"When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.",
|
|
"When a local/TUI user explicitly asks to send a generated file to Telegram, telegram_attach can deliver it to the paired/default Telegram chat even without an active Telegram turn.",
|
|
"For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
|
|
],
|
|
parameters: Type.Object({
|
|
paths: Type.Array(
|
|
Type.String({ description: "Local file path to attach" }),
|
|
{ minItems: 1, maxItems: maxAttachmentsPerTurn },
|
|
),
|
|
chat_id: Type.Optional(
|
|
Type.Number({
|
|
description:
|
|
"Optional Telegram chat id for immediate delivery when no Telegram turn is active",
|
|
}),
|
|
),
|
|
thread_id: Type.Optional(
|
|
Type.Number({
|
|
description:
|
|
"Optional Telegram topic thread id for immediate delivery with chat_id",
|
|
}),
|
|
),
|
|
caption: Type.Optional(
|
|
Type.String({
|
|
description:
|
|
"Optional caption for immediate delivery; ignored when queued for an active turn",
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
try {
|
|
return await queueTelegramOutboundAttachments({
|
|
activeTurn: deps.getActiveTurn(),
|
|
paths: params.paths,
|
|
chatId: params.chat_id,
|
|
threadId: params.thread_id,
|
|
caption: params.caption,
|
|
maxAttachmentsPerTurn,
|
|
maxAttachmentSizeBytes,
|
|
sendMultipart: deps.sendMultipart,
|
|
getDefaultChatId: deps.getDefaultChatId,
|
|
getDefaultTarget: deps.getDefaultTarget,
|
|
canSendDirect: deps.canSendDirect,
|
|
statPath: deps.statPath,
|
|
});
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("attachment", error, {
|
|
phase: "queue",
|
|
count: params.paths.length,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function registerTelegramOutboundMessageTool(
|
|
pi: ExtensionAPI,
|
|
deps: TelegramOutboundMessageToolRegistrationDeps,
|
|
): void {
|
|
pi.registerTool({
|
|
name: "telegram_message",
|
|
label: "Telegram Message",
|
|
description:
|
|
"Send a Markdown text message directly to the paired/default Telegram chat or an explicit chat_id. Hidden telegram_button comments in the text become attached inline prompt buttons.",
|
|
promptSnippet:
|
|
"Send direct Telegram Markdown text when the user explicitly asks for Telegram delivery outside the normal reply flow.",
|
|
promptGuidelines: [
|
|
"Use telegram_message only when the user explicitly asks to send a message to Telegram from the local/TUI side, or names a concrete Telegram delivery target.",
|
|
"For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
|
|
"Add buttons by embedding the same top-level telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
|
|
"Do not use this tool for ordinary Telegram-originated replies; answer normally so the bridge can deliver the active turn reply.",
|
|
],
|
|
parameters: Type.Object({
|
|
text: Type.String({ description: "Message text to send" }),
|
|
chat_id: Type.Optional(
|
|
Type.Number({ description: "Optional Telegram chat id" }),
|
|
),
|
|
thread_id: Type.Optional(
|
|
Type.Number({
|
|
description: "Optional Telegram topic thread id with chat_id",
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
try {
|
|
return await sendTelegramOutboundMessage({
|
|
text: params.text,
|
|
chatId: params.chat_id,
|
|
threadId: params.thread_id,
|
|
getDefaultChatId: deps.getDefaultChatId,
|
|
getDefaultTarget: deps.getDefaultTarget,
|
|
canSendDirect: deps.canSendDirect,
|
|
planMessage: deps.planMessage,
|
|
sendMarkdownMessage: deps.sendMarkdownMessage,
|
|
});
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("message", error, { phase: "direct" });
|
|
throw error;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export interface TelegramQueuedOutboundAttachmentDeliveryDeps {
|
|
sendMultipart: (
|
|
method: string,
|
|
fields: Record<string, string>,
|
|
fileField: string,
|
|
filePath: string,
|
|
fileName: string,
|
|
) => Promise<unknown>;
|
|
sendTextReply: (
|
|
chatId: number,
|
|
replyToMessageId: number,
|
|
text: string,
|
|
options?: { target?: TelegramTarget },
|
|
) => Promise<unknown>;
|
|
recordRuntimeEvent?: (
|
|
category: string,
|
|
error: unknown,
|
|
details?: Record<string, unknown>,
|
|
) => void;
|
|
statPath?: (path: string) => Promise<{ size: number }>;
|
|
maxAttachmentSizeBytes?: number;
|
|
}
|
|
|
|
export async function queueTelegramOutboundAttachments(options: {
|
|
activeTurn: TelegramOutboundAttachmentQueueTargetView | undefined;
|
|
paths: string[];
|
|
chatId?: number;
|
|
threadId?: number;
|
|
caption?: string;
|
|
maxAttachmentsPerTurn: number;
|
|
maxAttachmentSizeBytes?: number;
|
|
sendMultipart?: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
|
|
getDefaultChatId?: () => number | undefined;
|
|
getDefaultTarget?: () => TelegramTarget | undefined;
|
|
canSendDirect?: () => boolean;
|
|
statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
|
|
}): Promise<TelegramOutboundAttachmentToolResult> {
|
|
if (!options.activeTurn) {
|
|
if (!options.sendMultipart) {
|
|
throw new Error(
|
|
"telegram_attach can only queue files while replying to an active Telegram turn; provide Telegram send ports for immediate delivery",
|
|
);
|
|
}
|
|
return sendTelegramOutboundFiles({
|
|
paths: options.paths,
|
|
chatId: options.chatId,
|
|
threadId: options.threadId,
|
|
caption: options.caption,
|
|
maxAttachmentsPerTurn: options.maxAttachmentsPerTurn,
|
|
maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
|
|
sendMultipart: options.sendMultipart,
|
|
getDefaultChatId: options.getDefaultChatId,
|
|
getDefaultTarget: options.getDefaultTarget,
|
|
canSendDirect: options.canSendDirect,
|
|
statPath: options.statPath,
|
|
});
|
|
}
|
|
if (
|
|
options.activeTurn.guestQueryId &&
|
|
options.activeTurn.queuedAttachments.length + options.paths.length > 1
|
|
) {
|
|
throw new Error(
|
|
"Telegram Guest Mode supports one attachment per reply; no attachment was queued",
|
|
);
|
|
}
|
|
if (
|
|
options.activeTurn.queuedAttachments.length + options.paths.length >
|
|
options.maxAttachmentsPerTurn
|
|
) {
|
|
throw new Error(
|
|
`Attachment limit reached (${options.maxAttachmentsPerTurn})`,
|
|
);
|
|
}
|
|
const pendingAttachments = await buildTelegramOutboundAttachmentViews({
|
|
paths: options.paths,
|
|
maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
|
|
statPath: options.statPath,
|
|
});
|
|
options.activeTurn.queuedAttachments.push(...pendingAttachments);
|
|
const added = pendingAttachments.map((attachment) => attachment.path);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: formatTelegramOutboundAttachmentToolResultText(added.length),
|
|
},
|
|
],
|
|
details: { paths: added },
|
|
};
|
|
}
|
|
|
|
export async function deliverTelegramGuestCachedAttachment(options: {
|
|
guestQueryId: string;
|
|
stagingChatId: number;
|
|
stagingTarget?: TelegramTarget;
|
|
attachment: TelegramQueuedOutboundAttachmentView;
|
|
caption?: string;
|
|
sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
|
|
answerGuestQuery: (
|
|
guestQueryId: string,
|
|
result: TelegramGuestCachedAttachmentResult,
|
|
) => Promise<void>;
|
|
answerGuestText?: (guestQueryId: string, text: string) => Promise<void>;
|
|
fallbackText?: string;
|
|
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
|
|
recordRuntimeEvent?: TelegramOutboundAttachmentRuntimeEventRecorderPort["recordRuntimeEvent"];
|
|
}): Promise<void> {
|
|
const transport = getTelegramGuestAttachmentTransport(options.attachment.path);
|
|
let stagingMessageId: number | undefined;
|
|
let answerAttempted = false;
|
|
try {
|
|
const message = (await options.sendMultipart(
|
|
transport.method,
|
|
{
|
|
chat_id: String(options.stagingChatId),
|
|
...getTelegramMultipartTargetFields(options.stagingTarget),
|
|
},
|
|
transport.fileField,
|
|
options.attachment.path,
|
|
options.attachment.fileName,
|
|
)) as TelegramGuestStagingMessage;
|
|
stagingMessageId = message.message_id;
|
|
const caption = options.caption
|
|
? Array.from(options.caption).slice(0, 1024).join("")
|
|
: undefined;
|
|
let result: TelegramGuestCachedAttachmentResult;
|
|
if (transport.fileField === "photo") {
|
|
const photo = [...(message.photo ?? [])]
|
|
.sort((left, right) => (left.file_size ?? 0) - (right.file_size ?? 0))
|
|
.at(-1);
|
|
if (!photo?.file_id) throw new Error("Guest staging upload returned no photo file_id");
|
|
result = {
|
|
type: "photo",
|
|
id: "attachment-1",
|
|
photo_file_id: photo.file_id,
|
|
...(caption ? { caption } : {}),
|
|
};
|
|
} else if (transport.fileField === "audio") {
|
|
if (!message.audio?.file_id)
|
|
throw new Error("Guest staging upload returned no audio file_id");
|
|
result = {
|
|
type: "audio",
|
|
id: "attachment-1",
|
|
audio_file_id: message.audio.file_id,
|
|
...(caption ? { caption } : {}),
|
|
};
|
|
} else if (transport.fileField === "voice") {
|
|
if (!message.voice?.file_id)
|
|
throw new Error("Guest staging upload returned no voice file_id");
|
|
result = {
|
|
type: "voice",
|
|
id: "attachment-1",
|
|
voice_file_id: message.voice.file_id,
|
|
title: options.attachment.fileName,
|
|
...(caption ? { caption } : {}),
|
|
};
|
|
} else {
|
|
if (!message.document?.file_id)
|
|
throw new Error("Guest staging upload returned no document file_id");
|
|
result = {
|
|
type: "document",
|
|
id: "attachment-1",
|
|
title: options.attachment.fileName,
|
|
document_file_id: message.document.file_id,
|
|
...(caption ? { caption } : {}),
|
|
};
|
|
}
|
|
answerAttempted = true;
|
|
await options.answerGuestQuery(options.guestQueryId, result);
|
|
} catch (error) {
|
|
if (
|
|
!answerAttempted &&
|
|
options.answerGuestText &&
|
|
options.fallbackText
|
|
) {
|
|
answerAttempted = true;
|
|
await options.answerGuestText(options.guestQueryId, options.fallbackText);
|
|
} else {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
if (stagingMessageId !== undefined) {
|
|
try {
|
|
await options.deleteMessage(options.stagingChatId, stagingMessageId);
|
|
} catch (error) {
|
|
options.recordRuntimeEvent?.("attachment", error, {
|
|
phase: "guest-staging-cleanup",
|
|
chatId: options.stagingChatId,
|
|
messageId: stagingMessageId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function sendTelegramOutboundMessage(options: {
|
|
text: string;
|
|
chatId?: number;
|
|
threadId?: number;
|
|
target?: TelegramTarget;
|
|
getDefaultChatId?: () => number | undefined;
|
|
getDefaultTarget?: () => TelegramTarget | undefined;
|
|
canSendDirect: () => boolean;
|
|
planMessage: (markdown: string) => TelegramOutboundMessagePlan;
|
|
sendMarkdownMessage: (
|
|
chatId: number,
|
|
markdown: string,
|
|
options?: { replyMarkup?: unknown; target?: TelegramTarget },
|
|
) => Promise<number | undefined>;
|
|
}): Promise<{
|
|
content: Array<{ type: "text"; text: string }>;
|
|
details: { chatId: number; messageId?: number };
|
|
}> {
|
|
assertTelegramDirectDeliveryAllowed(options.canSendDirect);
|
|
const { chatId, target } = resolveTelegramOutboundTarget({
|
|
chatId: options.chatId,
|
|
threadId: options.threadId,
|
|
target: options.target,
|
|
getDefaultChatId: options.getDefaultChatId,
|
|
getDefaultTarget: options.getDefaultTarget,
|
|
});
|
|
const plan = options.planMessage(options.text);
|
|
const messageId = await options.sendMarkdownMessage(chatId, plan.markdown, {
|
|
replyMarkup: plan.replyMarkup,
|
|
target,
|
|
});
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: formatTelegramOutboundMessageToolResultText(chatId),
|
|
},
|
|
],
|
|
details: { chatId, messageId },
|
|
};
|
|
}
|
|
|
|
export async function sendTelegramOutboundFiles(options: {
|
|
paths: string[];
|
|
chatId?: number;
|
|
threadId?: number;
|
|
target?: TelegramTarget;
|
|
caption?: string;
|
|
maxAttachmentsPerTurn: number;
|
|
maxAttachmentSizeBytes?: number;
|
|
sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
|
|
getDefaultChatId?: () => number | undefined;
|
|
getDefaultTarget?: () => TelegramTarget | undefined;
|
|
canSendDirect?: () => boolean;
|
|
statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
|
|
}): Promise<
|
|
TelegramOutboundAttachmentToolResult & {
|
|
details: { paths: string[]; chatId: number };
|
|
}
|
|
> {
|
|
assertTelegramDirectDeliveryAllowed(options.canSendDirect);
|
|
if (options.paths.length > options.maxAttachmentsPerTurn) {
|
|
throw new Error(
|
|
`Attachment limit reached (${options.maxAttachmentsPerTurn})`,
|
|
);
|
|
}
|
|
const { chatId, target } = resolveTelegramOutboundTarget({
|
|
chatId: options.chatId,
|
|
threadId: options.threadId,
|
|
target: options.target,
|
|
getDefaultChatId: options.getDefaultChatId,
|
|
getDefaultTarget: options.getDefaultTarget,
|
|
});
|
|
const pendingAttachments = await buildTelegramOutboundAttachmentViews({
|
|
paths: options.paths,
|
|
maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
|
|
statPath: options.statPath,
|
|
});
|
|
for (const [index, attachment] of pendingAttachments.entries()) {
|
|
const isPhoto = isTelegramOutboundPhotoAttachmentPath(attachment.path);
|
|
const method = isPhoto ? "sendPhoto" : "sendDocument";
|
|
const fieldName = isPhoto ? "photo" : "document";
|
|
await options.sendMultipart(
|
|
method,
|
|
{
|
|
chat_id: String(chatId),
|
|
...(options.caption && index === 0 ? { caption: options.caption } : {}),
|
|
...getTelegramMultipartTargetFields(target),
|
|
},
|
|
fieldName,
|
|
attachment.path,
|
|
attachment.fileName,
|
|
);
|
|
}
|
|
const added = pendingAttachments.map((attachment) => attachment.path);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: formatTelegramOutboundAttachmentToolResultText(
|
|
added.length,
|
|
"sent",
|
|
),
|
|
},
|
|
],
|
|
details: { paths: added, chatId },
|
|
};
|
|
}
|
|
|
|
export function createTelegramQueuedOutboundAttachmentSender(
|
|
deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
|
|
) {
|
|
return async (turn: TelegramQueuedOutboundAttachmentTurnView): Promise<void> => {
|
|
await sendQueuedTelegramOutboundAttachments(turn, {
|
|
...deps,
|
|
maxAttachmentSizeBytes:
|
|
deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES,
|
|
});
|
|
};
|
|
}
|
|
|
|
export async function sendQueuedTelegramOutboundAttachments(
|
|
turn: TelegramQueuedOutboundAttachmentTurnView,
|
|
deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
|
|
): Promise<void> {
|
|
for (const attachment of turn.queuedAttachments) {
|
|
try {
|
|
if (deps.maxAttachmentSizeBytes !== undefined) {
|
|
const stats = await (deps.statPath ?? stat)(attachment.path);
|
|
if (stats.size > deps.maxAttachmentSizeBytes) {
|
|
throw new Error(
|
|
formatTelegramOutboundAttachmentSizeLimitError(
|
|
stats.size,
|
|
deps.maxAttachmentSizeBytes,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
const isPhoto = isTelegramOutboundPhotoAttachmentPath(attachment.path);
|
|
const method = isPhoto ? "sendPhoto" : "sendDocument";
|
|
const fieldName = isPhoto ? "photo" : "document";
|
|
const replyParameters = buildTelegramMultipartReplyParameters(
|
|
turn.chatId,
|
|
turn.replyToMessageId,
|
|
turn.target,
|
|
);
|
|
await deps.sendMultipart(
|
|
method,
|
|
{
|
|
chat_id: String(turn.chatId),
|
|
...(replyParameters ? { reply_parameters: replyParameters } : {}),
|
|
...getTelegramMultipartTargetFields(turn.target),
|
|
},
|
|
fieldName,
|
|
attachment.path,
|
|
attachment.fileName,
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
deps.recordRuntimeEvent?.("attachment", error, {
|
|
fileName: attachment.fileName,
|
|
});
|
|
await deps.sendTextReply(
|
|
turn.chatId,
|
|
turn.replyToMessageId,
|
|
`Failed to send attachment ${attachment.fileName}: ${message}`,
|
|
{ target: turn.target },
|
|
);
|
|
}
|
|
}
|
|
}
|