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
This commit is contained in:
commit
cd56658c7f
9 changed files with 1345 additions and 0 deletions
197
src/formatter.js
Normal file
197
src/formatter.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue