jeopi-cli 16.5.6 → 16.6.2

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
+ }
@@ -1131,7 +1131,7 @@ export class SelectorController {
1131
1131
  const manualInput = this.ctx.oauthManualInput;
1132
1132
  const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
1133
1133
  try {
1134
- await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
1134
+ await this.ctx.session.modelRegistry.login(providerId as OAuthProvider, {
1135
1135
  onAuth: (info: { url: string; instructions?: string }) => {
1136
1136
  const block = new TranscriptBlock();
1137
1137
  block.addChild(new Text(theme.fg("dim", info.url), 1, 0));
@@ -1175,7 +1175,6 @@ export class SelectorController {
1175
1175
  },
1176
1176
  onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
1177
1177
  });
1178
- await this.ctx.session.modelRegistry.refresh();
1179
1178
  const block = new TranscriptBlock();
1180
1179
  block.addChild(
1181
1180
  new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
@@ -1289,7 +1289,7 @@ export async function runRpcMode(
1289
1289
  // We use this ordering to self-classify at runtime — no static allowlist.
1290
1290
  let authEmitted = false;
1291
1291
  try {
1292
- await session.modelRegistry.authStorage.login(command.providerId, {
1292
+ await session.modelRegistry.login(command.providerId, {
1293
1293
  onAuth: info => {
1294
1294
  authEmitted = true;
1295
1295
  output({
@@ -1321,7 +1321,6 @@ export async function runRpcMode(
1321
1321
  return new Promise<string>(() => {});
1322
1322
  },
1323
1323
  });
1324
- await session.modelRegistry.refresh();
1325
1324
  return success(id, "login", { providerId: command.providerId });
1326
1325
  } catch (err: unknown) {
1327
1326
  return error(id, "login", err instanceof Error ? err.message : String(err));
@@ -179,7 +179,7 @@ export class SignInTab implements SetupTab {
179
179
  this.host.restoreFocus();
180
180
  this.host.requestRender();
181
181
  try {
182
- await this.#authStorage.login(providerId as OAuthProvider, {
182
+ await this.host.ctx.session.modelRegistry.login(providerId as OAuthProvider, {
183
183
  signal: this.#loginAbort.signal,
184
184
  onAuth: info => {
185
185
  this.#authUrl = info.url;
@@ -202,7 +202,6 @@ export class SignInTab implements SetupTab {
202
202
  onManualCodeInput: () =>
203
203
  this.#showPrompt({ message: "Paste the authorization code (or full redirect URL):" }),
204
204
  });
205
- await this.host.ctx.session.modelRegistry.refresh();
206
205
  if (this.#disposed) return;
207
206
  this.#statusLines = [
208
207
  theme.fg("success", `${theme.status.success} Signed in to ${providerId}`),