jeopi-cli 16.5.6 → 16.6.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,244 @@
1
+ import { logger } from "jeopi-utils";
2
+ import { MessengerHttpError, messengerFetchJson, splitMessengerText, waitForMessengerRetry } from "./http";
3
+ import type { MessengerAdapter, MessengerMessage, MessengerPlatformConfig, MessengerTransportDeps } from "./types";
4
+
5
+ interface TelegramIdentity {
6
+ id: number;
7
+ username: string;
8
+ }
9
+
10
+ class TelegramApiError extends Error {
11
+ constructor(
12
+ readonly status: number,
13
+ readonly retryAfterMs = 0,
14
+ ) {
15
+ super("Telegram API request failed");
16
+ }
17
+ }
18
+
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ function isInteger(value: unknown): value is number {
24
+ return typeof value === "number" && Number.isSafeInteger(value);
25
+ }
26
+
27
+ function isPositiveId(value: unknown): value is number {
28
+ return isInteger(value) && value > 0;
29
+ }
30
+
31
+ function parseIdentity(value: unknown): TelegramIdentity {
32
+ if (
33
+ !isRecord(value) ||
34
+ !isPositiveId(value.id) ||
35
+ value.is_bot !== true ||
36
+ typeof value.username !== "string" ||
37
+ !/^[a-zA-Z0-9_]{1,32}$/.test(value.username)
38
+ ) {
39
+ throw new TelegramApiError(400);
40
+ }
41
+ return { id: value.id, username: value.username };
42
+ }
43
+
44
+ function stripMention(
45
+ text: string,
46
+ entities: unknown,
47
+ identity: TelegramIdentity,
48
+ ): { text: string; mentioned: boolean } {
49
+ const mention = `@${identity.username.toLowerCase()}`;
50
+ const ranges: { start: number; end: number }[] = [];
51
+ if (Array.isArray(entities)) {
52
+ for (const entity of entities) {
53
+ if (
54
+ !isRecord(entity) ||
55
+ !isInteger(entity.offset) ||
56
+ !isPositiveId(entity.length) ||
57
+ entity.offset < 0 ||
58
+ entity.offset + entity.length > text.length
59
+ ) {
60
+ continue;
61
+ }
62
+ const start = entity.offset;
63
+ const end = start + entity.length;
64
+ const value = text.slice(start, end).toLowerCase();
65
+ if (
66
+ (entity.type === "mention" && value === mention) ||
67
+ (entity.type === "text_mention" && isRecord(entity.user) && entity.user.id === identity.id)
68
+ ) {
69
+ ranges.push({ start, end });
70
+ } else if (
71
+ entity.type === "bot_command" &&
72
+ /^\/[a-z0-9_]+@[a-z0-9_]+$/.test(value) &&
73
+ value.endsWith(mention)
74
+ ) {
75
+ ranges.push({ start: end - mention.length, end });
76
+ }
77
+ }
78
+ } else if (entities === undefined) {
79
+ // Username is validated before interpolation; reject email/name substrings.
80
+ const pattern = new RegExp(`(^|[^\\p{L}\\p{N}_@])@${identity.username}(?![\\p{L}\\p{N}_])`, "giu");
81
+ for (const match of text.matchAll(pattern)) {
82
+ const start = match.index + match[1].length;
83
+ ranges.push({ start, end: start + mention.length });
84
+ }
85
+ }
86
+ if (ranges.length === 0) return { text, mentioned: false };
87
+ ranges.sort((left, right) => left.start - right.start);
88
+ let cursor = 0;
89
+ let stripped = "";
90
+ for (const range of ranges) {
91
+ if (range.start < cursor) continue;
92
+ stripped += text.slice(cursor, range.start);
93
+ cursor = range.end;
94
+ }
95
+ return { text: (stripped + text.slice(cursor)).trim(), mentioned: true };
96
+ }
97
+
98
+ function parseMessage(value: unknown, identity: TelegramIdentity): MessengerMessage | undefined {
99
+ if (
100
+ !isRecord(value) ||
101
+ !isPositiveId(value.message_id) ||
102
+ typeof value.text !== "string" ||
103
+ !isRecord(value.from) ||
104
+ !isPositiveId(value.from.id) ||
105
+ value.from.is_bot !== false ||
106
+ !isRecord(value.chat) ||
107
+ !isInteger(value.chat.id) ||
108
+ value.chat.id === 0 ||
109
+ (value.chat.type !== "private" && value.chat.type !== "group" && value.chat.type !== "supergroup") ||
110
+ (value.message_thread_id !== undefined && !isPositiveId(value.message_thread_id)) ||
111
+ value.edit_date !== undefined ||
112
+ value.sender_chat !== undefined
113
+ ) {
114
+ return undefined;
115
+ }
116
+ const content = stripMention(value.text, value.entities, identity);
117
+ const direct = value.chat.type === "private";
118
+ if (!content.text.trim()) return undefined;
119
+ return {
120
+ platform: "telegram",
121
+ accountId: String(identity.id),
122
+ channelId: String(value.chat.id),
123
+ senderId: String(value.from.id),
124
+ messageId: String(value.message_id),
125
+ text: content.text,
126
+ ...(value.message_thread_id === undefined ? {} : { threadId: String(value.message_thread_id) }),
127
+ direct,
128
+ mentioned: content.mentioned,
129
+ };
130
+ }
131
+
132
+ function isPermanent(error: unknown): boolean {
133
+ if (!(error instanceof TelegramApiError || error instanceof MessengerHttpError)) return false;
134
+ return error.status >= 400 && error.status < 500 && error.status !== 408 && error.status !== 429;
135
+ }
136
+
137
+ export function createTelegramAdapter(
138
+ config: MessengerPlatformConfig,
139
+ env: Record<string, string | undefined> = process.env,
140
+ deps: MessengerTransportDeps = {},
141
+ ): MessengerAdapter {
142
+ const token = env[config.tokenEnv];
143
+ if (!token?.trim()) throw new Error("Telegram bot token is missing");
144
+ const baseUrl = `https://api.telegram.org/bot${encodeURIComponent(token).replaceAll("%3A", ":")}/`;
145
+ let running = false;
146
+ let offset = 0;
147
+
148
+ async function request(method: string, parameters: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
149
+ const response = await messengerFetchJson(
150
+ `${baseUrl}${method}`,
151
+ { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(parameters) },
152
+ signal,
153
+ deps.fetch,
154
+ );
155
+ if (!isRecord(response) || typeof response.ok !== "boolean") throw new TelegramApiError(400);
156
+ if (!response.ok) {
157
+ const status = isInteger(response.error_code) ? response.error_code : 400;
158
+ const retryAfter = isRecord(response.parameters) ? response.parameters.retry_after : undefined;
159
+ throw new TelegramApiError(status, isPositiveId(retryAfter) ? Math.min(retryAfter, 300) * 1_000 : 0);
160
+ }
161
+ if (!("result" in response)) throw new TelegramApiError(400);
162
+ return response.result;
163
+ }
164
+
165
+ return {
166
+ platform: "telegram",
167
+ async start(onMessage, signal) {
168
+ if (signal.aborted) return;
169
+ if (running) throw new Error("Telegram adapter is already running");
170
+ running = true;
171
+ const stopped = Promise.withResolvers<void>();
172
+ const onAbort = () => stopped.resolve();
173
+ signal.addEventListener("abort", onAbort, { once: true });
174
+ let identity: TelegramIdentity | undefined;
175
+ let retryMs = 1_000;
176
+ try {
177
+ while (!signal.aborted) {
178
+ try {
179
+ identity ??= parseIdentity(await request("getMe", {}, signal));
180
+ if (signal.aborted) break;
181
+ const updates = await request(
182
+ "getUpdates",
183
+ { offset, timeout: 25, allowed_updates: ["message"] },
184
+ signal,
185
+ );
186
+ if (!Array.isArray(updates)) throw new TelegramApiError(400);
187
+ retryMs = 1_000;
188
+ for (const update of updates) {
189
+ if (signal.aborted) break;
190
+ if (
191
+ !isRecord(update) ||
192
+ !isInteger(update.update_id) ||
193
+ update.update_id < 0 ||
194
+ update.update_id === Number.MAX_SAFE_INTEGER
195
+ ) {
196
+ throw new TelegramApiError(400);
197
+ }
198
+ if (update.update_id < offset) continue;
199
+ // Acknowledge even ignored/failed callbacks: replaying can duplicate replies.
200
+ offset = update.update_id + 1;
201
+ const message = parseMessage(update.message, identity);
202
+ if (!message) continue;
203
+ const processing = Promise.resolve()
204
+ .then(() => onMessage(message))
205
+ .catch(() => {
206
+ logger.warn("Telegram message handler failed");
207
+ });
208
+ await Promise.race([processing, stopped.promise]);
209
+ }
210
+ } catch (error) {
211
+ if (signal.aborted) break;
212
+ if (isPermanent(error)) throw new Error("Telegram authentication or polling configuration failed");
213
+ const delay =
214
+ error instanceof TelegramApiError || error instanceof MessengerHttpError
215
+ ? Math.max(retryMs, error.retryAfterMs ?? 0)
216
+ : retryMs;
217
+ await waitForMessengerRetry(delay, signal);
218
+ retryMs = Math.min(retryMs * 2, 30_000);
219
+ }
220
+ }
221
+ } finally {
222
+ signal.removeEventListener("abort", onAbort);
223
+ running = false;
224
+ }
225
+ },
226
+ async send(message, text, signal) {
227
+ const threadId = message.threadId === undefined ? undefined : Number(message.threadId);
228
+ if (threadId !== undefined && !isPositiveId(threadId)) throw new Error("Telegram topic identifier is invalid");
229
+ for (const chunk of splitMessengerText(text, 4_096)) {
230
+ // Never retry an ambiguous send failure: Telegram may already have delivered it.
231
+ const result = await request(
232
+ "sendMessage",
233
+ {
234
+ chat_id: message.channelId,
235
+ text: chunk,
236
+ ...(threadId === undefined ? {} : { message_thread_id: threadId }),
237
+ },
238
+ signal,
239
+ );
240
+ if (!isRecord(result) || !isPositiveId(result.message_id)) throw new TelegramApiError(400);
241
+ }
242
+ },
243
+ };
244
+ }
@@ -0,0 +1,47 @@
1
+ export type MessengerPlatform = "discord" | "telegram" | "slack";
2
+
3
+ export interface MessengerMessage {
4
+ platform: MessengerPlatform;
5
+ accountId: string;
6
+ channelId: string;
7
+ senderId: string;
8
+ messageId: string;
9
+ text: string;
10
+ threadId?: string;
11
+ direct: boolean;
12
+ mentioned: boolean;
13
+ }
14
+
15
+ export interface MessengerAdapter {
16
+ platform: MessengerPlatform;
17
+ start(onMessage: (message: MessengerMessage) => Promise<void>, signal: AbortSignal): Promise<void>;
18
+ send(message: MessengerMessage, text: string, signal?: AbortSignal): Promise<void>;
19
+ }
20
+
21
+ export interface MessengerPlatformConfig {
22
+ tokenEnv: string;
23
+ appTokenEnv?: string;
24
+ allowedUserIds: string[];
25
+ allowedChannelIds?: string[];
26
+ }
27
+
28
+ export interface MessengerConfig {
29
+ cwd: string;
30
+ sessionDir: string;
31
+ toolNames: string[];
32
+ platforms: Partial<Record<MessengerPlatform, MessengerPlatformConfig>>;
33
+ }
34
+
35
+ export interface MessengerTransportDeps {
36
+ fetch?: typeof fetch;
37
+ WebSocket?: typeof WebSocket;
38
+ }
39
+
40
+ export type MessengerReply = (message: MessengerMessage, signal: AbortSignal) => Promise<string>;
41
+ /** A partially initialized SDK must not be retried inside a live gateway. */
42
+ export class MessengerFatalError extends Error {
43
+ constructor() {
44
+ super("Messenger session initialization failed; restart required");
45
+ this.name = "MessengerFatalError";
46
+ }
47
+ }