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
197 lines
5.4 KiB
JavaScript
197 lines
5.4 KiB
JavaScript
import { Api } from "telegram";
|
|
|
|
function escapeHtml(text) {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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;
|
|
}
|