talon-agent 3.20.0 → 3.22.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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Moderation actions — the `moderate` tool's member/chat/topic operations,
3
+ * plus profile-photo lookup.
4
+ *
5
+ * Handlers call the Bot API directly and let Telegram's own errors surface
6
+ * (missing admin rights come back as descriptive 400s the model can read);
7
+ * pre-checking rights here would just race the real check.
8
+ */
9
+
10
+ import type { ChatPermissions } from "grammy/types";
11
+ import { toPositiveId } from "./shared.js";
12
+ import { resolveMediaInput } from "./media.js";
13
+ import { clearJoinRequest, listJoinRequests } from "../join-requests.js";
14
+ import type { TelegramActionHandlers } from "./types.js";
15
+
16
+ /** Seconds-from-now → Telegram until_date, honouring "omit = forever". */
17
+ function untilDate(minutes: unknown): number | undefined {
18
+ const n = Number(minutes);
19
+ if (!Number.isFinite(n) || n <= 0) return undefined;
20
+ return Math.floor(Date.now() / 1000) + Math.round(n * 60);
21
+ }
22
+
23
+ /**
24
+ * Friendly permission names → ChatPermissions. `send_media` fans out to all
25
+ * the per-kind media flags; raw `can_*` keys pass through untouched so the
26
+ * full API surface stays reachable.
27
+ */
28
+ function toChatPermissions(raw: Record<string, boolean>): ChatPermissions {
29
+ const out: Record<string, boolean> = {};
30
+ const alias: Record<string, string[]> = {
31
+ send_messages: ["can_send_messages"],
32
+ send_media: [
33
+ "can_send_audios",
34
+ "can_send_documents",
35
+ "can_send_photos",
36
+ "can_send_videos",
37
+ "can_send_video_notes",
38
+ "can_send_voice_notes",
39
+ ],
40
+ send_polls: ["can_send_polls"],
41
+ send_other: ["can_send_other_messages"],
42
+ web_previews: ["can_add_web_page_previews"],
43
+ change_info: ["can_change_info"],
44
+ invite_users: ["can_invite_users"],
45
+ pin_messages: ["can_pin_messages"],
46
+ manage_topics: ["can_manage_topics"],
47
+ };
48
+ for (const [key, value] of Object.entries(raw)) {
49
+ if (typeof value !== "boolean") continue;
50
+ for (const target of alias[key] ?? (key.startsWith("can_") ? [key] : [])) {
51
+ out[target] = value;
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** Everything a mute takes away; unmute grants it back. */
58
+ const FULL_MEMBER_PERMISSIONS: ChatPermissions = toChatPermissions({
59
+ send_messages: true,
60
+ send_media: true,
61
+ send_polls: true,
62
+ send_other: true,
63
+ web_previews: true,
64
+ });
65
+
66
+ /** The workaday admin kit `promote` grants; demote sets all of these false. */
67
+ const DEFAULT_ADMIN_RIGHTS = {
68
+ can_manage_chat: true,
69
+ can_delete_messages: true,
70
+ can_restrict_members: true,
71
+ can_pin_messages: true,
72
+ can_invite_users: true,
73
+ can_change_info: true,
74
+ can_manage_topics: true,
75
+ can_manage_video_chats: true,
76
+ };
77
+
78
+ export const moderationHandlers: TelegramActionHandlers = {
79
+ moderate: async (body, chatId, ctx) => {
80
+ const { bot, InputFileClass } = ctx;
81
+ const op = String(body.op ?? "");
82
+ const userId = toPositiveId(body.user_id);
83
+ const needsUser = [
84
+ "ban",
85
+ "unban",
86
+ "mute",
87
+ "unmute",
88
+ "promote",
89
+ "demote",
90
+ "set_admin_title",
91
+ "approve_join_request",
92
+ "decline_join_request",
93
+ ];
94
+ if (needsUser.includes(op) && userId === undefined)
95
+ return { ok: false, error: `${op}: user_id is required` };
96
+
97
+ switch (op) {
98
+ case "ban":
99
+ await bot.api.banChatMember(chatId, userId!, {
100
+ until_date: untilDate(body.minutes),
101
+ revoke_messages: body.delete_messages === true || undefined,
102
+ });
103
+ return { ok: true };
104
+ case "unban":
105
+ // only_if_banned keeps this from kicking a current member.
106
+ await bot.api.unbanChatMember(chatId, userId!, {
107
+ only_if_banned: true,
108
+ });
109
+ return { ok: true };
110
+ case "mute":
111
+ await bot.api.restrictChatMember(
112
+ chatId,
113
+ userId!,
114
+ { can_send_messages: false },
115
+ { until_date: untilDate(body.minutes) },
116
+ );
117
+ return { ok: true };
118
+ case "unmute":
119
+ await bot.api.restrictChatMember(
120
+ chatId,
121
+ userId!,
122
+ FULL_MEMBER_PERMISSIONS,
123
+ );
124
+ return { ok: true };
125
+ case "promote":
126
+ await bot.api.promoteChatMember(chatId, userId!, DEFAULT_ADMIN_RIGHTS);
127
+ return { ok: true };
128
+ case "demote": {
129
+ const revoked = Object.fromEntries(
130
+ Object.keys(DEFAULT_ADMIN_RIGHTS).map((k) => [k, false]),
131
+ );
132
+ await bot.api.promoteChatMember(chatId, userId!, revoked);
133
+ return { ok: true };
134
+ }
135
+ case "set_admin_title":
136
+ await bot.api.setChatAdministratorCustomTitle(
137
+ chatId,
138
+ userId!,
139
+ String(body.title ?? ""),
140
+ );
141
+ return { ok: true };
142
+ case "set_permissions": {
143
+ const raw = body.permissions;
144
+ if (!raw || typeof raw !== "object")
145
+ return { ok: false, error: "set_permissions: permissions required" };
146
+ await bot.api.setChatPermissions(
147
+ chatId,
148
+ toChatPermissions(raw as Record<string, boolean>),
149
+ );
150
+ return { ok: true };
151
+ }
152
+ case "create_invite_link": {
153
+ const expireMin = Number(body.expire_minutes);
154
+ const link = await bot.api.createChatInviteLink(chatId, {
155
+ name: body.title ? String(body.title) : undefined,
156
+ expire_date:
157
+ Number.isFinite(expireMin) && expireMin > 0
158
+ ? Math.floor(Date.now() / 1000) + Math.round(expireMin * 60)
159
+ : undefined,
160
+ member_limit: toPositiveId(body.member_limit),
161
+ });
162
+ return { ok: true, invite_link: link.invite_link };
163
+ }
164
+ case "revoke_invite_link": {
165
+ if (!body.link)
166
+ return { ok: false, error: "revoke_invite_link: link required" };
167
+ await bot.api.revokeChatInviteLink(chatId, String(body.link));
168
+ return { ok: true };
169
+ }
170
+ case "approve_join_request":
171
+ await bot.api.approveChatJoinRequest(chatId, userId!);
172
+ clearJoinRequest(chatId, userId!);
173
+ return { ok: true };
174
+ case "decline_join_request":
175
+ await bot.api.declineChatJoinRequest(chatId, userId!);
176
+ clearJoinRequest(chatId, userId!);
177
+ return { ok: true };
178
+ case "list_join_requests":
179
+ return { ok: true, requests: listJoinRequests(chatId) };
180
+ case "set_chat_photo": {
181
+ const resolved = resolveMediaInput(
182
+ body,
183
+ "set_chat_photo",
184
+ InputFileClass,
185
+ );
186
+ if ("error" in resolved) return { ok: false, error: resolved.error };
187
+ if (typeof resolved.file === "string")
188
+ return {
189
+ ok: false,
190
+ error: "set_chat_photo needs a local file_path (no url/file_id)",
191
+ };
192
+ await bot.api.setChatPhoto(chatId, resolved.file);
193
+ return { ok: true };
194
+ }
195
+ case "delete_chat_photo":
196
+ await bot.api.deleteChatPhoto(chatId);
197
+ return { ok: true };
198
+ case "unpin_all":
199
+ await bot.api.unpinAllChatMessages(chatId);
200
+ return { ok: true };
201
+ case "leave_chat":
202
+ await bot.api.leaveChat(chatId);
203
+ return { ok: true };
204
+ case "create_topic": {
205
+ if (!body.title)
206
+ return { ok: false, error: "create_topic: title required" };
207
+ const topic = await bot.api.createForumTopic(
208
+ chatId,
209
+ String(body.title),
210
+ );
211
+ return { ok: true, thread_id: topic.message_thread_id };
212
+ }
213
+ case "edit_topic": {
214
+ const threadId = toPositiveId(body.thread_id);
215
+ if (threadId === undefined)
216
+ return { ok: false, error: "edit_topic: thread_id required" };
217
+ await bot.api.editForumTopic(chatId, threadId, {
218
+ name: body.title ? String(body.title) : undefined,
219
+ });
220
+ return { ok: true };
221
+ }
222
+ case "close_topic":
223
+ case "reopen_topic":
224
+ case "delete_topic": {
225
+ const threadId = toPositiveId(body.thread_id);
226
+ if (threadId === undefined)
227
+ return { ok: false, error: `${op}: thread_id required` };
228
+ if (op === "close_topic")
229
+ await bot.api.closeForumTopic(chatId, threadId);
230
+ else if (op === "reopen_topic")
231
+ await bot.api.reopenForumTopic(chatId, threadId);
232
+ else await bot.api.deleteForumTopic(chatId, threadId);
233
+ return { ok: true };
234
+ }
235
+ default:
236
+ return { ok: false, error: `Unknown moderation op: ${op}` };
237
+ }
238
+ },
239
+
240
+ get_user_profile_photos: async (body, _chatId, { bot }) => {
241
+ const userId = toPositiveId(body.user_id);
242
+ if (userId === undefined)
243
+ return { ok: false, error: "user_id is required" };
244
+ const limit = toPositiveId(body.limit) ?? 5;
245
+ const photos = await bot.api.getUserProfilePhotos(userId, {
246
+ limit: Math.min(limit, 100),
247
+ });
248
+ return {
249
+ ok: true,
250
+ total: photos.total_count,
251
+ // Largest size of each photo; its file_id sends/downloads like media.
252
+ file_ids: photos.photos.map((sizes) => sizes[sizes.length - 1].file_id),
253
+ };
254
+ },
255
+ };
@@ -6,6 +6,7 @@
6
6
  import type { Bot } from "grammy";
7
7
  import { markdownToTelegramHtml } from "../formatting.js";
8
8
  import { logWarn } from "../../../util/log.js";
9
+ import { ambientThreadId, resolveThreadId } from "../topics.js";
9
10
  import { TELEGRAM_MAX_TEXT } from "./types.js";
10
11
 
11
12
  export function replyParams(
@@ -15,6 +16,30 @@ export function replyParams(
15
16
  return replyTo !== undefined ? { message_id: replyTo } : undefined;
16
17
  }
17
18
 
19
+ /** Delivery modifiers shared by every outbound send. */
20
+ export type ExtraSendOpts = {
21
+ message_thread_id?: number;
22
+ disable_notification?: boolean;
23
+ protect_content?: boolean;
24
+ link_preview_options?: { is_disabled: boolean };
25
+ };
26
+
27
+ /**
28
+ * Extract the delivery modifiers from an action body: forum topic (explicit
29
+ * `thread_id` beats the chat's ambient one), `silent` (no notification sound),
30
+ * and `protect` (no forwarding/saving). Spread into any Bot API send options.
31
+ */
32
+ export function sendOpts(
33
+ body: Record<string, unknown>,
34
+ chatId: number,
35
+ ): ExtraSendOpts {
36
+ return {
37
+ message_thread_id: resolveThreadId(body, chatId),
38
+ disable_notification: body.silent === true || undefined,
39
+ protect_content: body.protect === true || undefined,
40
+ };
41
+ }
42
+
18
43
  /**
19
44
  * Telegram/GramJS APIs expect numeric IDs. `snowflakeOrIdSchema` normalizes IDs
20
45
  * to digit-strings (so 17-19 digit Discord snowflakes survive validation), so a
@@ -79,6 +104,7 @@ export async function sendText(
79
104
  replyMarkup?: NonNullable<
80
105
  Parameters<Bot["api"]["sendRichMessage"]>[2]
81
106
  >["reply_markup"],
107
+ extra?: ExtraSendOpts,
82
108
  ): Promise<number> {
83
109
  if (text.length > TELEGRAM_MAX_TEXT) {
84
110
  throw new Error(
@@ -86,6 +112,13 @@ export async function sendText(
86
112
  );
87
113
  }
88
114
 
115
+ // Default to the chat's ambient forum topic so every text path — commands,
116
+ // menus, scheduled replays — stays in the topic the conversation is in.
117
+ // An explicit `extra` (from sendOpts) already resolved this.
118
+ const opts: ExtraSendOpts = extra ?? {
119
+ message_thread_id: ambientThreadId(chatId),
120
+ };
121
+
89
122
  if (richMessagesSupported) {
90
123
  try {
91
124
  const sent = await bot.api.sendRichMessage(
@@ -94,6 +127,7 @@ export async function sendText(
94
127
  {
95
128
  reply_parameters: replyTo ? { message_id: replyTo } : undefined,
96
129
  reply_markup: replyMarkup,
130
+ ...opts,
97
131
  },
98
132
  );
99
133
  return sent.message_id;
@@ -108,6 +142,7 @@ export async function sendText(
108
142
  parse_mode: "HTML",
109
143
  reply_parameters: replyTo ? { message_id: replyTo } : undefined,
110
144
  reply_markup: replyMarkup,
145
+ ...opts,
111
146
  });
112
147
  return sent.message_id;
113
148
  } catch (err) {
@@ -118,6 +153,7 @@ export async function sendText(
118
153
  const sent = await bot.api.sendMessage(chatId, text, {
119
154
  reply_parameters: replyTo ? { message_id: replyTo } : undefined,
120
155
  reply_markup: replyMarkup,
156
+ ...opts,
121
157
  });
122
158
  return sent.message_id;
123
159
  }
@@ -11,6 +11,7 @@ import { appendDailyLogResponse } from "../../../storage/daily-log.js";
11
11
  import { stripMcpPrefix } from "../../../core/tools/index.js";
12
12
  import { logWarn } from "../../../util/log.js";
13
13
  import { sendText } from "../actions/shared.js";
14
+ import { ambientThreadId } from "../topics.js";
14
15
  import { trackDmUser } from "./access.js";
15
16
 
16
17
  export async function sendHtml(
@@ -22,6 +23,7 @@ export async function sendHtml(
22
23
  const params = {
23
24
  parse_mode: "HTML" as const,
24
25
  reply_parameters: replyToId ? { message_id: replyToId } : undefined,
26
+ message_thread_id: ambientThreadId(chatId),
25
27
  };
26
28
  try {
27
29
  const sent = await bot.api.sendMessage(chatId, html, params);
@@ -39,6 +41,7 @@ export async function sendHtml(
39
41
  } while (plain !== prev);
40
42
  const sent = await bot.api.sendMessage(chatId, plain, {
41
43
  reply_parameters: replyToId ? { message_id: replyToId } : undefined,
44
+ message_thread_id: ambientThreadId(chatId),
42
45
  });
43
46
  return sent.message_id;
44
47
  }
@@ -97,7 +100,9 @@ function createStreamCallbacks(
97
100
  ? accumulated.slice(0, 3900) + "…"
98
101
  : accumulated;
99
102
 
100
- await bot.api.sendMessageDraft(chatId, state.draftId, display);
103
+ await bot.api.sendMessageDraft(chatId, state.draftId, display, {
104
+ message_thread_id: ambientThreadId(chatId),
105
+ });
101
106
  if (draftsSupported === null) draftsSupported = true;
102
107
  state.lastSentLength = accumulated.length;
103
108
  } catch {
@@ -14,6 +14,7 @@ import { soulEnabled } from "../../core/soul/settings.js";
14
14
  import type { ContextManager } from "../../core/types.js";
15
15
  import type { Gateway } from "../../core/engine/gateway.js";
16
16
  import { createTelegramActionHandler, sendText } from "./actions/index.js";
17
+ import { ambientThreadId } from "./topics.js";
17
18
  import { initUserClient, disconnectUserClient } from "./userbot.js";
18
19
  import {
19
20
  registerCommands,
@@ -60,7 +61,11 @@ export function createTelegramFrontend(
60
61
  context,
61
62
 
62
63
  sendTyping: (chatId: number) =>
63
- bot.api.sendChatAction(chatId, "typing").then(() => {}),
64
+ bot.api
65
+ .sendChatAction(chatId, "typing", {
66
+ message_thread_id: ambientThreadId(chatId),
67
+ })
68
+ .then(() => {}),
64
69
 
65
70
  sendMessage: async (chatId: number, text: string) => {
66
71
  await sendText(bot, chatId, text);
@@ -118,13 +123,16 @@ export function createTelegramFrontend(
118
123
  process.exit(1);
119
124
  }
120
125
  });
121
- // Reactions arrive only if we explicitly request `message_reaction`
122
- // (it is absent from grammY's default allowed_updates). Subscribe only
123
- // when the soul is enabled, so a soulless deployment keeps the default
124
- // update set and behaves byte-identically.
125
- const allowedUpdates = soulEnabled(config.soul)
126
- ? [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "message_reaction" as const]
127
- : undefined;
126
+ // Beyond grammY's defaults: `chat_join_request` feeds the moderation
127
+ // tool's pending-join cache (inert unless the bot admins an
128
+ // approval-gated chat), and `message_reaction` feeds the soul's
129
+ // reaction tap — subscribed only when the soul is enabled, so a
130
+ // soulless deployment's reaction behaviour is unchanged.
131
+ const allowedUpdates = [
132
+ ...API_CONSTANTS.DEFAULT_UPDATE_TYPES,
133
+ "chat_join_request" as const,
134
+ ...(soulEnabled(config.soul) ? ["message_reaction" as const] : []),
135
+ ];
128
136
  await bot.start({
129
137
  allowed_updates: allowedUpdates,
130
138
  onStart: (info) => log("bot", `Talon running as @${info.username}`),
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pending chat-join-request tracking.
3
+ *
4
+ * When the bot administers a group whose invite link requires approval,
5
+ * Telegram delivers `chat_join_request` updates. They're stored here (not
6
+ * enqueued as agent turns — a join isn't a conversation) so the agent can
7
+ * review and act when asked: `moderate(op="list_join_requests")`, then
8
+ * approve/decline by user_id.
9
+ *
10
+ * In-memory by design: requests are also visible in Telegram's own UI, so
11
+ * losing the cache on restart costs nothing but a re-request.
12
+ */
13
+
14
+ export type JoinRequest = {
15
+ userId: number;
16
+ name: string;
17
+ username?: string;
18
+ bio?: string;
19
+ /** Unix ms the request arrived. */
20
+ at: number;
21
+ };
22
+
23
+ const MAX_PER_CHAT = 200;
24
+
25
+ const pendingByChat = new Map<number, Map<number, JoinRequest>>();
26
+
27
+ export function recordJoinRequest(chatId: number, req: JoinRequest): void {
28
+ let chat = pendingByChat.get(chatId);
29
+ if (!chat) {
30
+ chat = new Map();
31
+ pendingByChat.set(chatId, chat);
32
+ }
33
+ if (chat.size >= MAX_PER_CHAT && !chat.has(req.userId)) {
34
+ // Drop the oldest (first-inserted) entry to stay bounded.
35
+ const oldest = chat.keys().next();
36
+ if (!oldest.done) chat.delete(oldest.value);
37
+ }
38
+ chat.set(req.userId, req);
39
+ }
40
+
41
+ /** Forget a request once it's been approved/declined (or withdrawn). */
42
+ export function clearJoinRequest(chatId: number, userId: number): void {
43
+ pendingByChat.get(chatId)?.delete(userId);
44
+ }
45
+
46
+ export function listJoinRequests(chatId: number): JoinRequest[] {
47
+ return [...(pendingByChat.get(chatId)?.values() ?? [])];
48
+ }
49
+
50
+ /** Test seam. */
51
+ export function resetJoinRequests(): void {
52
+ pendingByChat.clear();
53
+ }
@@ -10,6 +10,8 @@ import { allowChat, revokeChat } from "./userbot.js";
10
10
  import { registerChat } from "../../core/background/pulse.js";
11
11
  import { log } from "../../util/log.js";
12
12
  import { getSenderName } from "./handlers/index.js";
13
+ import { noteInboundThread } from "./topics.js";
14
+ import { recordJoinRequest } from "./join-requests.js";
13
15
  import { newlyAddedEmojis, recordReactionToBot } from "../../core/soul/taps.js";
14
16
  import {
15
17
  handleTextMessage,
@@ -28,6 +30,10 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
28
30
  bot.on("message", (ctx, next) => {
29
31
  const chatId = String(ctx.chat.id);
30
32
  const sender = getSenderName(ctx.from);
33
+ // Keep the ambient forum topic current so outbound sends (which have no
34
+ // reply anchor — drafts, media, plain sends) land in the topic the
35
+ // conversation is actually happening in, not General.
36
+ noteInboundThread(ctx.chat.id, ctx.message);
31
37
  // The handle is the only addressable form of a user — persist it with
32
38
  // every row so later readers (history views, heartbeat-composed
33
39
  // messages) can mention someone instead of guessing.
@@ -188,6 +194,25 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
188
194
  recordReactionToBot(mr.chat.id, mr.message_id, added);
189
195
  });
190
196
 
197
+ // ── Join requests — cache for moderate(op="list_join_requests") ─────────
198
+ // Delivered only when subscribed via allowed_updates (see index.ts) and
199
+ // the bot admins a chat whose invite link requires approval. Stored, not
200
+ // enqueued: a join request isn't a conversation turn.
201
+ bot.on("chat_join_request", (ctx) => {
202
+ const req = ctx.chatJoinRequest;
203
+ recordJoinRequest(ctx.chat.id, {
204
+ userId: req.from.id,
205
+ name: getSenderName(req.from),
206
+ username: req.from.username,
207
+ bio: req.bio,
208
+ at: Date.now(),
209
+ });
210
+ log(
211
+ "bot",
212
+ `Join request for chat ${ctx.chat.id} from ${req.from.id} (@${req.from.username ?? "?"})`,
213
+ );
214
+ });
215
+
191
216
  // ── Bot removed from group — revoke userbot access ─────────────────────
192
217
  bot.on("my_chat_member", (ctx) => {
193
218
  const newStatus = ctx.myChatMember.new_chat_member.status;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Forum-topic (message thread) tracking.
3
+ *
4
+ * In a supergroup with topics enabled, every message lives in a thread, and a
5
+ * bot send without a `message_thread_id` lands in the General topic — so
6
+ * without this the agent answers a question asked in #support over in
7
+ * General. Telegram does route a send that *replies* to a topic message into
8
+ * that topic, but most agent output (reactions aside) is not a reply: draft
9
+ * streaming, scheduled sends, media, and plain `send(type="text")` all go out
10
+ * unanchored.
11
+ *
12
+ * The registry keeps the last inbound thread per chat, updated by the
13
+ * history-capture middleware that already sees every message. Outbound
14
+ * helpers consult it via {@link resolveThreadId}, with an explicit
15
+ * `thread_id` in the action body always winning over the ambient value.
16
+ *
17
+ * Last-write-wins per chat is the right granularity: chats are processed as
18
+ * serial turns, and each turn's replies belong to the topic that woke it.
19
+ */
20
+
21
+ const lastThreadByChat = new Map<number, number>();
22
+
23
+ /** Record (or clear) the ambient thread from an inbound message. */
24
+ export function noteInboundThread(
25
+ chatId: number,
26
+ msg: { is_topic_message?: boolean; message_thread_id?: number },
27
+ ): void {
28
+ // `message_thread_id` is also set on plain reply chains in non-forum
29
+ // groups; only `is_topic_message` marks a real forum topic. General-topic
30
+ // messages carry neither, and must clear a stale thread.
31
+ if (msg.is_topic_message && msg.message_thread_id) {
32
+ lastThreadByChat.set(chatId, msg.message_thread_id);
33
+ } else {
34
+ lastThreadByChat.delete(chatId);
35
+ }
36
+ }
37
+
38
+ /** The thread the chat's latest topic message arrived in, if any. */
39
+ export function ambientThreadId(chatId: number): number | undefined {
40
+ return lastThreadByChat.get(chatId);
41
+ }
42
+
43
+ /**
44
+ * Thread for an outbound action: an explicit `thread_id` in the body wins,
45
+ * else the chat's ambient thread. `thread_id: 0` (or "general") explicitly
46
+ * targets the General topic, suppressing the ambient value.
47
+ */
48
+ export function resolveThreadId(
49
+ body: Record<string, unknown>,
50
+ chatId: number,
51
+ ): number | undefined {
52
+ const raw = body.thread_id;
53
+ if (raw === 0 || raw === "0" || raw === "general") return undefined;
54
+ const n =
55
+ typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
56
+ if (Number.isInteger(n) && n > 0) return n;
57
+ return ambientThreadId(chatId);
58
+ }
59
+
60
+ /** Test seam. */
61
+ export function resetThreadRegistry(): void {
62
+ lastThreadByChat.clear();
63
+ }
@@ -129,10 +129,17 @@ export async function disconnectUserClient(): Promise<void> {
129
129
  const CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
130
130
 
131
131
  let reconnecting = false;
132
+ // Bumped by stopConnectionMonitor so a tick whose awaits straddle a shutdown
133
+ // can tell that its own client was torn down under it — the liveness probe
134
+ // rejecting because *we* disconnected must not trigger a reconnect, or
135
+ // shutdown resurrects the client it just closed.
136
+ let monitorGeneration = 0;
132
137
 
133
138
  function startConnectionMonitor(): void {
134
139
  if (reconnectTimer) return;
140
+ const gen = monitorGeneration;
135
141
  reconnectTimer = setInterval(async () => {
142
+ if (gen !== monitorGeneration) return;
136
143
  if (!client) return;
137
144
  if (reconnecting) return; // prevent overlapping reconnect attempts
138
145
 
@@ -146,10 +153,12 @@ function startConnectionMonitor(): void {
146
153
  await client.getMe();
147
154
  return; // genuinely alive
148
155
  } catch {
156
+ if (gen !== monitorGeneration) return; // monitor stopped mid-probe
149
157
  logWarn("userbot", "Liveness check failed — treating socket as dead.");
150
158
  }
151
159
  }
152
160
 
161
+ if (!client) return; // torn down while the probe was in flight
153
162
  reconnecting = true;
154
163
  logWarn("userbot", "Connection lost, attempting reconnect...");
155
164
  try {
@@ -160,6 +169,7 @@ function startConnectionMonitor(): void {
160
169
  logWarn("userbot", "Reconnected but not authorized.");
161
170
  }
162
171
  } catch (err) {
172
+ if (gen !== monitorGeneration) return; // stopped while reconnecting
163
173
  logError("userbot", "Reconnect failed", err);
164
174
  // Try a full re-init on next check
165
175
  if (storedApiId && storedApiHash) {
@@ -189,6 +199,7 @@ function startConnectionMonitor(): void {
189
199
  }
190
200
 
191
201
  function stopConnectionMonitor(): void {
202
+ monitorGeneration++;
192
203
  if (reconnectTimer) {
193
204
  clearInterval(reconnectTimer);
194
205
  reconnectTimer = null;