talon-agent 3.21.0 → 3.22.1

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,13 +6,60 @@
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(
12
13
  body: Record<string, unknown>,
13
- ): { message_id: number } | undefined {
14
- const replyTo = toPositiveId(body.reply_to ?? body.reply_to_message_id);
15
- return replyTo !== undefined ? { message_id: replyTo } : undefined;
14
+ ): ReplyParams | undefined {
15
+ return replyParamsFor(
16
+ toPositiveId(body.reply_to ?? body.reply_to_message_id),
17
+ );
18
+ }
19
+
20
+ export type ReplyParams = {
21
+ message_id: number;
22
+ allow_sending_without_reply: true;
23
+ };
24
+
25
+ /**
26
+ * Build `reply_parameters` for an outbound send. Always allows sending
27
+ * without the reply: the target message may have been deleted by the time
28
+ * we send (fast-moving groups, cleanup bots), and without this flag
29
+ * Telegram 400s the whole send — every formatting-level fallback then
30
+ * fails identically and the message is lost. A send that arrives
31
+ * un-linked beats one that never arrives.
32
+ */
33
+ export function replyParamsFor(
34
+ replyTo: number | undefined,
35
+ ): ReplyParams | undefined {
36
+ return replyTo !== undefined && replyTo > 0
37
+ ? { message_id: replyTo, allow_sending_without_reply: true }
38
+ : undefined;
39
+ }
40
+
41
+ /** Delivery modifiers shared by every outbound send. */
42
+ export type ExtraSendOpts = {
43
+ message_thread_id?: number;
44
+ disable_notification?: boolean;
45
+ protect_content?: boolean;
46
+ link_preview_options?: { is_disabled: boolean };
47
+ };
48
+
49
+ /**
50
+ * Extract the delivery modifiers from an action body: forum topic (explicit
51
+ * `thread_id` beats the chat's ambient one), `silent` (no notification sound),
52
+ * and `protect` (no forwarding/saving). Spread into any Bot API send options.
53
+ */
54
+ export function sendOpts(
55
+ body: Record<string, unknown>,
56
+ chatId: number,
57
+ ): ExtraSendOpts {
58
+ return {
59
+ message_thread_id: resolveThreadId(body, chatId),
60
+ disable_notification: body.silent === true || undefined,
61
+ protect_content: body.protect === true || undefined,
62
+ };
16
63
  }
17
64
 
18
65
  /**
@@ -79,6 +126,7 @@ export async function sendText(
79
126
  replyMarkup?: NonNullable<
80
127
  Parameters<Bot["api"]["sendRichMessage"]>[2]
81
128
  >["reply_markup"],
129
+ extra?: ExtraSendOpts,
82
130
  ): Promise<number> {
83
131
  if (text.length > TELEGRAM_MAX_TEXT) {
84
132
  throw new Error(
@@ -86,14 +134,22 @@ export async function sendText(
86
134
  );
87
135
  }
88
136
 
137
+ // Default to the chat's ambient forum topic so every text path — commands,
138
+ // menus, scheduled replays — stays in the topic the conversation is in.
139
+ // An explicit `extra` (from sendOpts) already resolved this.
140
+ const opts: ExtraSendOpts = extra ?? {
141
+ message_thread_id: ambientThreadId(chatId),
142
+ };
143
+
89
144
  if (richMessagesSupported) {
90
145
  try {
91
146
  const sent = await bot.api.sendRichMessage(
92
147
  chatId,
93
148
  { markdown: text },
94
149
  {
95
- reply_parameters: replyTo ? { message_id: replyTo } : undefined,
150
+ reply_parameters: replyParamsFor(replyTo),
96
151
  reply_markup: replyMarkup,
152
+ ...opts,
97
153
  },
98
154
  );
99
155
  return sent.message_id;
@@ -106,8 +162,9 @@ export async function sendText(
106
162
  try {
107
163
  const sent = await bot.api.sendMessage(chatId, html, {
108
164
  parse_mode: "HTML",
109
- reply_parameters: replyTo ? { message_id: replyTo } : undefined,
165
+ reply_parameters: replyParamsFor(replyTo),
110
166
  reply_markup: replyMarkup,
167
+ ...opts,
111
168
  });
112
169
  return sent.message_id;
113
170
  } catch (err) {
@@ -116,8 +173,9 @@ export async function sendText(
116
173
  `Legacy HTML send failed; retrying as plain text (chat=${chatId}): ${err instanceof Error ? err.message : err}`,
117
174
  );
118
175
  const sent = await bot.api.sendMessage(chatId, text, {
119
- reply_parameters: replyTo ? { message_id: replyTo } : undefined,
176
+ reply_parameters: replyParamsFor(replyTo),
120
177
  reply_markup: replyMarkup,
178
+ ...opts,
121
179
  });
122
180
  return sent.message_id;
123
181
  }
@@ -10,7 +10,8 @@ import { toolInputToRecord } from "../../../core/agent-runtime/events.js";
10
10
  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
- import { sendText } from "../actions/shared.js";
13
+ import { replyParamsFor, 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(
@@ -21,7 +22,8 @@ export async function sendHtml(
21
22
  ): Promise<number> {
22
23
  const params = {
23
24
  parse_mode: "HTML" as const,
24
- reply_parameters: replyToId ? { message_id: replyToId } : undefined,
25
+ reply_parameters: replyParamsFor(replyToId),
26
+ message_thread_id: ambientThreadId(chatId),
25
27
  };
26
28
  try {
27
29
  const sent = await bot.api.sendMessage(chatId, html, params);
@@ -38,7 +40,8 @@ export async function sendHtml(
38
40
  plain = plain.replace(/<[^>]*>/g, "");
39
41
  } while (plain !== prev);
40
42
  const sent = await bot.api.sendMessage(chatId, plain, {
41
- reply_parameters: replyToId ? { message_id: replyToId } : undefined,
43
+ reply_parameters: replyParamsFor(replyToId),
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 {
@@ -9,13 +9,18 @@
9
9
  import type { Bot } from "grammy";
10
10
  import type { TalonConfig } from "../../../util/config.js";
11
11
  import { escapeHtml } from "../formatting.js";
12
- import { classify, friendlyMessage } from "../../../core/errors.js";
12
+ import {
13
+ classify,
14
+ friendlyMessage,
15
+ RETRY_ELAPSED_CAP_MS,
16
+ } from "../../../core/errors.js";
13
17
  import {
14
18
  getRecentHistory,
15
19
  type HistoryMessage,
16
20
  } from "../../../storage/history.js";
17
21
  import {
18
22
  recordMessageProcessed,
23
+ recordMessageSettled,
19
24
  recordMessageReceived,
20
25
  recordError,
21
26
  } from "../../../util/watchdog.js";
@@ -176,6 +181,7 @@ async function flushQueue(chatId: string): Promise<void> {
176
181
  chatTitle: last.chatTitle,
177
182
  });
178
183
 
184
+ const startedAt = Date.now();
179
185
  try {
180
186
  await runTurn();
181
187
  lastHandledMessageIdByChat.set(chatId, last.messageId);
@@ -190,8 +196,20 @@ async function flushQueue(chatId: string): Promise<void> {
190
196
  );
191
197
  recordError(classified.message);
192
198
 
193
- // Retry once for transient errors (rate_limit, overloaded, network)
194
- if (classified.retryable) {
199
+ // Retry once for transient errors (rate_limit, overloaded, network) —
200
+ // but only when the failed attempt was actually brief. An attempt that
201
+ // already ran for minutes (a remote turn deadline, a slow collapse)
202
+ // won't be saved by a 2s pause, and turns serialize per chat, so the
203
+ // blind retry used to double a 10-minute stall for everything queued
204
+ // behind it.
205
+ const attemptMs = Date.now() - startedAt;
206
+ if (classified.retryable && attemptMs >= RETRY_ELAPSED_CAP_MS) {
207
+ log(
208
+ "bot",
209
+ `[${chatId}] Not retrying ${classified.reason}: the attempt already ran ${Math.round(attemptMs / 1000)}s`,
210
+ );
211
+ }
212
+ if (classified.retryable && attemptMs < RETRY_ELAPSED_CAP_MS) {
195
213
  const delayMs = classified.retryAfterMs ?? 2000;
196
214
  log(
197
215
  "bot",
@@ -209,6 +227,7 @@ async function flushQueue(chatId: string): Promise<void> {
209
227
  "bot",
210
228
  `[${chatId}] [${chatType}] Retry failed: ${retryClassified.message}`,
211
229
  );
230
+ recordMessageSettled();
212
231
  await sendHtml(
213
232
  bot,
214
233
  numericChatId,
@@ -219,6 +238,7 @@ async function flushQueue(chatId: string): Promise<void> {
219
238
  }
220
239
  }
221
240
 
241
+ recordMessageSettled();
222
242
  await sendHtml(
223
243
  bot,
224
244
  numericChatId,
@@ -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
+ }