摘要: 1. 交件匣機制:每輪 prompt 附系統規則,Codex 把要給使用者的檔案放進 工作目錄的 .tgcodex-outbox/,回覆後 bot 自動傳到 Telegram(圖片走 sendPhoto、其餘或超過 10MB 走 sendDocument)並清空,一輪上限 10 個檔案。 2. bot 設定新增「允許 Codex 連網」開關(-c sandbox_workspace_write.network_access), 開啟後可用 codex 的 imagegen 技能生圖,已實測產出 PNG 進交件匣。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
132 lines
5.6 KiB
JavaScript
132 lines
5.6 KiB
JavaScript
'use strict';
|
|
// Telegram Bot API 薄封裝:零依賴,直接用內建 fetch。
|
|
// 含 Markdown→Telegram HTML 轉換(安全子集)、4000 字切分、HTML 解析失敗自動退回純文字。
|
|
|
|
function createTelegram(token) {
|
|
const API = `https://api.telegram.org/bot${token}`;
|
|
|
|
async function tg(method, params) {
|
|
const res = await fetch(`${API}/${method}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(params),
|
|
});
|
|
const data = await res.json();
|
|
if (!data.ok) throw new Error(`${method} 失敗:${data.description}`);
|
|
return data.result;
|
|
}
|
|
|
|
// Markdown → Telegram HTML(只轉安全子集:code block、inline code、連結、粗體、標題)。
|
|
// 單星號斜體與底線刻意不轉:會誤傷清單符號 * 與 username 的底線。
|
|
const SENTINEL = String.fromCharCode(0);
|
|
function mdToTgHtml(md) {
|
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const stash = [];
|
|
const put = (html) => SENTINEL + (stash.push(html) - 1) + SENTINEL;
|
|
let text = md.replace(/```\w*\n?([\s\S]*?)```/g, (_, code) => put(`<pre>${esc(code.replace(/\n$/, ''))}</pre>`));
|
|
text = text.replace(/`([^`\n]+)`/g, (_, code) => put(`<code>${esc(code)}</code>`));
|
|
text = esc(text);
|
|
text = text.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
|
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
|
text = text.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>');
|
|
return text.replace(new RegExp(`${SENTINEL}(\\d+)${SENTINEL}`, 'g'), (_, i) => stash[+i]);
|
|
}
|
|
|
|
// 送出訊息;HTML 解析失敗(切分把標籤切壞等)自動退回純文字。
|
|
async function sendMessage(chatId, text, { replyTo, html = false } = {}) {
|
|
const params = { chat_id: chatId, text };
|
|
if (replyTo) params.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
|
if (html) {
|
|
try {
|
|
return await tg('sendMessage', { ...params, parse_mode: 'HTML' });
|
|
} catch { /* fallback to plain */ }
|
|
}
|
|
return tg('sendMessage', params);
|
|
}
|
|
|
|
// 超過 4096 上限就切段(留餘裕切 4000);只有第一段帶 reply。
|
|
async function sendSplit(chatId, text, { replyTo, html = false } = {}) {
|
|
const chunks = text.match(/[\s\S]{1,4000}/g) || ['(空回應)'];
|
|
let first = null;
|
|
for (const chunk of chunks) {
|
|
const msg = await sendMessage(chatId, chunk, { replyTo, html });
|
|
if (!first) first = msg;
|
|
replyTo = undefined;
|
|
}
|
|
return first;
|
|
}
|
|
|
|
// 編輯既有訊息;過長則第一段 edit、其餘續傳新訊息。
|
|
async function editOrSplit(chatId, messageId, text, { html = false } = {}) {
|
|
const edit = async (t) => {
|
|
const params = { chat_id: chatId, message_id: messageId, text: t };
|
|
if (html) {
|
|
try {
|
|
return await tg('editMessageText', { ...params, parse_mode: 'HTML' });
|
|
} catch (err) {
|
|
// 「訊息沒變」不算錯;其他 HTML 失敗退回純文字
|
|
if (String(err.message).includes('message is not modified')) return;
|
|
return tg('editMessageText', params);
|
|
}
|
|
}
|
|
return tg('editMessageText', params);
|
|
};
|
|
if (text.length <= 4000) return edit(text);
|
|
const chunks = text.match(/[\s\S]{1,4000}/g) || [];
|
|
await edit(chunks[0]);
|
|
for (let i = 1; i < chunks.length; i++) await sendMessage(chatId, chunks[i], { html });
|
|
}
|
|
|
|
// 表情回應當狀態指示(👀 收到、👍 完成);很多聊天型別不支援,失敗直接吞。
|
|
function react(chatId, messageId, emoji) {
|
|
return tg('setMessageReaction', {
|
|
chat_id: chatId,
|
|
message_id: messageId,
|
|
reaction: emoji ? [{ type: 'emoji', emoji }] : [],
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// 上傳檔案到 Telegram:圖片走 sendPhoto,其餘(或 sendPhoto 失敗時)走 sendDocument
|
|
async function sendFile(chatId, filePath, { caption, replyTo } = {}) {
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const name = path.basename(filePath);
|
|
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
const size = fs.statSync(filePath).size;
|
|
|
|
const upload = async (method, field) => {
|
|
const form = new FormData();
|
|
form.append('chat_id', String(chatId));
|
|
if (caption) form.append('caption', caption.slice(0, 1000));
|
|
if (replyTo) form.append('reply_parameters', JSON.stringify({ message_id: replyTo, allow_sending_without_reply: true }));
|
|
form.append(field, new Blob([fs.readFileSync(filePath)]), name);
|
|
const res = await fetch(`${API}/${method}`, { method: 'POST', body: form });
|
|
const data = await res.json();
|
|
if (!data.ok) throw new Error(`${method} 失敗:${data.description}`);
|
|
return data.result;
|
|
};
|
|
|
|
// sendPhoto 上限 10MB,且某些格式會被拒,失敗就退回用檔案傳
|
|
if (isImage && size <= 9.5 * 1024 * 1024) {
|
|
try {
|
|
return await upload('sendPhoto', 'photo');
|
|
} catch { /* fall through */ }
|
|
}
|
|
return upload('sendDocument', 'document');
|
|
}
|
|
|
|
async function downloadFile(fileId, destPath) {
|
|
const fs = require('fs');
|
|
const info = await tg('getFile', { file_id: fileId });
|
|
const res = await fetch(`https://api.telegram.org/file/bot${token}/${info.file_path}`);
|
|
if (!res.ok) throw new Error(`下載檔案失敗:HTTP ${res.status}`);
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
fs.writeFileSync(destPath, buf);
|
|
return destPath;
|
|
}
|
|
|
|
return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile, sendFile };
|
|
}
|
|
|
|
module.exports = { createTelegram };
|