mirror of
https://github.com/wassname/pi-telegram.git
synced 2026-06-27 17:01:39 +08:00
preparation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -87,6 +87,7 @@ Send any message in the bot DM. It is forwarded into pi with a `[telegram]` pref
|
||||
Send images, albums, or files in the DM.
|
||||
|
||||
The extension:
|
||||
|
||||
- downloads them to `~/.pi/agent/tmp/telegram`
|
||||
- includes local file paths in the prompt
|
||||
- forwards inbound images as image inputs to pi
|
||||
@@ -96,6 +97,7 @@ The extension:
|
||||
If you ask pi for a file or generated artifact, pi should call the `telegram_attach` tool. The extension then sends those files with the next Telegram reply.
|
||||
|
||||
Examples:
|
||||
|
||||
- `summarize this image`
|
||||
- `read this README and summarize it`
|
||||
- `write me a markdown file with the plan and send it back`
|
||||
|
||||
@@ -4,7 +4,10 @@ import { homedir } from "node:os";
|
||||
|
||||
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
interface TelegramConfig {
|
||||
@@ -176,7 +179,10 @@ function sanitizeFileName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
||||
}
|
||||
|
||||
function guessExtensionFromMime(mimeType: string | undefined, fallback: string): string {
|
||||
function guessExtensionFromMime(
|
||||
mimeType: string | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!mimeType) return fallback;
|
||||
const normalized = mimeType.toLowerCase();
|
||||
if (normalized === "image/jpeg") return ".jpg";
|
||||
@@ -231,7 +237,8 @@ function chunkParagraphs(text: string): string[] {
|
||||
const lineChunks: string[] = [];
|
||||
let lineCurrent = "";
|
||||
for (const line of lines) {
|
||||
const candidate = lineCurrent.length === 0 ? line : `${lineCurrent}\n${line}`;
|
||||
const candidate =
|
||||
lineCurrent.length === 0 ? line : `${lineCurrent}\n${line}`;
|
||||
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
||||
lineCurrent = candidate;
|
||||
continue;
|
||||
@@ -281,7 +288,11 @@ async function readConfig(): Promise<TelegramConfig> {
|
||||
|
||||
async function writeConfig(config: TelegramConfig): Promise<void> {
|
||||
await mkdir(join(homedir(), ".pi", "agent"), { recursive: true });
|
||||
await writeFile(CONFIG_PATH, JSON.stringify(config, null, "\t") + "\n", "utf8");
|
||||
await writeFile(
|
||||
CONFIG_PATH,
|
||||
JSON.stringify(config, null, "\t") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
@@ -308,27 +319,48 @@ export default function (pi: ExtensionAPI) {
|
||||
const theme = ctx.ui.theme;
|
||||
const label = theme.fg("accent", "telegram");
|
||||
if (error) {
|
||||
ctx.ui.setStatus("telegram", `${label} ${theme.fg("error", "error")} ${theme.fg("muted", error)}`);
|
||||
ctx.ui.setStatus(
|
||||
"telegram",
|
||||
`${label} ${theme.fg("error", "error")} ${theme.fg("muted", error)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!config.botToken) {
|
||||
ctx.ui.setStatus("telegram", `${label} ${theme.fg("muted", "not configured")}`);
|
||||
ctx.ui.setStatus(
|
||||
"telegram",
|
||||
`${label} ${theme.fg("muted", "not configured")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!pollingPromise) {
|
||||
ctx.ui.setStatus("telegram", `${label} ${theme.fg("muted", "disconnected")}`);
|
||||
ctx.ui.setStatus(
|
||||
"telegram",
|
||||
`${label} ${theme.fg("muted", "disconnected")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!config.allowedUserId) {
|
||||
ctx.ui.setStatus("telegram", `${label} ${theme.fg("warning", "awaiting pairing")}`);
|
||||
ctx.ui.setStatus(
|
||||
"telegram",
|
||||
`${label} ${theme.fg("warning", "awaiting pairing")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (activeTelegramTurn || queuedTelegramTurns.length > 0) {
|
||||
const queued = queuedTelegramTurns.length > 0 ? theme.fg("muted", ` +${queuedTelegramTurns.length} queued`) : "";
|
||||
ctx.ui.setStatus("telegram", `${label} ${theme.fg("accent", "processing")}${queued}`);
|
||||
const queued =
|
||||
queuedTelegramTurns.length > 0
|
||||
? theme.fg("muted", ` +${queuedTelegramTurns.length} queued`)
|
||||
: "";
|
||||
ctx.ui.setStatus(
|
||||
"telegram",
|
||||
`${label} ${theme.fg("accent", "processing")}${queued}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ctx.ui.setStatus("telegram", `${label} ${theme.fg("success", "connected")}`);
|
||||
ctx.ui.setStatus(
|
||||
"telegram",
|
||||
`${label} ${theme.fg("success", "connected")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function callTelegram<TResponse>(
|
||||
@@ -336,13 +368,17 @@ export default function (pi: ExtensionAPI) {
|
||||
body: Record<string, unknown>,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<TResponse> {
|
||||
if (!config.botToken) throw new Error("Telegram bot token is not configured");
|
||||
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/${method}`, {
|
||||
if (!config.botToken)
|
||||
throw new Error("Telegram bot token is not configured");
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${config.botToken}/${method}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
});
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as TelegramApiResponse<TResponse>;
|
||||
if (!data.ok || data.result === undefined) {
|
||||
throw new Error(data.description || `Telegram API ${method} failed`);
|
||||
@@ -358,18 +394,22 @@ export default function (pi: ExtensionAPI) {
|
||||
fileName: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<TResponse> {
|
||||
if (!config.botToken) throw new Error("Telegram bot token is not configured");
|
||||
if (!config.botToken)
|
||||
throw new Error("Telegram bot token is not configured");
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
form.set(key, value);
|
||||
}
|
||||
const buffer = await readFile(filePath);
|
||||
form.set(fileField, new Blob([buffer]), fileName);
|
||||
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/${method}`, {
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${config.botToken}/${method}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: options?.signal,
|
||||
});
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as TelegramApiResponse<TResponse>;
|
||||
if (!data.ok || data.result === undefined) {
|
||||
throw new Error(data.description || `Telegram API ${method} failed`);
|
||||
@@ -377,13 +417,25 @@ export default function (pi: ExtensionAPI) {
|
||||
return data.result;
|
||||
}
|
||||
|
||||
async function downloadTelegramFile(fileId: string, suggestedName: string): Promise<string> {
|
||||
if (!config.botToken) throw new Error("Telegram bot token is not configured");
|
||||
const file = await callTelegram<TelegramGetFileResult>("getFile", { file_id: fileId });
|
||||
async function downloadTelegramFile(
|
||||
fileId: string,
|
||||
suggestedName: string,
|
||||
): Promise<string> {
|
||||
if (!config.botToken)
|
||||
throw new Error("Telegram bot token is not configured");
|
||||
const file = await callTelegram<TelegramGetFileResult>("getFile", {
|
||||
file_id: fileId,
|
||||
});
|
||||
await mkdir(TEMP_DIR, { recursive: true });
|
||||
const targetPath = join(TEMP_DIR, `${Date.now()}-${sanitizeFileName(suggestedName)}`);
|
||||
const response = await fetch(`https://api.telegram.org/file/bot${config.botToken}/${file.file_path}`);
|
||||
if (!response.ok) throw new Error(`Failed to download Telegram file: ${response.status}`);
|
||||
const targetPath = join(
|
||||
TEMP_DIR,
|
||||
`${Date.now()}-${sanitizeFileName(suggestedName)}`,
|
||||
);
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/file/bot${config.botToken}/${file.file_path}`,
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to download Telegram file: ${response.status}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
await writeFile(targetPath, Buffer.from(arrayBuffer));
|
||||
return targetPath;
|
||||
@@ -395,7 +447,10 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
const sendTyping = async (): Promise<void> => {
|
||||
try {
|
||||
await callTelegram("sendChatAction", { chat_id: targetChatId, action: "typing" });
|
||||
await callTelegram("sendChatAction", {
|
||||
chat_id: targetChatId,
|
||||
action: "typing",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
updateStatus(ctx, `typing failed: ${message}`);
|
||||
@@ -422,8 +477,13 @@ export default function (pi: ExtensionAPI) {
|
||||
const value = message as unknown as Record<string, unknown>;
|
||||
const content = Array.isArray(value.content) ? value.content : [];
|
||||
return content
|
||||
.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")
|
||||
.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();
|
||||
@@ -439,7 +499,11 @@ export default function (pi: ExtensionAPI) {
|
||||
previewState = undefined;
|
||||
if (state.mode === "draft" && state.draftId !== undefined) {
|
||||
try {
|
||||
await callTelegram("sendMessageDraft", { chat_id: chatId, draft_id: state.draftId, text: "" });
|
||||
await callTelegram("sendMessageDraft", {
|
||||
chat_id: chatId,
|
||||
draft_id: state.draftId,
|
||||
text: "",
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -452,13 +516,20 @@ export default function (pi: ExtensionAPI) {
|
||||
state.flushTimer = undefined;
|
||||
const text = state.pendingText.trim();
|
||||
if (!text || text === state.lastSentText) return;
|
||||
const truncated = text.length > MAX_MESSAGE_LENGTH ? text.slice(0, MAX_MESSAGE_LENGTH) : text;
|
||||
const truncated =
|
||||
text.length > MAX_MESSAGE_LENGTH
|
||||
? text.slice(0, MAX_MESSAGE_LENGTH)
|
||||
: text;
|
||||
|
||||
if (draftSupport !== "unsupported") {
|
||||
const draftId = state.draftId ?? allocateDraftId();
|
||||
state.draftId = draftId;
|
||||
try {
|
||||
await callTelegram("sendMessageDraft", { chat_id: chatId, draft_id: draftId, text: truncated });
|
||||
await callTelegram("sendMessageDraft", {
|
||||
chat_id: chatId,
|
||||
draft_id: draftId,
|
||||
text: truncated,
|
||||
});
|
||||
draftSupport = "supported";
|
||||
state.mode = "draft";
|
||||
state.lastSentText = truncated;
|
||||
@@ -469,13 +540,20 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
if (state.messageId === undefined) {
|
||||
const sent = await callTelegram<TelegramSentMessage>("sendMessage", { chat_id: chatId, text: truncated });
|
||||
const sent = await callTelegram<TelegramSentMessage>("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: truncated,
|
||||
});
|
||||
state.messageId = sent.message_id;
|
||||
state.mode = "message";
|
||||
state.lastSentText = truncated;
|
||||
return;
|
||||
}
|
||||
await callTelegram("editMessageText", { chat_id: chatId, message_id: state.messageId, text: truncated });
|
||||
await callTelegram("editMessageText", {
|
||||
chat_id: chatId,
|
||||
message_id: state.messageId,
|
||||
text: truncated,
|
||||
});
|
||||
state.mode = "message";
|
||||
state.lastSentText = truncated;
|
||||
}
|
||||
@@ -497,7 +575,10 @@ export default function (pi: ExtensionAPI) {
|
||||
return false;
|
||||
}
|
||||
if (state.mode === "draft") {
|
||||
await callTelegram<TelegramSentMessage>("sendMessage", { chat_id: chatId, text: finalText });
|
||||
await callTelegram<TelegramSentMessage>("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: finalText,
|
||||
});
|
||||
await clearPreview(chatId);
|
||||
return true;
|
||||
}
|
||||
@@ -505,7 +586,11 @@ export default function (pi: ExtensionAPI) {
|
||||
return state.messageId !== undefined;
|
||||
}
|
||||
|
||||
async function sendTextReply(chatId: number, _replyToMessageId: number, text: string): Promise<number | undefined> {
|
||||
async function sendTextReply(
|
||||
chatId: number,
|
||||
_replyToMessageId: number,
|
||||
text: string,
|
||||
): Promise<number | undefined> {
|
||||
const chunks = chunkParagraphs(text);
|
||||
let lastMessageId: number | undefined;
|
||||
for (const chunk of chunks) {
|
||||
@@ -518,7 +603,9 @@ export default function (pi: ExtensionAPI) {
|
||||
return lastMessageId;
|
||||
}
|
||||
|
||||
async function sendQueuedAttachments(turn: ActiveTelegramTurn): Promise<void> {
|
||||
async function sendQueuedAttachments(
|
||||
turn: ActiveTelegramTurn,
|
||||
): Promise<void> {
|
||||
for (const attachment of turn.queuedAttachments) {
|
||||
try {
|
||||
const mediaType = guessMediaType(attachment.path);
|
||||
@@ -535,21 +622,38 @@ export default function (pi: ExtensionAPI) {
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await sendTextReply(turn.chatId, turn.replyToMessageId, `Failed to send attachment ${attachment.fileName}: ${message}`);
|
||||
await sendTextReply(
|
||||
turn.chatId,
|
||||
turn.replyToMessageId,
|
||||
`Failed to send attachment ${attachment.fileName}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractAssistantText(messages: AgentMessage[]): { text?: string; stopReason?: string; errorMessage?: string } {
|
||||
function extractAssistantText(messages: AgentMessage[]): {
|
||||
text?: string;
|
||||
stopReason?: string;
|
||||
errorMessage?: string;
|
||||
} {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i] as unknown as Record<string, unknown>;
|
||||
if (message.role !== "assistant") continue;
|
||||
const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined;
|
||||
const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage : undefined;
|
||||
const stopReason =
|
||||
typeof message.stopReason === "string" ? message.stopReason : undefined;
|
||||
const errorMessage =
|
||||
typeof message.errorMessage === "string"
|
||||
? message.errorMessage
|
||||
: undefined;
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const text = content
|
||||
.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")
|
||||
.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();
|
||||
@@ -558,11 +662,15 @@ export default function (pi: ExtensionAPI) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function collectTelegramFileInfos(messages: TelegramMessage[]): TelegramFileInfo[] {
|
||||
function collectTelegramFileInfos(
|
||||
messages: TelegramMessage[],
|
||||
): TelegramFileInfo[] {
|
||||
const files: TelegramFileInfo[] = [];
|
||||
for (const message of messages) {
|
||||
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
||||
const photo = [...message.photo].sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0)).pop();
|
||||
const photo = [...message.photo]
|
||||
.sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
|
||||
.pop();
|
||||
if (photo) {
|
||||
files.push({
|
||||
file_id: photo.file_id,
|
||||
@@ -573,7 +681,9 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
if (message.document) {
|
||||
const fileName = message.document.file_name || `document-${message.message_id}${guessExtensionFromMime(message.document.mime_type, "")}`;
|
||||
const fileName =
|
||||
message.document.file_name ||
|
||||
`document-${message.message_id}${guessExtensionFromMime(message.document.mime_type, "")}`;
|
||||
files.push({
|
||||
file_id: message.document.file_id,
|
||||
fileName,
|
||||
@@ -582,7 +692,9 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
if (message.video) {
|
||||
const fileName = message.video.file_name || `video-${message.message_id}${guessExtensionFromMime(message.video.mime_type, ".mp4")}`;
|
||||
const fileName =
|
||||
message.video.file_name ||
|
||||
`video-${message.message_id}${guessExtensionFromMime(message.video.mime_type, ".mp4")}`;
|
||||
files.push({
|
||||
file_id: message.video.file_id,
|
||||
fileName,
|
||||
@@ -591,7 +703,9 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
if (message.audio) {
|
||||
const fileName = message.audio.file_name || `audio-${message.message_id}${guessExtensionFromMime(message.audio.mime_type, ".mp3")}`;
|
||||
const fileName =
|
||||
message.audio.file_name ||
|
||||
`audio-${message.message_id}${guessExtensionFromMime(message.audio.mime_type, ".mp3")}`;
|
||||
files.push({
|
||||
file_id: message.audio.file_id,
|
||||
fileName,
|
||||
@@ -608,7 +722,9 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
if (message.animation) {
|
||||
const fileName = message.animation.file_name || `animation-${message.message_id}${guessExtensionFromMime(message.animation.mime_type, ".mp4")}`;
|
||||
const fileName =
|
||||
message.animation.file_name ||
|
||||
`animation-${message.message_id}${guessExtensionFromMime(message.animation.mime_type, ".mp4")}`;
|
||||
files.push({
|
||||
file_id: message.animation.file_id,
|
||||
fileName,
|
||||
@@ -628,11 +744,18 @@ export default function (pi: ExtensionAPI) {
|
||||
return files;
|
||||
}
|
||||
|
||||
async function buildTelegramFiles(messages: TelegramMessage[]): Promise<DownloadedTelegramFile[]> {
|
||||
async function buildTelegramFiles(
|
||||
messages: TelegramMessage[],
|
||||
): Promise<DownloadedTelegramFile[]> {
|
||||
const downloaded: DownloadedTelegramFile[] = [];
|
||||
for (const file of collectTelegramFileInfos(messages)) {
|
||||
const path = await downloadTelegramFile(file.file_id, file.fileName);
|
||||
downloaded.push({ path, fileName: file.fileName, isImage: file.isImage, mimeType: file.mimeType });
|
||||
downloaded.push({
|
||||
path,
|
||||
fileName: file.fileName,
|
||||
isImage: file.isImage,
|
||||
mimeType: file.mimeType,
|
||||
});
|
||||
}
|
||||
return downloaded;
|
||||
}
|
||||
@@ -641,14 +764,22 @@ export default function (pi: ExtensionAPI) {
|
||||
if (!ctx.hasUI || setupInProgress) return;
|
||||
setupInProgress = true;
|
||||
try {
|
||||
const token = await ctx.ui.input("Telegram bot token", "123456:ABCDEF...");
|
||||
const token = await ctx.ui.input(
|
||||
"Telegram bot token",
|
||||
"123456:ABCDEF...",
|
||||
);
|
||||
if (!token) return;
|
||||
|
||||
const nextConfig: TelegramConfig = { ...config, botToken: token.trim() };
|
||||
const response = await fetch(`https://api.telegram.org/bot${nextConfig.botToken}/getMe`);
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${nextConfig.botToken}/getMe`,
|
||||
);
|
||||
const data = (await response.json()) as TelegramApiResponse<TelegramUser>;
|
||||
if (!data.ok || !data.result) {
|
||||
ctx.ui.notify(data.description || "Invalid Telegram bot token", "error");
|
||||
ctx.ui.notify(
|
||||
data.description || "Invalid Telegram bot token",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -656,8 +787,14 @@ export default function (pi: ExtensionAPI) {
|
||||
nextConfig.botUsername = data.result.username;
|
||||
config = nextConfig;
|
||||
await writeConfig(config);
|
||||
ctx.ui.notify(`Telegram bot connected: @${config.botUsername ?? "unknown"}`, "info");
|
||||
ctx.ui.notify("Send /start to your bot in Telegram to pair this extension with your account.", "info");
|
||||
ctx.ui.notify(
|
||||
`Telegram bot connected: @${config.botUsername ?? "unknown"}`,
|
||||
"info",
|
||||
);
|
||||
ctx.ui.notify(
|
||||
"Send /start to your bot in Telegram to pair this extension with your account.",
|
||||
"info",
|
||||
);
|
||||
await startPolling(ctx);
|
||||
updateStatus(ctx);
|
||||
} finally {
|
||||
@@ -673,7 +810,10 @@ export default function (pi: ExtensionAPI) {
|
||||
pollingPromise = undefined;
|
||||
}
|
||||
|
||||
function formatTelegramHistoryText(rawText: string, files: DownloadedTelegramFile[]): string {
|
||||
function formatTelegramHistoryText(
|
||||
rawText: string,
|
||||
files: DownloadedTelegramFile[],
|
||||
): string {
|
||||
let summary = rawText.length > 0 ? rawText : "(no text)";
|
||||
if (files.length > 0) {
|
||||
summary += `\nAttachments:`;
|
||||
@@ -689,8 +829,12 @@ export default function (pi: ExtensionAPI) {
|
||||
historyTurns: PendingTelegramTurn[] = [],
|
||||
): Promise<PendingTelegramTurn> {
|
||||
const firstMessage = messages[0];
|
||||
if (!firstMessage) throw new Error("Missing Telegram message for turn creation");
|
||||
const rawText = messages.map((message) => (message.text || message.caption || "").trim()).filter(Boolean).join("\n\n");
|
||||
if (!firstMessage)
|
||||
throw new Error("Missing Telegram message for turn creation");
|
||||
const rawText = messages
|
||||
.map((message) => (message.text || message.caption || "").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const files = await buildTelegramFiles(messages);
|
||||
const content: Array<TextContent | ImageContent> = [];
|
||||
let prompt = `${TELEGRAM_PREFIX}`;
|
||||
@@ -735,10 +879,16 @@ export default function (pi: ExtensionAPI) {
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchAuthorizedTelegramMessages(messages: TelegramMessage[], ctx: ExtensionContext): Promise<void> {
|
||||
async function dispatchAuthorizedTelegramMessages(
|
||||
messages: TelegramMessage[],
|
||||
ctx: ExtensionContext,
|
||||
): Promise<void> {
|
||||
const firstMessage = messages[0];
|
||||
if (!firstMessage) return;
|
||||
const rawText = messages.map((message) => (message.text || message.caption || "").trim()).find((text) => text.length > 0) || "";
|
||||
const rawText =
|
||||
messages
|
||||
.map((message) => (message.text || message.caption || "").trim())
|
||||
.find((text) => text.length > 0) || "";
|
||||
const lower = rawText.toLowerCase();
|
||||
|
||||
if (lower === "stop" || lower === "/stop") {
|
||||
@@ -748,28 +898,53 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
currentAbort();
|
||||
updateStatus(ctx);
|
||||
await sendTextReply(firstMessage.chat.id, firstMessage.message_id, "Aborted current turn.");
|
||||
await sendTextReply(
|
||||
firstMessage.chat.id,
|
||||
firstMessage.message_id,
|
||||
"Aborted current turn.",
|
||||
);
|
||||
} else {
|
||||
await sendTextReply(firstMessage.chat.id, firstMessage.message_id, "No active turn.");
|
||||
await sendTextReply(
|
||||
firstMessage.chat.id,
|
||||
firstMessage.message_id,
|
||||
"No active turn.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lower === "/compact") {
|
||||
if (!ctx.isIdle()) {
|
||||
await sendTextReply(firstMessage.chat.id, firstMessage.message_id, "Cannot compact while pi is busy. Send \"stop\" first.");
|
||||
await sendTextReply(
|
||||
firstMessage.chat.id,
|
||||
firstMessage.message_id,
|
||||
'Cannot compact while pi is busy. Send "stop" first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
ctx.compact({
|
||||
onComplete: () => {
|
||||
void sendTextReply(firstMessage.chat.id, firstMessage.message_id, "Compaction completed.");
|
||||
void sendTextReply(
|
||||
firstMessage.chat.id,
|
||||
firstMessage.message_id,
|
||||
"Compaction completed.",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
void sendTextReply(firstMessage.chat.id, firstMessage.message_id, `Compaction failed: ${message}`);
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
void sendTextReply(
|
||||
firstMessage.chat.id,
|
||||
firstMessage.message_id,
|
||||
`Compaction failed: ${message}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
await sendTextReply(firstMessage.chat.id, firstMessage.message_id, "Compaction started.");
|
||||
await sendTextReply(
|
||||
firstMessage.chat.id,
|
||||
firstMessage.message_id,
|
||||
"Compaction started.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -781,7 +956,8 @@ export default function (pi: ExtensionAPI) {
|
||||
let totalCost = 0;
|
||||
|
||||
for (const entry of ctx.sessionManager.getEntries()) {
|
||||
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
||||
if (entry.type !== "message" || entry.message.role !== "assistant")
|
||||
continue;
|
||||
totalInput += entry.message.usage.input;
|
||||
totalOutput += entry.message.usage.output;
|
||||
totalCacheRead += entry.message.usage.cacheRead;
|
||||
@@ -802,13 +978,19 @@ export default function (pi: ExtensionAPI) {
|
||||
if (tokenParts.length > 0) {
|
||||
lines.push(`Usage: ${tokenParts.join(" ")}`);
|
||||
}
|
||||
const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
|
||||
const usingSubscription = ctx.model
|
||||
? ctx.modelRegistry.isUsingOAuth(ctx.model)
|
||||
: false;
|
||||
if (totalCost || usingSubscription) {
|
||||
lines.push(`Cost: $${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
||||
lines.push(
|
||||
`Cost: $${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`,
|
||||
);
|
||||
}
|
||||
if (usage) {
|
||||
const contextWindow = usage.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
||||
const percent = usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?";
|
||||
const contextWindow =
|
||||
usage.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
||||
const percent =
|
||||
usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?";
|
||||
lines.push(`Context: ${percent}/${formatTokens(contextWindow)}`);
|
||||
} else {
|
||||
lines.push("Context: unknown");
|
||||
@@ -816,7 +998,11 @@ export default function (pi: ExtensionAPI) {
|
||||
if (lines.length === 0) {
|
||||
lines.push("No usage data yet.");
|
||||
}
|
||||
await sendTextReply(firstMessage.chat.id, firstMessage.message_id, lines.join("\n"));
|
||||
await sendTextReply(
|
||||
firstMessage.chat.id,
|
||||
firstMessage.message_id,
|
||||
lines.join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -834,7 +1020,9 @@ export default function (pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const historyTurns = preserveQueuedTurnsAsHistory ? queuedTelegramTurns.splice(0) : [];
|
||||
const historyTurns = preserveQueuedTurnsAsHistory
|
||||
? queuedTelegramTurns.splice(0)
|
||||
: [];
|
||||
preserveQueuedTurnsAsHistory = false;
|
||||
const turn = await createTelegramTurn(messages, historyTurns);
|
||||
queuedTelegramTurns.push(turn);
|
||||
@@ -845,7 +1033,10 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthorizedTelegramMessage(message: TelegramMessage, ctx: ExtensionContext): Promise<void> {
|
||||
async function handleAuthorizedTelegramMessage(
|
||||
message: TelegramMessage,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<void> {
|
||||
if (message.media_group_id) {
|
||||
const key = `${message.chat.id}:${message.media_group_id}`;
|
||||
const existing = mediaGroups.get(key) ?? { messages: [] };
|
||||
@@ -864,37 +1055,65 @@ export default function (pi: ExtensionAPI) {
|
||||
await dispatchAuthorizedTelegramMessages([message], ctx);
|
||||
}
|
||||
|
||||
async function handleUpdate(update: TelegramUpdate, ctx: ExtensionContext): Promise<void> {
|
||||
async function handleUpdate(
|
||||
update: TelegramUpdate,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<void> {
|
||||
const message = update.message || update.edited_message;
|
||||
if (!message || message.chat.type !== "private" || !message.from || message.from.is_bot) return;
|
||||
if (
|
||||
!message ||
|
||||
message.chat.type !== "private" ||
|
||||
!message.from ||
|
||||
message.from.is_bot
|
||||
)
|
||||
return;
|
||||
|
||||
if (config.allowedUserId === undefined) {
|
||||
config.allowedUserId = message.from.id;
|
||||
await writeConfig(config);
|
||||
updateStatus(ctx);
|
||||
await sendTextReply(message.chat.id, message.message_id, "Telegram bridge paired with this account.");
|
||||
await sendTextReply(
|
||||
message.chat.id,
|
||||
message.message_id,
|
||||
"Telegram bridge paired with this account.",
|
||||
);
|
||||
}
|
||||
|
||||
if (message.from.id !== config.allowedUserId) {
|
||||
await sendTextReply(message.chat.id, message.message_id, "This bot is not authorized for your account.");
|
||||
await sendTextReply(
|
||||
message.chat.id,
|
||||
message.message_id,
|
||||
"This bot is not authorized for your account.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await handleAuthorizedTelegramMessage(message, ctx);
|
||||
}
|
||||
|
||||
async function pollLoop(ctx: ExtensionContext, signal: AbortSignal): Promise<void> {
|
||||
async function pollLoop(
|
||||
ctx: ExtensionContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (!config.botToken) return;
|
||||
|
||||
try {
|
||||
await callTelegram("deleteWebhook", { drop_pending_updates: false }, { signal });
|
||||
await callTelegram(
|
||||
"deleteWebhook",
|
||||
{ drop_pending_updates: false },
|
||||
{ signal },
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (config.lastUpdateId === undefined) {
|
||||
try {
|
||||
const updates = await callTelegram<TelegramUpdate[]>("getUpdates", { offset: -1, limit: 1, timeout: 0 }, { signal });
|
||||
const updates = await callTelegram<TelegramUpdate[]>(
|
||||
"getUpdates",
|
||||
{ offset: -1, limit: 1, timeout: 0 },
|
||||
{ signal },
|
||||
);
|
||||
const last = updates.at(-1);
|
||||
if (last) {
|
||||
config.lastUpdateId = last.update_id;
|
||||
@@ -910,7 +1129,10 @@ export default function (pi: ExtensionAPI) {
|
||||
const updates = await callTelegram<TelegramUpdate[]>(
|
||||
"getUpdates",
|
||||
{
|
||||
offset: config.lastUpdateId !== undefined ? config.lastUpdateId + 1 : undefined,
|
||||
offset:
|
||||
config.lastUpdateId !== undefined
|
||||
? config.lastUpdateId + 1
|
||||
: undefined,
|
||||
limit: 10,
|
||||
timeout: 30,
|
||||
allowed_updates: ["message", "edited_message"],
|
||||
@@ -924,7 +1146,8 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
if (error instanceof DOMException && error.name === "AbortError")
|
||||
return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
updateStatus(ctx, message);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
@@ -947,17 +1170,23 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "telegram_attach",
|
||||
label: "Telegram Attach",
|
||||
description: "Queue one or more local files to be sent with the next Telegram reply.",
|
||||
description:
|
||||
"Queue one or more local files to be sent with the next Telegram reply.",
|
||||
promptSnippet: "Queue local files to be sent with the next Telegram reply.",
|
||||
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.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
paths: Type.Array(Type.String({ description: "Local file path to attach" }), { minItems: 1, maxItems: MAX_ATTACHMENTS_PER_TURN }),
|
||||
paths: Type.Array(
|
||||
Type.String({ description: "Local file path to attach" }),
|
||||
{ minItems: 1, maxItems: MAX_ATTACHMENTS_PER_TURN },
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
if (!activeTelegramTurn) {
|
||||
throw new Error("telegram_attach can only be used while replying to an active Telegram turn");
|
||||
throw new Error(
|
||||
"telegram_attach can only be used while replying to an active Telegram turn",
|
||||
);
|
||||
}
|
||||
const added: string[] = [];
|
||||
for (const inputPath of params.paths) {
|
||||
@@ -965,14 +1194,27 @@ export default function (pi: ExtensionAPI) {
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Not a file: ${inputPath}`);
|
||||
}
|
||||
if (activeTelegramTurn.queuedAttachments.length >= MAX_ATTACHMENTS_PER_TURN) {
|
||||
throw new Error(`Attachment limit reached (${MAX_ATTACHMENTS_PER_TURN})`);
|
||||
if (
|
||||
activeTelegramTurn.queuedAttachments.length >=
|
||||
MAX_ATTACHMENTS_PER_TURN
|
||||
) {
|
||||
throw new Error(
|
||||
`Attachment limit reached (${MAX_ATTACHMENTS_PER_TURN})`,
|
||||
);
|
||||
}
|
||||
activeTelegramTurn.queuedAttachments.push({ path: inputPath, fileName: basename(inputPath) });
|
||||
activeTelegramTurn.queuedAttachments.push({
|
||||
path: inputPath,
|
||||
fileName: basename(inputPath),
|
||||
});
|
||||
added.push(inputPath);
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Queued ${added.length} Telegram attachment(s).` }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Queued ${added.length} Telegram attachment(s).`,
|
||||
},
|
||||
],
|
||||
details: { paths: added },
|
||||
};
|
||||
},
|
||||
@@ -1056,7 +1298,11 @@ export default function (pi: ExtensionAPI) {
|
||||
const nextTurn = queuedTelegramTurns.shift();
|
||||
if (nextTurn) {
|
||||
activeTelegramTurn = { ...nextTurn };
|
||||
previewState = { mode: draftSupport === "unsupported" ? "message" : "draft", pendingText: "", lastSentText: "" };
|
||||
previewState = {
|
||||
mode: draftSupport === "unsupported" ? "message" : "draft",
|
||||
pendingText: "",
|
||||
lastSentText: "",
|
||||
};
|
||||
startTypingLoop(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1065,16 +1311,28 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
pi.on("message_start", async (event, _ctx) => {
|
||||
if (!activeTelegramTurn || !isAssistantMessage(event.message)) return;
|
||||
if (previewState && (previewState.pendingText.trim().length > 0 || previewState.lastSentText.trim().length > 0)) {
|
||||
if (
|
||||
previewState &&
|
||||
(previewState.pendingText.trim().length > 0 ||
|
||||
previewState.lastSentText.trim().length > 0)
|
||||
) {
|
||||
await finalizePreview(activeTelegramTurn.chatId);
|
||||
}
|
||||
previewState = { mode: draftSupport === "unsupported" ? "message" : "draft", pendingText: "", lastSentText: "" };
|
||||
previewState = {
|
||||
mode: draftSupport === "unsupported" ? "message" : "draft",
|
||||
pendingText: "",
|
||||
lastSentText: "",
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("message_update", async (event, _ctx) => {
|
||||
if (!activeTelegramTurn || !isAssistantMessage(event.message)) return;
|
||||
if (!previewState) {
|
||||
previewState = { mode: draftSupport === "unsupported" ? "message" : "draft", pendingText: "", lastSentText: "" };
|
||||
previewState = {
|
||||
mode: draftSupport === "unsupported" ? "message" : "draft",
|
||||
pendingText: "",
|
||||
lastSentText: "",
|
||||
};
|
||||
}
|
||||
previewState.pendingText = getMessageText(event.message);
|
||||
schedulePreviewFlush(activeTelegramTurn.chatId);
|
||||
@@ -1095,7 +1353,12 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
if (assistant.stopReason === "error") {
|
||||
await clearPreview(turn.chatId);
|
||||
await sendTextReply(turn.chatId, turn.replyToMessageId, assistant.errorMessage || "Telegram bridge: pi failed while processing the request.");
|
||||
await sendTextReply(
|
||||
turn.chatId,
|
||||
turn.replyToMessageId,
|
||||
assistant.errorMessage ||
|
||||
"Telegram bridge: pi failed while processing the request.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1107,14 +1370,22 @@ export default function (pi: ExtensionAPI) {
|
||||
if (finalText && finalText.length <= MAX_MESSAGE_LENGTH) {
|
||||
const finalized = await finalizePreview(turn.chatId);
|
||||
if (!finalized && turn.queuedAttachments.length > 0 && !finalText) {
|
||||
await sendTextReply(turn.chatId, turn.replyToMessageId, "Attached requested file(s).");
|
||||
await sendTextReply(
|
||||
turn.chatId,
|
||||
turn.replyToMessageId,
|
||||
"Attached requested file(s).",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await clearPreview(turn.chatId);
|
||||
if (finalText) {
|
||||
await sendTextReply(turn.chatId, turn.replyToMessageId, finalText);
|
||||
} else if (turn.queuedAttachments.length > 0) {
|
||||
await sendTextReply(turn.chatId, turn.replyToMessageId, "Attached requested file(s).");
|
||||
await sendTextReply(
|
||||
turn.chatId,
|
||||
turn.replyToMessageId,
|
||||
"Attached requested file(s).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+3791
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -4,7 +4,13 @@
|
||||
"private": false,
|
||||
"description": "Telegram DM bridge extension for pi",
|
||||
"type": "module",
|
||||
"keywords": ["pi-package", "pi", "telegram", "bot", "extension"],
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi",
|
||||
"telegram",
|
||||
"bot",
|
||||
"extension"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -15,7 +21,9 @@
|
||||
"url": "https://github.com/badlogic/pi-telegram/issues"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-ai": "*",
|
||||
|
||||
Reference in New Issue
Block a user