talon-agent 3.21.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.
- package/package.json +2 -2
- 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/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 +36 -0
- package/src/frontend/telegram/handlers/delivery.ts +6 -1
- 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
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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;
|