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.
- package/package.json +2 -2
- package/src/app.ts +15 -5
- package/src/backend/claude-sdk/handler.ts +20 -11
- package/src/backend/remote-server/mcp.ts +15 -2
- package/src/backend/shared/cache-telemetry.ts +42 -0
- package/src/backend/shared/index.ts +3 -2
- package/src/core/engine/dispatcher.ts +10 -0
- package/src/core/errors.ts +11 -0
- package/src/core/mcp-hub/children.ts +45 -1
- package/src/core/tasks/table.ts +14 -0
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/messaging.ts +154 -8
- package/src/core/tools/moderation.ts +131 -0
- package/src/core/tools/types.ts +1 -0
- package/src/frontend/discord/handlers/queue.ts +22 -2
- package/src/frontend/telegram/actions/index.ts +2 -0
- package/src/frontend/telegram/actions/media.ts +143 -31
- package/src/frontend/telegram/actions/messaging.ts +78 -13
- package/src/frontend/telegram/actions/moderation.ts +255 -0
- package/src/frontend/telegram/actions/shared.ts +64 -6
- package/src/frontend/telegram/handlers/delivery.ts +9 -4
- package/src/frontend/telegram/handlers/queue.ts +23 -3
- package/src/frontend/telegram/index.ts +16 -8
- package/src/frontend/telegram/join-requests.ts +53 -0
- package/src/frontend/telegram/middleware.ts +25 -0
- package/src/frontend/telegram/topics.ts +63 -0
- package/src/frontend/telegram/userbot.ts +11 -0
- package/src/util/watchdog.ts +12 -0
|
@@ -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
|
-
):
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|