add TG skill

This commit is contained in:
xCyanGrizzly
2026-03-17 12:59:05 +01:00
parent d7bbb7587e
commit 761d5e0790
30 changed files with 4869 additions and 42 deletions

View File

@@ -8,6 +8,7 @@ import {
getGlobalDestinationChannel,
} from "./db/queries.js";
import { copyMessageToUser, sendTextMessage, sendPhotoMessage } from "./tdlib/client.js";
import { sleep } from "./util/flood-wait.js";
const log = childLogger("send-listener");
@@ -200,6 +201,9 @@ async function handleNewPackage(payload: string): Promise<void> {
"Failed to notify subscriber"
);
});
// Rate limit delay between notifications (~20 msgs/sec, under 30 msgs/sec bot limit)
await sleep(50);
}
} catch (err) {
log.error({ err, payload }, "Failed to process new_package notification");

View File

@@ -2,6 +2,7 @@ import tdl from "tdl";
import { getTdjson } from "prebuilt-tdlib";
import { config } from "../util/config.js";
import { childLogger } from "../util/logger.js";
import { withFloodWait } from "../util/flood-wait.js";
const log = childLogger("tdlib-bot");
@@ -66,14 +67,18 @@ export async function copyMessageToUser(
): Promise<void> {
if (!client) throw new Error("Bot client not initialized");
await client.invoke({
_: "forwardMessages",
chat_id: Number(toUserId),
from_chat_id: Number(fromChatId),
message_ids: [Number(messageId)],
send_copy: true,
remove_caption: false,
});
await withFloodWait(
() =>
client.invoke({
_: "forwardMessages",
chat_id: Number(toUserId),
from_chat_id: Number(fromChatId),
message_ids: [Number(messageId)],
send_copy: true,
remove_caption: false,
}),
"copyMessageToUser"
);
}
/**
@@ -87,20 +92,28 @@ export async function sendTextMessage(
if (!client) throw new Error("Bot client not initialized");
// Parse the text first
const parsed = await client.invoke({
_: "parseTextEntities",
text,
parse_mode: { _: parseMode, version: parseMode === "textParseModeMarkdown" ? 2 : 0 },
});
const parsed = await withFloodWait(
() =>
client.invoke({
_: "parseTextEntities",
text,
parse_mode: { _: parseMode, version: parseMode === "textParseModeMarkdown" ? 2 : 0 },
}),
"parseTextEntities"
);
await client.invoke({
_: "sendMessage",
chat_id: Number(chatId),
input_message_content: {
_: "inputMessageText",
text: parsed,
},
});
await withFloodWait(
() =>
client.invoke({
_: "sendMessage",
chat_id: Number(chatId),
input_message_content: {
_: "inputMessageText",
text: parsed,
},
}),
"sendTextMessage"
);
}
/**
@@ -121,23 +134,31 @@ export async function sendPhotoMessage(
try {
await writeFile(tempPath, photoData);
const parsedCaption = await client.invoke({
_: "parseTextEntities",
text: caption,
parse_mode: { _: "textParseModeMarkdown", version: 2 },
});
const parsedCaption = await withFloodWait(
() =>
client.invoke({
_: "parseTextEntities",
text: caption,
parse_mode: { _: "textParseModeMarkdown", version: 2 },
}),
"parsePhotoCaption"
);
await client.invoke({
_: "sendMessage",
chat_id: Number(chatId),
input_message_content: {
_: "inputMessagePhoto",
photo: { _: "inputFileLocal", path: tempPath },
caption: parsedCaption,
width: 0,
height: 0,
},
});
await withFloodWait(
() =>
client.invoke({
_: "sendMessage",
chat_id: Number(chatId),
input_message_content: {
_: "inputMessagePhoto",
photo: { _: "inputFileLocal", path: tempPath },
caption: parsedCaption,
width: 0,
height: 0,
},
}),
"sendPhotoMessage"
);
} finally {
await unlink(tempPath).catch(() => {});
}
@@ -150,10 +171,14 @@ export async function getUser(
userId: number
): Promise<{ firstName: string; lastName?: string; username?: string }> {
if (!client) throw new Error("Bot client not initialized");
const user = (await client.invoke({
_: "getUser",
user_id: userId,
})) as {
const user = (await withFloodWait(
() =>
client.invoke({
_: "getUser",
user_id: userId,
}),
"getUser"
)) as {
first_name?: string;
last_name?: string;
usernames?: { editable_username?: string };

View File

@@ -0,0 +1,60 @@
import { childLogger } from "./logger.js";
const log = childLogger("flood-wait");
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Extract the mandatory wait duration (in seconds) from a Telegram
* FLOOD_WAIT error. Returns null when the error is not rate-limit related.
*/
export function extractFloodWaitSeconds(err: unknown): number | null {
const message = err instanceof Error ? err.message : String(err);
// Pattern 1: FLOOD_WAIT_30
const flood = message.match(/FLOOD_WAIT_(\d+)/i);
if (flood) return parseInt(flood[1], 10);
// Pattern 2: "retry after 30"
const retry = message.match(/retry after (\d+)/i);
if (retry) return parseInt(retry[1], 10);
// Pattern 3: HTTP 429 without explicit seconds
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (String((err as any)?.code) === "429") return 30;
return null;
}
/**
* Wrap any async Telegram operation with automatic FLOOD_WAIT retry.
* Adds random jitter (1-5s) to prevent thundering-herd retries.
*
* Non-rate-limit errors are re-thrown immediately (fail-fast).
*/
export async function withFloodWait<T>(
fn: () => Promise<T>,
context?: string,
maxRetries = 5
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
const wait = extractFloodWaitSeconds(err);
if (wait === null || attempt >= maxRetries) throw err;
const jitter = 1000 + Math.random() * 4000;
log.warn(
{ context, wait, attempt: attempt + 1, maxRetries, jitter: Math.round(jitter) },
"FLOOD_WAIT received — backing off"
);
await sleep(wait * 1000 + jitter);
}
}
throw new Error("Unreachable");
}
export { sleep };