Files
2026-05-31 13:49:36 +08:00

304 lines
11 KiB
TypeScript

/**
* Monitor Telegram module (independent of the main project's tg-push)
*
* Features:
* - Config persisted to monitor/.tg-config.json
* - Auto-fetch chat_id (only available after the user has messaged the bot)
* - Long-poll getUpdates to receive and respond to commands
* - Commands: /status overview, /d <account name> single account, /help
*/
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const TG_CONFIG_FILE = resolve(__dirname, ".tg-config.json");
export interface TgConfig {
enabled: boolean;
botToken: string;
chatId: string;
}
const DEFAULT_CONFIG: TgConfig = {
enabled: false,
botToken: "",
chatId: "",
};
export function loadTgConfig(): TgConfig {
try {
if (!existsSync(TG_CONFIG_FILE)) return { ...DEFAULT_CONFIG };
const raw = JSON.parse(readFileSync(TG_CONFIG_FILE, "utf-8"));
return {
enabled: !!raw.enabled,
botToken: typeof raw.botToken === "string" ? raw.botToken : "",
chatId: typeof raw.chatId === "string" ? raw.chatId : "",
};
} catch (err) {
console.warn(`[monitor.TG] Failed to load config: ${err instanceof Error ? err.message : String(err)}`);
return { ...DEFAULT_CONFIG };
}
}
export function saveTgConfig(cfg: TgConfig): void {
try {
writeFileSync(TG_CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
} catch (err) {
console.warn(`[monitor.TG] Failed to save config: ${err instanceof Error ? err.message : String(err)}`);
}
}
export function maskToken(token: string): string {
if (!token) return "";
if (token.length < 14) return token.slice(0, 4) + "***";
return token.slice(0, 10) + "***" + token.slice(-4);
}
/** Auto-fetch chat_id: the user must have sent the bot any message (e.g. /start) before updates exist */
export async function autoDetectChatId(botToken: string): Promise<{ ok: boolean; chatId?: string; error?: string }> {
if (!botToken) return { ok: false, error: "Bot Token is empty" };
const url = `https://api.telegram.org/bot${botToken}/getUpdates`;
try {
const res = await fetch(url);
const data = await res.json() as { ok: boolean; result?: Array<{ message?: { chat?: { id?: number } } }>; description?: string };
if (!data.ok) return { ok: false, error: data.description || `HTTP ${res.status}` };
const results = data.result || [];
if (results.length === 0) return { ok: false, error: "No message found. Send the bot a message in Telegram first (e.g. /start), then try again" };
for (let i = results.length - 1; i >= 0; i--) {
const id = results[i].message?.chat?.id;
if (typeof id === "number") return { ok: true, chatId: String(id) };
}
return { ok: false, error: "chat.id not found in the message" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export interface InlineButton { text: string; callback_data: string; }
export interface SendOpts {
parseMode?: "Markdown" | "MarkdownV2" | "HTML";
inlineKeyboard?: InlineButton[][];
/** Specify a chatId to override cfg.chatId (used when responding to someone else's command) */
toChatId?: string;
}
/** Send a message */
export async function sendTgMessage(cfg: TgConfig, text: string, opts: SendOpts = {}): Promise<{ ok: boolean; error?: string; messageId?: number }> {
const targetChat = opts.toChatId || cfg.chatId;
if (!cfg.botToken || !targetChat) return { ok: false, error: "Bot Token or Chat ID not configured" };
const url = `https://api.telegram.org/bot${cfg.botToken}/sendMessage`;
try {
const body: Record<string, unknown> = {
chat_id: targetChat,
text,
disable_web_page_preview: true,
};
if (opts.parseMode) body.parse_mode = opts.parseMode;
if (opts.inlineKeyboard) body.reply_markup = { inline_keyboard: opts.inlineKeyboard };
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json() as { ok: boolean; description?: string; result?: { message_id?: number } };
if (!data.ok) return { ok: false, error: data.description || `HTTP ${res.status}` };
return { ok: true, messageId: data.result?.message_id };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
/** Delete a message (used by the "🔄 Refresh" button: delete the old one then send a new one, triggering TG's animation to signal a successful refresh) */
export async function deleteTgMessage(cfg: TgConfig, chatId: string, messageId: number): Promise<{ ok: boolean; error?: string }> {
const url = `https://api.telegram.org/bot${cfg.botToken}/deleteMessage`;
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId, message_id: messageId }),
});
const data = await res.json() as { ok: boolean; description?: string };
if (!data.ok) return { ok: false, error: data.description || `HTTP ${res.status}` };
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
/** Edit an existing message (kept as a fallback; currently refresh uses the delete+send path) */
export async function editTgMessage(cfg: TgConfig, chatId: string, messageId: number, text: string, opts: { inlineKeyboard?: InlineButton[][]; parseMode?: "Markdown" | "MarkdownV2" | "HTML" } = {}): Promise<{ ok: boolean; error?: string }> {
const url = `https://api.telegram.org/bot${cfg.botToken}/editMessageText`;
try {
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
text,
disable_web_page_preview: true,
};
if (opts.parseMode) body.parse_mode = opts.parseMode;
if (opts.inlineKeyboard) body.reply_markup = { inline_keyboard: opts.inlineKeyboard };
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json() as { ok: boolean; description?: string };
// "message is not modified" is not an error (TG rejects the edit when content is identical, but semantically it succeeded)
if (!data.ok) {
const desc = data.description || "";
if (desc.includes("message is not modified")) return { ok: true };
return { ok: false, error: desc || `HTTP ${res.status}` };
}
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
/** Answer a callback_query (must ack after a button press, otherwise the TG client keeps spinning) */
export async function answerCallbackQuery(cfg: TgConfig, callbackQueryId: string, text?: string): Promise<void> {
if (!cfg.botToken) return;
const url = `https://api.telegram.org/bot${cfg.botToken}/answerCallbackQuery`;
try {
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ callback_query_id: callbackQueryId, text }),
});
} catch {
// Ignore ack errors
}
}
// ── Long polling ──
export interface TgUpdate {
update_id: number;
message?: {
message_id: number;
chat: { id: number; type: string };
from?: { id: number; username?: string };
text?: string;
date: number;
};
callback_query?: {
id: string;
from: { id: number; username?: string };
message?: { message_id: number; chat: { id: number; type: string } };
data?: string;
};
}
export interface CallbackContext {
callbackQueryId: string;
fromChatId: number;
messageId?: number;
data: string;
}
/** Long-poll getUpdates */
export class TgPoller {
private offset = 0;
private stopped = false;
private timer: NodeJS.Timeout | null = null;
private currentToken = "";
constructor(
private readonly getCfg: () => TgConfig,
private readonly onCommand: (cmd: string, args: string, fromChatId: number) => Promise<void> | void,
private readonly onCallback?: (ctx: CallbackContext) => Promise<void> | void,
) {}
start(): void {
this.stopped = false;
void this.loop();
}
stop(): void {
this.stopped = true;
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
}
/** Called when the token changes - resets offset to avoid getting stuck on 401 */
resetOnTokenChange(): void {
this.offset = 0;
}
private async loop(): Promise<void> {
while (!this.stopped) {
const cfg = this.getCfg();
// Idle when config is invalid, check every 2s
if (!cfg.enabled || !cfg.botToken) {
if (this.currentToken !== cfg.botToken) this.offset = 0;
this.currentToken = cfg.botToken;
await this.sleep(2000);
continue;
}
// token changed → reset offset
if (this.currentToken !== cfg.botToken) {
this.offset = 0;
this.currentToken = cfg.botToken;
}
try {
const url = `https://api.telegram.org/bot${cfg.botToken}/getUpdates?timeout=25&offset=${this.offset}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30_000);
const res = await fetch(url, { signal: ctrl.signal });
clearTimeout(timer);
const data = await res.json() as { ok: boolean; result?: TgUpdate[]; description?: string };
if (!data.ok) {
console.warn(`[monitor.TG] getUpdates failed: ${data.description || res.status}`);
await this.sleep(5000);
continue;
}
for (const update of data.result || []) {
this.offset = Math.max(this.offset, update.update_id + 1);
// callback_query: button click
if (update.callback_query && this.onCallback) {
const cq = update.callback_query;
try {
await this.onCallback({
callbackQueryId: cq.id,
fromChatId: cq.from.id,
messageId: cq.message?.message_id,
data: cq.data || "",
});
} catch (err) {
console.warn(`[monitor.TG] Error handling callback: ${err instanceof Error ? err.message : String(err)}`);
}
continue;
}
const msg = update.message;
if (!msg || typeof msg.text !== "string") continue;
const text = msg.text.trim();
if (!text.startsWith("/")) continue;
const space = text.indexOf(" ");
// Command may carry an @bot suffix (in groups), strip it
let cmd = (space === -1 ? text : text.slice(0, space)).toLowerCase();
const at = cmd.indexOf("@");
if (at !== -1) cmd = cmd.slice(0, at);
const args = space === -1 ? "" : text.slice(space + 1).trim();
try {
await this.onCommand(cmd, args, msg.chat.id);
} catch (err) {
console.warn(`[monitor.TG] Error handling command: ${err instanceof Error ? err.message : String(err)}`);
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!this.stopped) {
console.warn(`[monitor.TG] Long-polling error: ${msg}`);
await this.sleep(5000);
}
}
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => {
this.timer = setTimeout(() => { this.timer = null; resolve(); }, ms);
});
}
}