@spinabot/brigade 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/convex/channels.d.ts +5 -5
- package/convex/schema.d.ts +2 -2
- package/dist/agents/agent-loop.d.ts.map +1 -1
- package/dist/agents/agent-loop.js +27 -4
- package/dist/agents/agent-loop.js.map +1 -1
- package/dist/agents/channels/approval-callback-codec.d.ts +107 -0
- package/dist/agents/channels/approval-callback-codec.d.ts.map +1 -0
- package/dist/agents/channels/approval-callback-codec.js +173 -0
- package/dist/agents/channels/approval-callback-codec.js.map +1 -0
- package/dist/agents/channels/approval-router.d.ts +77 -20
- package/dist/agents/channels/approval-router.d.ts.map +1 -1
- package/dist/agents/channels/approval-router.js +163 -37
- package/dist/agents/channels/approval-router.js.map +1 -1
- package/dist/agents/channels/backoff.d.ts +55 -0
- package/dist/agents/channels/backoff.d.ts.map +1 -0
- package/dist/agents/channels/backoff.js +47 -0
- package/dist/agents/channels/backoff.js.map +1 -0
- package/dist/agents/channels/channel-secrets.d.ts +45 -0
- package/dist/agents/channels/channel-secrets.d.ts.map +1 -0
- package/dist/agents/channels/channel-secrets.js +69 -0
- package/dist/agents/channels/channel-secrets.js.map +1 -0
- package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
- package/dist/agents/channels/inbound-pipeline.js +67 -3
- package/dist/agents/channels/inbound-pipeline.js.map +1 -1
- package/dist/agents/channels/last-sent-message.d.ts +46 -0
- package/dist/agents/channels/last-sent-message.d.ts.map +1 -0
- package/dist/agents/channels/last-sent-message.js +55 -0
- package/dist/agents/channels/last-sent-message.js.map +1 -0
- package/dist/agents/channels/manager.d.ts +52 -0
- package/dist/agents/channels/manager.d.ts.map +1 -1
- package/dist/agents/channels/manager.js +141 -31
- package/dist/agents/channels/manager.js.map +1 -1
- package/dist/agents/channels/plugin-channel-manager-facade.d.ts +13 -2
- package/dist/agents/channels/plugin-channel-manager-facade.d.ts.map +1 -1
- package/dist/agents/channels/plugin-channel-manager-facade.js +21 -0
- package/dist/agents/channels/plugin-channel-manager-facade.js.map +1 -1
- package/dist/agents/channels/sdk.d.ts +426 -0
- package/dist/agents/channels/sdk.d.ts.map +1 -0
- package/dist/agents/channels/sdk.js +274 -0
- package/dist/agents/channels/sdk.js.map +1 -0
- package/dist/agents/channels/telegram/account-config.d.ts +92 -0
- package/dist/agents/channels/telegram/account-config.d.ts.map +1 -0
- package/dist/agents/channels/telegram/account-config.js +192 -0
- package/dist/agents/channels/telegram/account-config.js.map +1 -0
- package/dist/agents/channels/telegram/adapter.d.ts +79 -0
- package/dist/agents/channels/telegram/adapter.d.ts.map +1 -0
- package/dist/agents/channels/telegram/adapter.js +475 -0
- package/dist/agents/channels/telegram/adapter.js.map +1 -0
- package/dist/agents/channels/telegram/allowed-updates.d.ts +44 -0
- package/dist/agents/channels/telegram/allowed-updates.d.ts.map +1 -0
- package/dist/agents/channels/telegram/allowed-updates.js +52 -0
- package/dist/agents/channels/telegram/allowed-updates.js.map +1 -0
- package/dist/agents/channels/telegram/approval-authorize.d.ts +41 -0
- package/dist/agents/channels/telegram/approval-authorize.d.ts.map +1 -0
- package/dist/agents/channels/telegram/approval-authorize.js +69 -0
- package/dist/agents/channels/telegram/approval-authorize.js.map +1 -0
- package/dist/agents/channels/telegram/approval-native.d.ts +68 -0
- package/dist/agents/channels/telegram/approval-native.d.ts.map +1 -0
- package/dist/agents/channels/telegram/approval-native.js +94 -0
- package/dist/agents/channels/telegram/approval-native.js.map +1 -0
- package/dist/agents/channels/telegram/command-menu.d.ts +35 -0
- package/dist/agents/channels/telegram/command-menu.d.ts.map +1 -0
- package/dist/agents/channels/telegram/command-menu.js +59 -0
- package/dist/agents/channels/telegram/command-menu.js.map +1 -0
- package/dist/agents/channels/telegram/connection.d.ts +359 -0
- package/dist/agents/channels/telegram/connection.d.ts.map +1 -0
- package/dist/agents/channels/telegram/connection.js +865 -0
- package/dist/agents/channels/telegram/connection.js.map +1 -0
- package/dist/agents/channels/telegram/format.d.ts +48 -0
- package/dist/agents/channels/telegram/format.d.ts.map +1 -0
- package/dist/agents/channels/telegram/format.js +256 -0
- package/dist/agents/channels/telegram/format.js.map +1 -0
- package/dist/agents/channels/telegram/inbound-extras.d.ts +73 -0
- package/dist/agents/channels/telegram/inbound-extras.d.ts.map +1 -0
- package/dist/agents/channels/telegram/inbound-extras.js +231 -0
- package/dist/agents/channels/telegram/inbound-extras.js.map +1 -0
- package/dist/agents/channels/telegram/index.d.ts +14 -0
- package/dist/agents/channels/telegram/index.d.ts.map +1 -0
- package/dist/agents/channels/telegram/index.js +14 -0
- package/dist/agents/channels/telegram/index.js.map +1 -0
- package/dist/agents/channels/telegram/media.d.ts +68 -0
- package/dist/agents/channels/telegram/media.d.ts.map +1 -0
- package/dist/agents/channels/telegram/media.js +143 -0
- package/dist/agents/channels/telegram/media.js.map +1 -0
- package/dist/agents/channels/telegram/module.d.ts +15 -0
- package/dist/agents/channels/telegram/module.d.ts.map +1 -0
- package/dist/agents/channels/telegram/module.js +36 -0
- package/dist/agents/channels/telegram/module.js.map +1 -0
- package/dist/agents/channels/telegram/plugin.d.ts +76 -0
- package/dist/agents/channels/telegram/plugin.d.ts.map +1 -0
- package/dist/agents/channels/telegram/plugin.js +314 -0
- package/dist/agents/channels/telegram/plugin.js.map +1 -0
- package/dist/agents/channels/telegram/probe.d.ts +54 -0
- package/dist/agents/channels/telegram/probe.d.ts.map +1 -0
- package/dist/agents/channels/telegram/probe.js +95 -0
- package/dist/agents/channels/telegram/probe.js.map +1 -0
- package/dist/agents/channels/telegram/webhook.d.ts +55 -0
- package/dist/agents/channels/telegram/webhook.d.ts.map +1 -0
- package/dist/agents/channels/telegram/webhook.js +141 -0
- package/dist/agents/channels/telegram/webhook.js.map +1 -0
- package/dist/agents/extensions/modules/index.d.ts.map +1 -1
- package/dist/agents/extensions/modules/index.js +4 -0
- package/dist/agents/extensions/modules/index.js.map +1 -1
- package/dist/agents/extensions/types.d.ts +72 -2
- package/dist/agents/extensions/types.d.ts.map +1 -1
- package/dist/agents/extensions/types.js.map +1 -1
- package/dist/agents/tools/connect-channel-tool.d.ts +86 -0
- package/dist/agents/tools/connect-channel-tool.d.ts.map +1 -0
- package/dist/agents/tools/connect-channel-tool.js +398 -0
- package/dist/agents/tools/connect-channel-tool.js.map +1 -0
- package/dist/agents/tools/message-action-tool.d.ts +67 -0
- package/dist/agents/tools/message-action-tool.d.ts.map +1 -0
- package/dist/agents/tools/message-action-tool.js +216 -0
- package/dist/agents/tools/message-action-tool.js.map +1 -0
- package/dist/agents/tools/registry.d.ts.map +1 -1
- package/dist/agents/tools/registry.js +19 -0
- package/dist/agents/tools/registry.js.map +1 -1
- package/dist/buildstamp.json +1 -1
- package/dist/cli/commands/channels.d.ts.map +1 -1
- package/dist/cli/commands/channels.js +27 -2
- package/dist/cli/commands/channels.js.map +1 -1
- package/dist/cli/commands/convex-cmd.d.ts +27 -0
- package/dist/cli/commands/convex-cmd.d.ts.map +1 -0
- package/dist/cli/commands/convex-cmd.js +162 -0
- package/dist/cli/commands/convex-cmd.js.map +1 -0
- package/dist/cli/program/build-program.d.ts.map +1 -1
- package/dist/cli/program/build-program.js +64 -0
- package/dist/cli/program/build-program.js.map +1 -1
- package/dist/config/paths.d.ts +3 -0
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +39 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +77 -27
- package/dist/core/server.js.map +1 -1
- package/dist/cron/service/state.d.ts +10 -0
- package/dist/cron/service/state.d.ts.map +1 -1
- package/dist/cron/service/state.js.map +1 -1
- package/dist/cron/service/timer.d.ts.map +1 -1
- package/dist/cron/service/timer.js +43 -14
- package/dist/cron/service/timer.js.map +1 -1
- package/dist/cron/session-reaper.d.ts +27 -0
- package/dist/cron/session-reaper.d.ts.map +1 -1
- package/dist/cron/session-reaper.js +81 -0
- package/dist/cron/session-reaper.js.map +1 -1
- package/dist/system-prompt/assembler.d.ts +14 -0
- package/dist/system-prompt/assembler.d.ts.map +1 -1
- package/dist/system-prompt/assembler.js +36 -14
- package/dist/system-prompt/assembler.js.map +1 -1
- package/package.json +16 -3
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot API connection (grammY long-polling).
|
|
3
|
+
*
|
|
4
|
+
* The Brigade analogue of `whatsapp/connection.ts`, distilled from the
|
|
5
|
+
* reference Telegram polling session. grammY + its runner + the throttler transformer are
|
|
6
|
+
* HEAVY and only needed when a Telegram channel actually starts, so they are
|
|
7
|
+
* lazy-imported here (`await import("grammy")` inside `connectTelegram`) — a
|
|
8
|
+
* non-Telegram boot never pays for them. Types are `type`-only so the static
|
|
9
|
+
* import never pulls the runtime in.
|
|
10
|
+
*
|
|
11
|
+
* Lifecycle:
|
|
12
|
+
* - `deleteWebhook({ drop_pending_updates: false })` is called BEFORE polling.
|
|
13
|
+
* If a webhook was ever set on this bot, `getUpdates` returns 409 forever
|
|
14
|
+
* and the bot silently "receives nothing" — clearing it first is the #1 fix.
|
|
15
|
+
* - `getMe` caches the bot's numeric id + @username (the group ACL needs the
|
|
16
|
+
* username to detect @-mentions; without it group messages never reach the
|
|
17
|
+
* agent).
|
|
18
|
+
* - `bot.on("message")` normalizes each update into a `TgInboundMessage` and
|
|
19
|
+
* hands it to `onMessage` with a DEFERRED `resolveMedia` thunk — bytes are
|
|
20
|
+
* downloaded only after the central access gate admits the sender (mirrors
|
|
21
|
+
* WhatsApp).
|
|
22
|
+
* - The grammY runner drives `getUpdates`; `apiThrottler()` rate-limits the
|
|
23
|
+
* outbound API.
|
|
24
|
+
* - Reconnect backoff COPIES WhatsApp's constants (2s → 30s, ×1.8, ±25%).
|
|
25
|
+
* - 401 Unauthorized → sticky `tokenInvalid` (terminal; the only fix is a new
|
|
26
|
+
* token — stop polling).
|
|
27
|
+
* - 409 Conflict (getUpdates) → another poller/webhook is live: clear the
|
|
28
|
+
* webhook and restart ONCE, then fall back to normal backoff.
|
|
29
|
+
* - Updates are de-duplicated by `update_id` (a redelivered update after a
|
|
30
|
+
* restart must not double-run the agent).
|
|
31
|
+
*
|
|
32
|
+
* Scope cut (v1): `callback_query` (inline-button taps) is intentionally NOT
|
|
33
|
+
* subscribed — Brigade's approvals are central TEXT replies handled in
|
|
34
|
+
* `inbound-pipeline.ts`, so `allowed_updates` only needs message updates.
|
|
35
|
+
*/
|
|
36
|
+
import { createDedupeCache, nextBackoffDelay, } from "../sdk.js";
|
|
37
|
+
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
|
38
|
+
import { buildTelegramSenderName, extractTelegramMentions, extractTelegramReplyContext, extractTelegramText, hasInboundMedia, resolveInboundMediaFileId, resolveInboundMediaKind, telegramChatType, telegramThreadId, } from "./inbound-extras.js";
|
|
39
|
+
import { downloadTelegramMedia } from "./media.js";
|
|
40
|
+
// All contract types — `OutboundMedia`, `InboundMediaAttachment`,
|
|
41
|
+
// `InboundReplyContext` — now come from the channel SDK barrel (imported above),
|
|
42
|
+
// so this channel is built entirely on `../sdk.js`.
|
|
43
|
+
/* ───────────────────────── reconnect backoff ───────────────────────── */
|
|
44
|
+
// Shares the neutral `nextBackoffDelay` curve with every other channel (see
|
|
45
|
+
// `channels/backoff.ts`), tuned to the same schedule WhatsApp uses (2s → 30s,
|
|
46
|
+
// ×1.8, ±25%). The constants live here so Telegram owns its own knobs; the
|
|
47
|
+
// arithmetic is the shared helper's.
|
|
48
|
+
const RECONNECT_INITIAL_MS = 2_000;
|
|
49
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
50
|
+
const RECONNECT_FACTOR = 1.8;
|
|
51
|
+
const RECONNECT_JITTER = 0.25;
|
|
52
|
+
const RECONNECT_MAX_ATTEMPTS = 12;
|
|
53
|
+
/**
|
|
54
|
+
* Jittered exponential backoff for reconnect attempt `attempt` (0-based).
|
|
55
|
+
* Thin wrapper over the neutral `nextBackoffDelay` helper — kept as a named
|
|
56
|
+
* export so `index.ts` and the connection tests have a stable entry point.
|
|
57
|
+
*/
|
|
58
|
+
export function telegramBackoffDelay(attempt) {
|
|
59
|
+
return nextBackoffDelay({
|
|
60
|
+
attempt,
|
|
61
|
+
initialMs: RECONNECT_INITIAL_MS,
|
|
62
|
+
maxMs: RECONNECT_MAX_MS,
|
|
63
|
+
factor: RECONNECT_FACTOR,
|
|
64
|
+
jitter: RECONNECT_JITTER,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/* ───────────────────────── error classification ───────────────────────── */
|
|
68
|
+
/** Pull a Telegram `error_code` off any thrown shape (grammY GrammyError or raw). */
|
|
69
|
+
function errorCode(err) {
|
|
70
|
+
if (!err || typeof err !== "object")
|
|
71
|
+
return undefined;
|
|
72
|
+
const e = err;
|
|
73
|
+
return e.error_code ?? e.errorCode;
|
|
74
|
+
}
|
|
75
|
+
/** Description / message text off any thrown shape. */
|
|
76
|
+
function errorText(err) {
|
|
77
|
+
if (!err)
|
|
78
|
+
return "";
|
|
79
|
+
if (typeof err === "string")
|
|
80
|
+
return err;
|
|
81
|
+
const e = err;
|
|
82
|
+
return e.description ?? e.message ?? String(err);
|
|
83
|
+
}
|
|
84
|
+
/** 401 Unauthorized → the bot token is wrong / revoked (terminal). */
|
|
85
|
+
export function isTelegramUnauthorized(err) {
|
|
86
|
+
if (errorCode(err) === 401)
|
|
87
|
+
return true;
|
|
88
|
+
return /unauthorized/i.test(errorText(err));
|
|
89
|
+
}
|
|
90
|
+
/** 409 Conflict on getUpdates → another poller or a webhook is active. */
|
|
91
|
+
export function isTelegramGetUpdatesConflict(err) {
|
|
92
|
+
if (errorCode(err) !== 409)
|
|
93
|
+
return false;
|
|
94
|
+
return /conflict|getupdates|webhook|terminated by other/i.test(errorText(err));
|
|
95
|
+
}
|
|
96
|
+
/** Strip a bot token out of any string before it reaches a log. */
|
|
97
|
+
export function redactTelegramToken(text, token) {
|
|
98
|
+
if (!text)
|
|
99
|
+
return text;
|
|
100
|
+
let out = text;
|
|
101
|
+
if (token)
|
|
102
|
+
out = out.split(token).join("<redacted>");
|
|
103
|
+
// Catch `bot<digits>:<base64ish>` URL fragments even if the exact token differs.
|
|
104
|
+
out = out.replace(/bot\d{6,}:[A-Za-z0-9_-]{20,}/g, "bot<redacted>");
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/* ───────────────────────── the connection ───────────────────────── */
|
|
108
|
+
export async function connectTelegram(args) {
|
|
109
|
+
const accountId = args.accountId ?? "default";
|
|
110
|
+
const mode = args.mode === "webhook" ? "webhook" : "polling";
|
|
111
|
+
const sleep = args.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
|
|
112
|
+
const safeLog = (msg, meta) => {
|
|
113
|
+
// Defensively redact the token from any message + string meta values.
|
|
114
|
+
const redactedMsg = redactTelegramToken(msg, args.token);
|
|
115
|
+
if (!meta)
|
|
116
|
+
return args.log(redactedMsg);
|
|
117
|
+
const redactedMeta = {};
|
|
118
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
119
|
+
redactedMeta[k] = typeof v === "string" ? redactTelegramToken(v, args.token) : v;
|
|
120
|
+
}
|
|
121
|
+
args.log(redactedMsg, redactedMeta);
|
|
122
|
+
};
|
|
123
|
+
// ── lazy-load grammY + runner + throttler (production path only) ──
|
|
124
|
+
let buildBot;
|
|
125
|
+
let buildRunner;
|
|
126
|
+
if (args.botFactory && args.runnerFactory) {
|
|
127
|
+
buildBot = args.botFactory;
|
|
128
|
+
buildRunner = args.runnerFactory;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const grammy = await import("grammy");
|
|
132
|
+
const { run } = await import("@grammyjs/runner");
|
|
133
|
+
const { apiThrottler } = await import("@grammyjs/transformer-throttler");
|
|
134
|
+
buildBot = (token) => {
|
|
135
|
+
const bot = new grammy.Bot(token);
|
|
136
|
+
// Rate-limit the outbound API so a chatty agent never trips Telegram's
|
|
137
|
+
// flood limits — installed on the api config as a transformer.
|
|
138
|
+
bot.api.config?.use(apiThrottler());
|
|
139
|
+
return bot;
|
|
140
|
+
};
|
|
141
|
+
buildRunner = (bot) => run(bot, {
|
|
142
|
+
// Subscribe `message` (text/media) + `callback_query` (inline-button
|
|
143
|
+
// approval presses) — the minimal set Brigade's central pipeline
|
|
144
|
+
// consumes. Widen via `args.allowedUpdates` (e.g. inbound reactions).
|
|
145
|
+
// Cast: the resolver returns a strict subset of grammY's update union,
|
|
146
|
+
// but `args.allowedUpdates` is a plain `string[]`.
|
|
147
|
+
runner: { fetch: { allowed_updates: allowedUpdates } },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// The `allowed_updates` list to request — defaults to message + callback_query.
|
|
151
|
+
const allowedUpdates = args.allowedUpdates && args.allowedUpdates.length > 0
|
|
152
|
+
? args.allowedUpdates
|
|
153
|
+
: resolveTelegramAllowedUpdates();
|
|
154
|
+
// ── connection state ──
|
|
155
|
+
let selfId = null;
|
|
156
|
+
let selfUsername = null;
|
|
157
|
+
let selfIdentity = null;
|
|
158
|
+
let connectedAtMs = null;
|
|
159
|
+
let connected = false;
|
|
160
|
+
let tokenInvalid = false;
|
|
161
|
+
let closed = false;
|
|
162
|
+
let webhookCleared = false;
|
|
163
|
+
let conflictRestartUsed = false;
|
|
164
|
+
let reconnectAttempts = 0;
|
|
165
|
+
let bot = null;
|
|
166
|
+
let runner = null;
|
|
167
|
+
let loopPromise = null;
|
|
168
|
+
// Dedupe inbound updates by `update_id` — a redelivered update after a
|
|
169
|
+
// restart must not double-run the agent. Per-connection lifetime.
|
|
170
|
+
const updateDedupe = createDedupeCache({ maxEntries: 10_000, ttlMs: 60 * 60 * 1_000 });
|
|
171
|
+
/** Normalize one grammY message into the deferred-media inbound shape. */
|
|
172
|
+
const normalize = (message) => {
|
|
173
|
+
const chatId = String(message.chat.id);
|
|
174
|
+
const text = extractTelegramText(message);
|
|
175
|
+
const chatType = telegramChatType(message);
|
|
176
|
+
const threadId = telegramThreadId(message);
|
|
177
|
+
const mentions = extractTelegramMentions(message, selfUsername ?? undefined, selfId ?? undefined);
|
|
178
|
+
const replyTo = extractTelegramReplyContext(message);
|
|
179
|
+
const fromName = buildTelegramSenderName(message);
|
|
180
|
+
const fromId = typeof message.from?.id === "number" ? String(message.from.id) : chatId;
|
|
181
|
+
const tsSec = typeof message.date === "number" ? message.date : 0;
|
|
182
|
+
// DEFERRED media — captured by reference, not downloaded. The thunk is only
|
|
183
|
+
// invoked by the pipeline after the access gate admits the sender.
|
|
184
|
+
const carriesMedia = hasInboundMedia(message);
|
|
185
|
+
const resolveMedia = carriesMedia
|
|
186
|
+
? async () => {
|
|
187
|
+
const fileId = resolveInboundMediaFileId(message);
|
|
188
|
+
const kind = resolveInboundMediaKind(message);
|
|
189
|
+
if (!fileId || !kind || !bot)
|
|
190
|
+
return [];
|
|
191
|
+
const caption = typeof message.caption === "string" ? message.caption : undefined;
|
|
192
|
+
const fileName = message.document?.file_name ?? message.audio?.file_name ?? message.video?.file_name;
|
|
193
|
+
const att = await downloadTelegramMedia({
|
|
194
|
+
bot: bot.api,
|
|
195
|
+
fileId,
|
|
196
|
+
kind,
|
|
197
|
+
token: args.token,
|
|
198
|
+
caption,
|
|
199
|
+
fileName,
|
|
200
|
+
log: safeLog,
|
|
201
|
+
});
|
|
202
|
+
return att ? [att] : [];
|
|
203
|
+
}
|
|
204
|
+
: undefined;
|
|
205
|
+
return {
|
|
206
|
+
conversationId: chatId,
|
|
207
|
+
messageId: typeof message.message_id === "number" ? String(message.message_id) : undefined,
|
|
208
|
+
messageTimestampMs: tsSec > 0 ? tsSec * 1000 : undefined,
|
|
209
|
+
from: fromId,
|
|
210
|
+
fromName,
|
|
211
|
+
text,
|
|
212
|
+
chatType,
|
|
213
|
+
threadId,
|
|
214
|
+
mentions: mentions.length > 0 ? mentions : undefined,
|
|
215
|
+
replyTo,
|
|
216
|
+
resolveMedia,
|
|
217
|
+
raw: message,
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
const onUpdate = (ctx) => {
|
|
221
|
+
try {
|
|
222
|
+
const updateId = ctx.update?.update_id;
|
|
223
|
+
if (typeof updateId === "number" && !updateDedupe.claim(String(updateId)))
|
|
224
|
+
return; // already seen
|
|
225
|
+
const message = ctx.message ?? ctx.update?.message;
|
|
226
|
+
if (!message)
|
|
227
|
+
return;
|
|
228
|
+
// Ignore the bot's own outbound echoes (a bot never sees its own sends
|
|
229
|
+
// via getUpdates, but a linked-account self-message would carry our id).
|
|
230
|
+
if (typeof message.from?.id === "number" && selfId && String(message.from.id) === selfId)
|
|
231
|
+
return;
|
|
232
|
+
args.onMessage(normalize(message));
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
safeLog("telegram inbound handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Normalize a `callback_query` (inline-button press) into the inbound shape
|
|
240
|
+
* the central pipeline routes to the approval-callback path. The button's
|
|
241
|
+
* `data` rides on `callbackQuery`; `conversationId` / `from` / `threadId`
|
|
242
|
+
* come from the message the button was attached to so the pending-approval
|
|
243
|
+
* lookup keys on the SAME peer the prompt was sent to.
|
|
244
|
+
*/
|
|
245
|
+
const normalizeCallback = (cb) => {
|
|
246
|
+
const data = typeof cb.data === "string" ? cb.data : "";
|
|
247
|
+
if (!data)
|
|
248
|
+
return null; // a button with no payload is not an approval press
|
|
249
|
+
const msg = cb.message;
|
|
250
|
+
const chatId = msg?.chat?.id !== undefined ? String(msg.chat.id) : cb.from?.id !== undefined ? String(cb.from.id) : "";
|
|
251
|
+
if (!chatId)
|
|
252
|
+
return null;
|
|
253
|
+
const fromId = typeof cb.from?.id === "number" ? String(cb.from.id) : chatId;
|
|
254
|
+
const fromName = cb.from
|
|
255
|
+
? [cb.from.first_name, cb.from.last_name].filter(Boolean).join(" ").trim() ||
|
|
256
|
+
(cb.from.username ? `@${cb.from.username}` : undefined)
|
|
257
|
+
: undefined;
|
|
258
|
+
const threadId = msg ? telegramThreadId(msg) : undefined;
|
|
259
|
+
const chatType = msg ? telegramChatType(msg) : "direct";
|
|
260
|
+
return {
|
|
261
|
+
conversationId: chatId,
|
|
262
|
+
from: fromId,
|
|
263
|
+
...(fromName ? { fromName } : {}),
|
|
264
|
+
text: "",
|
|
265
|
+
chatType,
|
|
266
|
+
...(threadId ? { threadId } : {}),
|
|
267
|
+
callbackQuery: { data, callbackId: cb.id },
|
|
268
|
+
raw: (msg ?? cb),
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
const onCallbackQuery = async (ctx) => {
|
|
272
|
+
try {
|
|
273
|
+
const updateId = ctx.update?.update_id;
|
|
274
|
+
if (typeof updateId === "number" && !updateDedupe.claim(String(updateId)))
|
|
275
|
+
return; // already seen
|
|
276
|
+
const cb = ctx.callbackQuery ?? ctx.update.callback_query;
|
|
277
|
+
if (!cb)
|
|
278
|
+
return;
|
|
279
|
+
// ACK first — clears the client-side loading spinner immediately (no text,
|
|
280
|
+
// matching the reference). Best-effort: a failed ack must not block routing.
|
|
281
|
+
try {
|
|
282
|
+
await ctx.answerCallbackQuery();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* ack is cosmetic */
|
|
286
|
+
}
|
|
287
|
+
const normalized = normalizeCallback(cb);
|
|
288
|
+
if (!normalized)
|
|
289
|
+
return;
|
|
290
|
+
args.onCallbackQuery?.(normalized);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
safeLog("telegram callback handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
/** Build a bot, wire handlers, getMe, clear webhook, sync commands, start runner. */
|
|
297
|
+
const startOnce = async () => {
|
|
298
|
+
const b = buildBot(args.token);
|
|
299
|
+
b.on("message", onUpdate);
|
|
300
|
+
// Subscribe inline-button presses (interactive approvals). grammY hands a
|
|
301
|
+
// per-update `answerCallbackQuery` helper on the ctx; the handler acks via
|
|
302
|
+
// it then routes the normalized callback inbound.
|
|
303
|
+
b.on("callback_query", (ctx) => void onCallbackQuery(ctx));
|
|
304
|
+
bot = b;
|
|
305
|
+
// getMe first — both proves the token (401 surfaces here) and caches the
|
|
306
|
+
// bot id + username the group ACL needs (+ the full identity for the probe).
|
|
307
|
+
const me = await b.api.getMe();
|
|
308
|
+
selfId = String(me.id);
|
|
309
|
+
selfUsername = me.username ?? null;
|
|
310
|
+
selfIdentity = me;
|
|
311
|
+
// Clear any webhook BEFORE polling — the #1 "receives nothing" cause. Do
|
|
312
|
+
// not drop pending updates (the operator may want queued messages). Once
|
|
313
|
+
// cleared we don't re-clear on every reconnect.
|
|
314
|
+
if (!webhookCleared) {
|
|
315
|
+
await b.api.deleteWebhook({ drop_pending_updates: false });
|
|
316
|
+
webhookCleared = true;
|
|
317
|
+
}
|
|
318
|
+
// Register the bot's `/` command menu (best-effort — a failed sync must not
|
|
319
|
+
// block polling). Re-applied on each (re)connect so an edited command set
|
|
320
|
+
// lands after a gateway restart.
|
|
321
|
+
if (args.commandMenu && args.commandMenu.length > 0 && b.api.setMyCommands) {
|
|
322
|
+
try {
|
|
323
|
+
await b.api.setMyCommands(args.commandMenu);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
safeLog("telegram setMyCommands failed (cosmetic)", {
|
|
327
|
+
error: err instanceof Error ? err.message : String(err),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const r = buildRunner(b);
|
|
332
|
+
runner = r;
|
|
333
|
+
return r;
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* Feed a raw update into the inbound path. In webhook mode the gateway HTTP
|
|
337
|
+
* route calls this with each POSTed `Update`; it dispatches to the same
|
|
338
|
+
* message / callback_query handlers polling uses (so dedupe + normalize +
|
|
339
|
+
* ack are identical). grammY's polling ctx supplies a per-update
|
|
340
|
+
* `answerCallbackQuery`; in webhook mode we synthesise one off `bot.api`.
|
|
341
|
+
*/
|
|
342
|
+
const feedUpdate = (update) => {
|
|
343
|
+
const message = update.message;
|
|
344
|
+
if (message) {
|
|
345
|
+
onUpdate({ update, message });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const callbackQuery = update.callback_query;
|
|
349
|
+
if (callbackQuery) {
|
|
350
|
+
void onCallbackQuery({
|
|
351
|
+
update,
|
|
352
|
+
callbackQuery,
|
|
353
|
+
answerCallbackQuery: async (opts) => {
|
|
354
|
+
const b = bot;
|
|
355
|
+
if (!b?.api.answerCallbackQuery)
|
|
356
|
+
return;
|
|
357
|
+
await b.api.answerCallbackQuery(callbackQuery.id, opts);
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
/**
|
|
363
|
+
* Webhook transport start: build the bot, getMe, sync commands, then register
|
|
364
|
+
* the webhook (when a url was supplied) so Telegram POSTs updates to the
|
|
365
|
+
* gateway route. Does NOT poll — inbound arrives via {@link feedUpdate}.
|
|
366
|
+
*/
|
|
367
|
+
const startWebhook = async () => {
|
|
368
|
+
const b = buildBot(args.token);
|
|
369
|
+
// We don't subscribe via b.on(...) in webhook mode — feedUpdate dispatches
|
|
370
|
+
// directly — but wiring the handlers is harmless and keeps parity if a
|
|
371
|
+
// future grammy webhookCallback is adopted.
|
|
372
|
+
bot = b;
|
|
373
|
+
const me = await b.api.getMe();
|
|
374
|
+
selfId = String(me.id);
|
|
375
|
+
selfUsername = me.username ?? null;
|
|
376
|
+
selfIdentity = me;
|
|
377
|
+
if (args.commandMenu && args.commandMenu.length > 0 && b.api.setMyCommands) {
|
|
378
|
+
try {
|
|
379
|
+
await b.api.setMyCommands(args.commandMenu);
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
safeLog("telegram setMyCommands failed (cosmetic)", {
|
|
383
|
+
error: err instanceof Error ? err.message : String(err),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const url = args.webhook?.url?.trim();
|
|
388
|
+
if (url && b.api.setWebhook) {
|
|
389
|
+
const opts = { allowed_updates: allowedUpdates };
|
|
390
|
+
if (args.webhook?.secretToken)
|
|
391
|
+
opts.secret_token = args.webhook.secretToken;
|
|
392
|
+
await b.api.setWebhook(url, opts);
|
|
393
|
+
safeLog("telegram webhook registered", { account: accountId });
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
safeLog("telegram webhook mode — no url configured; inbound only via gateway route", {
|
|
397
|
+
account: accountId,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
connected = true;
|
|
401
|
+
connectedAtMs = Date.now();
|
|
402
|
+
reconnectAttempts = 0;
|
|
403
|
+
args.onConnected?.();
|
|
404
|
+
};
|
|
405
|
+
/** The supervise loop — start, run until the runner stops, reconnect with backoff. */
|
|
406
|
+
const superviseLoop = async () => {
|
|
407
|
+
while (!closed && !tokenInvalid) {
|
|
408
|
+
let r;
|
|
409
|
+
try {
|
|
410
|
+
r = await startOnce();
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
if (isTelegramUnauthorized(err)) {
|
|
414
|
+
tokenInvalid = true;
|
|
415
|
+
connected = false;
|
|
416
|
+
safeLog("telegram token rejected (401) — re-token required; polling stopped");
|
|
417
|
+
args.onTokenInvalid?.();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (closed)
|
|
421
|
+
return;
|
|
422
|
+
// Setup failed (transient network on getMe/deleteWebhook) — back off + retry.
|
|
423
|
+
const delay = telegramBackoffDelay(reconnectAttempts);
|
|
424
|
+
reconnectAttempts += 1;
|
|
425
|
+
if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
|
|
426
|
+
safeLog("telegram setup attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
safeLog("telegram setup failed — retrying", {
|
|
430
|
+
attempt: reconnectAttempts,
|
|
431
|
+
delayMs: delay,
|
|
432
|
+
error: err instanceof Error ? err.message : String(err),
|
|
433
|
+
});
|
|
434
|
+
await sleep(delay);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
// close() may have fired during the async startOnce() — bail before we
|
|
438
|
+
// commit to driving a runner we'd otherwise have to wait out.
|
|
439
|
+
if (closed) {
|
|
440
|
+
await teardownRunner();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Connected. Reset backoff, announce.
|
|
444
|
+
connected = true;
|
|
445
|
+
connectedAtMs = Date.now();
|
|
446
|
+
reconnectAttempts = 0;
|
|
447
|
+
conflictRestartUsed = false;
|
|
448
|
+
safeLog("telegram connected", { account: accountId, self: selfUsername ? `@${selfUsername}` : selfId });
|
|
449
|
+
args.onConnected?.();
|
|
450
|
+
// Drive the runner until it stops (graceful or error).
|
|
451
|
+
try {
|
|
452
|
+
await r.task();
|
|
453
|
+
// Runner stopped without throwing — graceful stop (close) or maxRetry.
|
|
454
|
+
if (closed)
|
|
455
|
+
return;
|
|
456
|
+
connected = false;
|
|
457
|
+
const delay = telegramBackoffDelay(reconnectAttempts);
|
|
458
|
+
reconnectAttempts += 1;
|
|
459
|
+
if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
|
|
460
|
+
safeLog("telegram polling attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
safeLog("telegram polling stopped — reconnecting", { attempt: reconnectAttempts, delayMs: delay });
|
|
464
|
+
await sleep(delay);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
connected = false;
|
|
468
|
+
if (closed)
|
|
469
|
+
return;
|
|
470
|
+
if (isTelegramUnauthorized(err)) {
|
|
471
|
+
tokenInvalid = true;
|
|
472
|
+
safeLog("telegram token rejected (401) — re-token required; polling stopped");
|
|
473
|
+
args.onTokenInvalid?.();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (isTelegramGetUpdatesConflict(err)) {
|
|
477
|
+
// Another poller / a leftover webhook is live. Clear the webhook
|
|
478
|
+
// and restart ONCE immediately; if it recurs, fall through to backoff.
|
|
479
|
+
if (!conflictRestartUsed) {
|
|
480
|
+
conflictRestartUsed = true;
|
|
481
|
+
webhookCleared = false; // force a re-clear on the next startOnce
|
|
482
|
+
safeLog("telegram getUpdates conflict (409) — clearing webhook + restarting once");
|
|
483
|
+
await teardownRunner();
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
safeLog("telegram getUpdates conflict (409) persists — backing off");
|
|
487
|
+
}
|
|
488
|
+
const delay = telegramBackoffDelay(reconnectAttempts);
|
|
489
|
+
reconnectAttempts += 1;
|
|
490
|
+
if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
|
|
491
|
+
safeLog("telegram polling attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
safeLog("telegram polling error — reconnecting", {
|
|
495
|
+
attempt: reconnectAttempts,
|
|
496
|
+
delayMs: delay,
|
|
497
|
+
error: err instanceof Error ? err.message : String(err),
|
|
498
|
+
});
|
|
499
|
+
await sleep(delay);
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
await teardownRunner();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
const teardownRunner = async () => {
|
|
507
|
+
const r = runner;
|
|
508
|
+
runner = null;
|
|
509
|
+
if (r) {
|
|
510
|
+
try {
|
|
511
|
+
if (r.isRunning())
|
|
512
|
+
await r.stop();
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
/* already stopped */
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const b = bot;
|
|
519
|
+
if (b) {
|
|
520
|
+
try {
|
|
521
|
+
await b.stop();
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
/* already stopped */
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
// Kick the supervise loop. It resolves the initial connect via onConnected;
|
|
529
|
+
// connectTelegram itself resolves as soon as the FIRST connect (or terminal
|
|
530
|
+
// failure) settles so the adapter's start() doesn't hang forever.
|
|
531
|
+
let resolveInitial;
|
|
532
|
+
const initial = new Promise((resolve) => {
|
|
533
|
+
resolveInitial = resolve;
|
|
534
|
+
});
|
|
535
|
+
const origOnConnected = args.onConnected;
|
|
536
|
+
const origOnTokenInvalid = args.onTokenInvalid;
|
|
537
|
+
args.onConnected = () => {
|
|
538
|
+
origOnConnected?.();
|
|
539
|
+
resolveInitial();
|
|
540
|
+
};
|
|
541
|
+
args.onTokenInvalid = () => {
|
|
542
|
+
origOnTokenInvalid?.();
|
|
543
|
+
resolveInitial();
|
|
544
|
+
};
|
|
545
|
+
if (mode === "webhook") {
|
|
546
|
+
// Webhook transport: one-shot setup (no poll loop). A 401 surfaces as a
|
|
547
|
+
// terminal token-invalid; any other setup error is logged and the channel
|
|
548
|
+
// stays "starting" until the operator fixes config + restarts.
|
|
549
|
+
loopPromise = startWebhook().catch((err) => {
|
|
550
|
+
if (isTelegramUnauthorized(err)) {
|
|
551
|
+
tokenInvalid = true;
|
|
552
|
+
connected = false;
|
|
553
|
+
safeLog("telegram token rejected (401) — re-token required; webhook not registered");
|
|
554
|
+
args.onTokenInvalid?.();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
safeLog("telegram webhook setup failed", { error: err instanceof Error ? err.message : String(err) });
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
loopPromise = superviseLoop().catch((err) => {
|
|
562
|
+
safeLog("telegram supervise loop crashed", { error: err instanceof Error ? err.message : String(err) });
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
// Don't block start() indefinitely — resolve once connected OR after the loop
|
|
566
|
+
// settles (terminal failure), whichever comes first.
|
|
567
|
+
await Promise.race([initial, loopPromise.then(() => undefined)]);
|
|
568
|
+
/* ── outbound + control surface ── */
|
|
569
|
+
const requireLive = () => {
|
|
570
|
+
if (tokenInvalid)
|
|
571
|
+
throw new Error("Telegram token is invalid — set a new bot token and restart.");
|
|
572
|
+
if (!bot)
|
|
573
|
+
throw new Error("Telegram channel is not started");
|
|
574
|
+
return bot;
|
|
575
|
+
};
|
|
576
|
+
const sendText = async (chatId, text, opts) => {
|
|
577
|
+
const b = requireLive();
|
|
578
|
+
const params = {};
|
|
579
|
+
if (opts?.html)
|
|
580
|
+
params.parse_mode = "HTML";
|
|
581
|
+
if (opts?.threadId)
|
|
582
|
+
params.message_thread_id = Number(opts.threadId);
|
|
583
|
+
try {
|
|
584
|
+
const res = await b.api.sendMessage(chatId, text, params);
|
|
585
|
+
return { messageId: res.message_id };
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
// Thread vanished — retry without the thread param (topic deleted/closed).
|
|
589
|
+
if (opts?.threadId && /message thread not found/i.test(errorText(err))) {
|
|
590
|
+
const { message_thread_id: _omit, ...rest } = params;
|
|
591
|
+
const res = await b.api.sendMessage(chatId, text, rest);
|
|
592
|
+
return { messageId: res.message_id };
|
|
593
|
+
}
|
|
594
|
+
throw err;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const sendInteractive = async (chatId, text, replyMarkup, opts) => {
|
|
598
|
+
const b = requireLive();
|
|
599
|
+
const params = { reply_markup: replyMarkup };
|
|
600
|
+
if (opts?.html)
|
|
601
|
+
params.parse_mode = "HTML";
|
|
602
|
+
if (opts?.threadId)
|
|
603
|
+
params.message_thread_id = Number(opts.threadId);
|
|
604
|
+
try {
|
|
605
|
+
const res = await b.api.sendMessage(chatId, text, params);
|
|
606
|
+
return { messageId: res.message_id };
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
if (opts?.threadId && /message thread not found/i.test(errorText(err))) {
|
|
610
|
+
const { message_thread_id: _omit, ...rest } = params;
|
|
611
|
+
const res = await b.api.sendMessage(chatId, text, rest);
|
|
612
|
+
return { messageId: res.message_id };
|
|
613
|
+
}
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
const sendMedia = async (chatId, media, opts) => {
|
|
618
|
+
const b = requireLive();
|
|
619
|
+
const { buildTelegramInputFile } = await import("./media.js");
|
|
620
|
+
const input = await buildTelegramInputFile(media);
|
|
621
|
+
const params = {};
|
|
622
|
+
if (opts?.threadId)
|
|
623
|
+
params.message_thread_id = Number(opts.threadId);
|
|
624
|
+
if (media.caption)
|
|
625
|
+
params.caption = media.caption;
|
|
626
|
+
const api = b.api;
|
|
627
|
+
const send = async (p) => {
|
|
628
|
+
switch (media.kind) {
|
|
629
|
+
case "image":
|
|
630
|
+
await api.sendPhoto?.(chatId, input, p);
|
|
631
|
+
return;
|
|
632
|
+
case "video":
|
|
633
|
+
await api.sendVideo?.(chatId, input, p);
|
|
634
|
+
return;
|
|
635
|
+
case "audio":
|
|
636
|
+
await api.sendAudio?.(chatId, input, p);
|
|
637
|
+
return;
|
|
638
|
+
case "voice":
|
|
639
|
+
await api.sendVoice?.(chatId, input, p);
|
|
640
|
+
return;
|
|
641
|
+
case "sticker":
|
|
642
|
+
await api.sendSticker?.(chatId, input, p);
|
|
643
|
+
return;
|
|
644
|
+
default:
|
|
645
|
+
await api.sendDocument?.(chatId, input, p);
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
try {
|
|
649
|
+
await send(params);
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
if (opts?.threadId && /message thread not found/i.test(errorText(err))) {
|
|
653
|
+
const { message_thread_id: _omit, ...rest } = params;
|
|
654
|
+
await send(rest);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
throw err;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
const react = async (chatId, messageId, emoji) => {
|
|
661
|
+
const b = requireLive();
|
|
662
|
+
if (!b.api.setMessageReaction)
|
|
663
|
+
return; // older API surface — reaction is cosmetic
|
|
664
|
+
const id = Number(messageId);
|
|
665
|
+
if (!Number.isFinite(id))
|
|
666
|
+
return;
|
|
667
|
+
const reaction = emoji ? [{ type: "emoji", emoji }] : [];
|
|
668
|
+
try {
|
|
669
|
+
await b.api.setMessageReaction(chatId, id, reaction);
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
safeLog("telegram react failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
const sendPoll = async (chatId, poll, opts) => {
|
|
676
|
+
const b = requireLive();
|
|
677
|
+
if (!b.api.sendPoll)
|
|
678
|
+
throw new Error("Telegram: this bot build cannot send polls.");
|
|
679
|
+
const params = {
|
|
680
|
+
// Telegram defaults `is_anonymous` to true; honour an explicit override.
|
|
681
|
+
is_anonymous: poll.isAnonymous !== false,
|
|
682
|
+
allows_multiple_answers: poll.allowsMultipleAnswers === true,
|
|
683
|
+
};
|
|
684
|
+
if (opts?.threadId)
|
|
685
|
+
params.message_thread_id = Number(opts.threadId);
|
|
686
|
+
try {
|
|
687
|
+
const res = await b.api.sendPoll(chatId, poll.question, poll.options, params);
|
|
688
|
+
return { messageId: res.message_id };
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
if (opts?.threadId && /message thread not found/i.test(errorText(err))) {
|
|
692
|
+
const { message_thread_id: _omit, ...rest } = params;
|
|
693
|
+
const res = await b.api.sendPoll(chatId, poll.question, poll.options, rest);
|
|
694
|
+
return { messageId: res.message_id };
|
|
695
|
+
}
|
|
696
|
+
throw err;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
const editMessageText = async (chatId, messageId, text, opts) => {
|
|
700
|
+
const b = requireLive();
|
|
701
|
+
if (!b.api.editMessageText)
|
|
702
|
+
throw new Error("Telegram: this bot build cannot edit messages.");
|
|
703
|
+
const id = Number(messageId);
|
|
704
|
+
if (!Number.isFinite(id))
|
|
705
|
+
throw new Error(`Telegram: invalid message id "${messageId}".`);
|
|
706
|
+
const params = {};
|
|
707
|
+
if (opts?.html)
|
|
708
|
+
params.parse_mode = "HTML";
|
|
709
|
+
await b.api.editMessageText(chatId, id, text, params);
|
|
710
|
+
};
|
|
711
|
+
const deleteMessage = async (chatId, messageId) => {
|
|
712
|
+
const b = requireLive();
|
|
713
|
+
if (!b.api.deleteMessage)
|
|
714
|
+
throw new Error("Telegram: this bot build cannot delete messages.");
|
|
715
|
+
const id = Number(messageId);
|
|
716
|
+
if (!Number.isFinite(id))
|
|
717
|
+
throw new Error(`Telegram: invalid message id "${messageId}".`);
|
|
718
|
+
await b.api.deleteMessage(chatId, id);
|
|
719
|
+
};
|
|
720
|
+
const pinMessage = async (chatId, messageId) => {
|
|
721
|
+
const b = requireLive();
|
|
722
|
+
if (!b.api.pinChatMessage)
|
|
723
|
+
throw new Error("Telegram: this bot build cannot pin messages.");
|
|
724
|
+
const id = Number(messageId);
|
|
725
|
+
if (!Number.isFinite(id))
|
|
726
|
+
throw new Error(`Telegram: invalid message id "${messageId}".`);
|
|
727
|
+
// Silent pin (no member notification) — matches the reference behavior.
|
|
728
|
+
await b.api.pinChatMessage(chatId, id, { disable_notification: true });
|
|
729
|
+
};
|
|
730
|
+
const unpinMessage = async (chatId, messageId) => {
|
|
731
|
+
const b = requireLive();
|
|
732
|
+
if (!b.api.unpinChatMessage)
|
|
733
|
+
throw new Error("Telegram: this bot build cannot unpin messages.");
|
|
734
|
+
const params = {};
|
|
735
|
+
if (messageId !== undefined) {
|
|
736
|
+
const id = Number(messageId);
|
|
737
|
+
if (Number.isFinite(id))
|
|
738
|
+
params.message_id = id;
|
|
739
|
+
}
|
|
740
|
+
await b.api.unpinChatMessage(chatId, params);
|
|
741
|
+
};
|
|
742
|
+
const editForumTopic = async (chatId, threadId, name) => {
|
|
743
|
+
const b = bot;
|
|
744
|
+
if (!b || tokenInvalid || !b.api.editForumTopic)
|
|
745
|
+
return; // best-effort — labeling is cosmetic
|
|
746
|
+
const id = Number(threadId);
|
|
747
|
+
if (!Number.isFinite(id))
|
|
748
|
+
return;
|
|
749
|
+
// Telegram caps a forum topic name at 128 chars — clamp defensively.
|
|
750
|
+
const clamped = name.length > 128 ? name.slice(0, 128) : name;
|
|
751
|
+
if (!clamped.trim())
|
|
752
|
+
return;
|
|
753
|
+
try {
|
|
754
|
+
await b.api.editForumTopic(chatId, id, { name: clamped });
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
safeLog("telegram editForumTopic failed (cosmetic)", {
|
|
758
|
+
error: err instanceof Error ? err.message : String(err),
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
const answerCallback = async (callbackId, text) => {
|
|
763
|
+
const b = bot;
|
|
764
|
+
if (!b || tokenInvalid || !b.api.answerCallbackQuery)
|
|
765
|
+
return; // best-effort
|
|
766
|
+
try {
|
|
767
|
+
await b.api.answerCallbackQuery(callbackId, text ? { text } : undefined);
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
/* ack is cosmetic */
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
const getIdentity = async (force) => {
|
|
774
|
+
if (selfIdentity && !force)
|
|
775
|
+
return selfIdentity;
|
|
776
|
+
const b = bot;
|
|
777
|
+
if (!b || tokenInvalid)
|
|
778
|
+
return selfIdentity;
|
|
779
|
+
try {
|
|
780
|
+
const me = await b.api.getMe();
|
|
781
|
+
selfIdentity = me;
|
|
782
|
+
selfId = String(me.id);
|
|
783
|
+
selfUsername = me.username ?? selfUsername;
|
|
784
|
+
return me;
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
safeLog("telegram getMe (probe) failed", { error: err instanceof Error ? err.message : String(err) });
|
|
788
|
+
return selfIdentity;
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
const setCommandMenu = async (commands) => {
|
|
792
|
+
const b = bot;
|
|
793
|
+
if (!b || tokenInvalid || !b.api.setMyCommands)
|
|
794
|
+
return; // best-effort
|
|
795
|
+
if (commands.length === 0)
|
|
796
|
+
return;
|
|
797
|
+
try {
|
|
798
|
+
await b.api.setMyCommands(commands);
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
safeLog("telegram setMyCommands failed (cosmetic)", {
|
|
802
|
+
error: err instanceof Error ? err.message : String(err),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
const setComposing = async (chatId, state, threadId) => {
|
|
807
|
+
if (state !== "composing")
|
|
808
|
+
return; // Telegram auto-clears typing after ~5s; nothing to send on "paused"
|
|
809
|
+
const b = bot;
|
|
810
|
+
if (!b || tokenInvalid)
|
|
811
|
+
return;
|
|
812
|
+
const params = {};
|
|
813
|
+
if (threadId)
|
|
814
|
+
params.message_thread_id = Number(threadId);
|
|
815
|
+
try {
|
|
816
|
+
await b.api.sendChatAction(chatId, "typing", params);
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
/* presence is best-effort */
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
const close = async () => {
|
|
823
|
+
closed = true;
|
|
824
|
+
connected = false;
|
|
825
|
+
await teardownRunner();
|
|
826
|
+
// Wait for the supervise loop to unwind, but never hang on it — teardown
|
|
827
|
+
// resolves the active runner's task() so the loop sees `closed` and
|
|
828
|
+
// returns promptly; the timeout is pure defense-in-depth.
|
|
829
|
+
try {
|
|
830
|
+
await Promise.race([
|
|
831
|
+
loopPromise ?? Promise.resolve(),
|
|
832
|
+
new Promise((resolve) => setTimeout(resolve, 5_000).unref?.()),
|
|
833
|
+
]);
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
/* loop already settled */
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
return {
|
|
840
|
+
selfId: () => selfId,
|
|
841
|
+
selfUsername: () => selfUsername,
|
|
842
|
+
connectedAt: () => connectedAtMs,
|
|
843
|
+
isConnected: () => connected,
|
|
844
|
+
isTokenInvalid: () => tokenInvalid,
|
|
845
|
+
sendText,
|
|
846
|
+
sendInteractive,
|
|
847
|
+
sendMedia,
|
|
848
|
+
sendPoll,
|
|
849
|
+
react,
|
|
850
|
+
editMessageText,
|
|
851
|
+
deleteMessage,
|
|
852
|
+
pinMessage,
|
|
853
|
+
unpinMessage,
|
|
854
|
+
editForumTopic,
|
|
855
|
+
answerCallback,
|
|
856
|
+
getIdentity,
|
|
857
|
+
setCommandMenu,
|
|
858
|
+
feedUpdate,
|
|
859
|
+
mode: () => mode,
|
|
860
|
+
setComposing,
|
|
861
|
+
markRead: async () => { },
|
|
862
|
+
close,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
//# sourceMappingURL=connection.js.map
|