intergram-js/src/formatter.js
Eagle cd56658c7f feat: initial commit — GramJS Telegram relay for OpenClaw inject
Telegram userbot relay that monitors specified group chats and forwards
bot messages to OpenClaw's inject-http endpoint via webhook.

Features:
- GramJS (MTProto) for reading bot messages (invisible to Bot API)
- Media download + multipart form upload
- BOT_EXCLUDE filter (by user ID or @username)
- HTML entity formatting (bold, italic, code, pre, links, etc.)
- Graceful shutdown on SIGINT/SIGTERM
- .env configuration with validation
2026-02-10 20:09:08 +08:00

197 lines
5.4 KiB
JavaScript

import { Api } from "telegram";
function escapeHtml(text) {
return text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function entitiesToHtml(text, entities) {
if (!text || !entities || entities.length === 0) {
return escapeHtml(text || "");
}
const buf = Buffer.from(text, "utf16le");
const insertions = [];
for (const ent of entities) {
const start = ent.offset * 2;
const end = (ent.offset + ent.length) * 2;
let openTag = "";
let closeTag = "";
if (ent instanceof Api.MessageEntityBold) {
openTag = "<b>"; closeTag = "</b>";
} else if (ent instanceof Api.MessageEntityItalic) {
openTag = "<i>"; closeTag = "</i>";
} else if (ent instanceof Api.MessageEntityCode) {
openTag = "<code>"; closeTag = "</code>";
} else if (ent instanceof Api.MessageEntityPre) {
const lang = ent.language || "";
openTag = lang
? `<pre><code class="language-${escapeHtml(lang)}">`
: "<pre><code>";
closeTag = "</code></pre>";
} else if (ent instanceof Api.MessageEntityStrike) {
openTag = "<s>"; closeTag = "</s>";
} else if (ent instanceof Api.MessageEntityUnderline) {
openTag = "<u>"; closeTag = "</u>";
} else if (ent instanceof Api.MessageEntitySpoiler) {
openTag = '<span class="spoiler">'; closeTag = "</span>";
} else if (ent instanceof Api.MessageEntityTextUrl) {
openTag = `<a href="${escapeHtml(ent.url)}">`; closeTag = "</a>";
}
if (openTag) {
insertions.push({ start, end, openTag, closeTag });
}
}
// Build open/close maps by byte position
const opens = new Map();
const closes = new Map();
for (const { start, end, openTag, closeTag } of insertions) {
if (!opens.has(start)) opens.set(start, []);
opens.get(start).push(openTag);
if (!closes.has(end)) closes.set(end, []);
closes.get(end).unshift(closeTag);
}
const positions = [
...new Set([...opens.keys(), ...closes.keys(), buf.length]),
].sort((a, b) => a - b);
const result = [];
let pos = 0;
for (const target of positions) {
if (target > pos) {
const chunk = buf.subarray(pos, target).toString("utf16le");
result.push(escapeHtml(chunk));
pos = target;
}
for (const tag of closes.get(target) || []) result.push(tag);
for (const tag of opens.get(target) || []) result.push(tag);
}
if (pos < buf.length) {
result.push(escapeHtml(buf.subarray(pos).toString("utf16le")));
}
return result.join("");
}
export function mediaType(message) {
const media = message.media;
if (!media) return null;
if (media instanceof Api.MessageMediaPhoto) return "photo";
if (media instanceof Api.MessageMediaDocument) {
const doc = media.document;
if (doc && doc.attributes) {
for (const attr of doc.attributes) {
if (attr instanceof Api.DocumentAttributeVideo) return "video";
if (attr instanceof Api.DocumentAttributeAudio) return "audio";
if (attr instanceof Api.DocumentAttributeSticker) return "sticker";
if (attr instanceof Api.DocumentAttributeAnimated) return "animation";
}
return "document";
}
}
if (media instanceof Api.MessageMediaWebPage) return "webpage";
return "unknown";
}
export function mediaSize(message) {
const media = message.media;
if (media instanceof Api.MessageMediaDocument && media.document) {
return Number(media.document.size || 0);
}
if (media instanceof Api.MessageMediaPhoto && media.photo) {
const sizes = media.photo.sizes || [];
for (let i = sizes.length - 1; i >= 0; i--) {
if (sizes[i].size != null) return Number(sizes[i].size);
}
return 0;
}
return 0;
}
function chatType(chat) {
if (chat instanceof Api.Channel) {
return chat.megagroup ? "supergroup" : "channel";
}
if (chat instanceof Api.Chat) return "group";
return "private";
}
function toNum(v) {
return typeof v === "bigint" ? Number(v) : v;
}
export async function formatMessage(client, message, accountId = "") {
let sender = null;
if (message.senderId) {
try { sender = await client.getEntity(message.senderId); } catch {}
}
let chat = null;
try { chat = await client.getEntity(message.chatId); } catch {}
// from (sender)
const fromObj = {};
if (sender) {
fromObj.id = toNum(sender.id);
fromObj.is_bot = sender.bot || false;
fromObj.first_name = sender.firstName || "";
if (sender.lastName) fromObj.last_name = sender.lastName;
if (sender.username) fromObj.username = sender.username;
}
// chat
const chatObj = {
id: toNum(message.chatId),
type: chat ? chatType(chat) : "unknown",
};
if (chat) {
if (chat.title) chatObj.title = chat.title;
if (chat.username) chatObj.username = chat.username;
}
// message body
const text = message.message || "";
const msgObj = {
message_id: message.id,
from: fromObj,
chat: chatObj,
date: message.date,
text,
text_html: entitiesToHtml(text, message.entities),
};
// reply
if (message.replyTo) {
msgObj.reply_to_message = {
message_id: message.replyTo.replyToMsgId,
};
}
// media
const mt = mediaType(message);
if (mt && mt !== "webpage") {
msgObj.media_type = mt;
msgObj.has_media = true;
msgObj.media_size = mediaSize(message);
}
const result = {
update: {
update_id: message.id,
message: msgObj,
},
};
if (accountId) result.accountId = accountId;
return result;
}