jeo-code 0.7.48 → 0.7.50
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/CHANGELOG.md +37 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/config-schema.ts +15 -1
- package/src/agent/notify/AGENTS.md +30 -10
- package/src/agent/notify/config-commands.ts +50 -0
- package/src/agent/notify/paths.ts +7 -0
- package/src/agent/notify/rate-limit-pool.ts +217 -0
- package/src/agent/notify/session-endpoint.ts +167 -29
- package/src/agent/notify/telegram-api.ts +71 -0
- package/src/agent/notify/telegram-daemon.ts +371 -38
- package/src/agent/notify/telegram-html.ts +414 -0
- package/src/agent/notify/topic-registry.ts +136 -0
- package/src/agent/prompt-router.ts +18 -0
- package/src/agent/state.ts +7 -0
- package/src/agent/task-tool.ts +6 -2
- package/src/commands/doctor.ts +23 -0
- package/src/commands/launch.ts +154 -7
|
@@ -1,24 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Per-SESSION loopback WebSocket endpoint (gjc per-session-thread parity —
|
|
3
|
+
* see the Tier 2 contract in CHANGELOG history). One endpoint lives for the
|
|
4
|
+
* whole interactive REPL lifetime (created once `sessionId` is established,
|
|
5
|
+
* stopped at process exit), multiplexing TWO frame families over the SAME
|
|
6
|
+
* connection:
|
|
6
7
|
*
|
|
8
|
+
* - subagent visibility/control (unchanged since 0.7.34): `snapshot` on
|
|
9
|
+
* connect + on change, `steer`/`cancel`/`list` requests — applied to
|
|
10
|
+
* whichever `SubagentRegistry` is currently ATTACHED (see below);
|
|
11
|
+
* - main-session mirroring (new): `identity_header` (once, at start),
|
|
12
|
+
* `context_update` (turn start/end), `turn_stream` (finalized reply) sent
|
|
13
|
+
* OUT, and `user_message`/`config_command` received IN via callbacks the
|
|
14
|
+
* caller registers.
|
|
15
|
+
*
|
|
16
|
+
* `SubagentRegistry` instances are themselves per-turn (a fresh one every
|
|
17
|
+
* `runTurn` call, see launch.ts), so this endpoint does NOT own one at
|
|
18
|
+
* construction — `attachRegistry`/`detachRegistry` swap the live registry in
|
|
19
|
+
* and out at turn boundaries. Between turns (or before the first turn),
|
|
20
|
+
* `snapshot`/`list` report an empty subagent list and `steer`/`cancel` ack
|
|
21
|
+
* `ok:false` — there is no registry to apply them to, not an error.
|
|
22
|
+
*
|
|
23
|
+
* The endpoint:
|
|
7
24
|
* - binds a random free port on 127.0.0.1 only (never exposed off-box; the
|
|
8
25
|
* ONLY internet-facing hop is the separate `notify-daemon-run` process
|
|
9
26
|
* relaying through Telegram — see `telegram-daemon.ts`);
|
|
10
|
-
* - publishes a discovery file (`notifySessionEndpointPath`)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
27
|
+
* - publishes a discovery file (`notifySessionEndpointPath`) keyed by jeo's
|
|
28
|
+
* REAL session id (not a random one) so a Telegram forum topic, once
|
|
29
|
+
* created for a session, is found and reused across `--resume` instead of
|
|
30
|
+
* minting a duplicate topic every relaunch;
|
|
31
|
+
* - pushes a `snapshot` frame on connect and whenever the attached
|
|
32
|
+
* registry's subagent list changes (cheap poll — bounded by the small
|
|
33
|
+
* list size, only runs while a client is connected).
|
|
17
34
|
*
|
|
18
|
-
* Lifecycle
|
|
19
|
-
* `notifications.enabled` is set in the global config
|
|
20
|
-
*
|
|
21
|
-
*
|
|
35
|
+
* Lifecycle: `startSessionNotifyEndpoint` is a no-op unless
|
|
36
|
+
* `notifications.enabled` is set in the global config. A context lacking a
|
|
37
|
+
* real jeo session (e.g. a bare `task-tool.ts` detached run outside any
|
|
38
|
+
* interactive REPL) falls back to the OLD lazy, per-registry, random-id
|
|
39
|
+
* behavior via `ensureSessionNotifyEndpoint` — but when an interactive
|
|
40
|
+
* session's endpoint is already running in this process, that same path
|
|
41
|
+
* ATTACHES to it instead of creating a second, competing endpoint.
|
|
22
42
|
*/
|
|
23
43
|
import * as fs from "node:fs/promises";
|
|
24
44
|
import type { Server, ServerWebSocket } from "bun";
|
|
@@ -28,25 +48,74 @@ import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
|
|
|
28
48
|
|
|
29
49
|
const SNAPSHOT_POLL_MS = 1_200;
|
|
30
50
|
|
|
51
|
+
/** Session identity, pushed once as an `identity_header` frame right after
|
|
52
|
+
* `start()` — lets the daemon name/rename the session's forum topic. */
|
|
53
|
+
export interface SessionIdentity {
|
|
54
|
+
repo: string;
|
|
55
|
+
branch?: string;
|
|
56
|
+
cwd: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A free-text reply arriving in the session's topic, already resolved to
|
|
60
|
+
* local temp-file paths for any attached photos/documents (the daemon owns
|
|
61
|
+
* the download; this endpoint only relays paths onward). */
|
|
62
|
+
export interface RemoteUserMessage {
|
|
63
|
+
text: string;
|
|
64
|
+
imagePaths?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A parsed `/verbose`, `/lean`, `/verbosity`, or `/redact` in-thread command. */
|
|
68
|
+
export interface RemoteConfigCommand {
|
|
69
|
+
verbosity?: "lean" | "verbose";
|
|
70
|
+
redact?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
31
73
|
export class SessionNotifyEndpoint {
|
|
32
74
|
private server: Server<undefined> | undefined;
|
|
33
75
|
|
|
34
76
|
private readonly sockets = new Set<ServerWebSocket<undefined>>();
|
|
35
77
|
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
36
78
|
private lastSnapshotJson = "";
|
|
79
|
+
/** `undefined` between turns — subagent requests report an empty registry. */
|
|
80
|
+
private registry: SubagentRegistry | undefined;
|
|
37
81
|
readonly sessionId: string;
|
|
38
82
|
private readonly token: string;
|
|
39
83
|
|
|
84
|
+
/** Registered by the caller (launch.ts) to route inbound frames; both are
|
|
85
|
+
* no-ops (frame silently dropped) until wired — matches the existing
|
|
86
|
+
* best-effort posture of this whole module. */
|
|
87
|
+
onUserMessage: ((msg: RemoteUserMessage) => void) | undefined;
|
|
88
|
+
onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
|
|
89
|
+
|
|
40
90
|
constructor(
|
|
41
|
-
private readonly registry: SubagentRegistry,
|
|
42
91
|
private readonly cwd: string,
|
|
92
|
+
/** jeo's real, persistent session id when one exists (so a resumed
|
|
93
|
+
* session reconnects to the SAME discovery file / Telegram topic);
|
|
94
|
+
* falls back to a random id for session-less contexts. */
|
|
95
|
+
sessionId: string = crypto.randomUUID(),
|
|
43
96
|
) {
|
|
44
|
-
this.sessionId =
|
|
97
|
+
this.sessionId = sessionId;
|
|
45
98
|
this.token = crypto.randomUUID();
|
|
46
99
|
}
|
|
47
100
|
|
|
101
|
+
/** Attach the registry backing THIS turn's subagents; `list`/`snapshot`
|
|
102
|
+
* immediately reflect it. Call once per `runTurn`. */
|
|
103
|
+
attachRegistry(registry: SubagentRegistry): void {
|
|
104
|
+
this.registry = registry;
|
|
105
|
+
this.lastSnapshotJson = ""; // force a fresh push even if shapes coincide
|
|
106
|
+
this.broadcastIfChanged();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Detach at turn end — a completed turn's registry must never keep
|
|
110
|
+
* receiving `steer`/`cancel` after `cancelAll()` already ran on it. */
|
|
111
|
+
detachRegistry(): void {
|
|
112
|
+
this.registry = undefined;
|
|
113
|
+
this.lastSnapshotJson = "";
|
|
114
|
+
this.broadcastIfChanged();
|
|
115
|
+
}
|
|
116
|
+
|
|
48
117
|
private snapshot(): { type: "snapshot"; sessionId: string; cwd: string; pid: number; subagents: SubagentRecord[] } {
|
|
49
|
-
return { type: "snapshot", sessionId: this.sessionId, cwd: this.cwd, pid: process.pid, subagents: this.registry
|
|
118
|
+
return { type: "snapshot", sessionId: this.sessionId, cwd: this.cwd, pid: process.pid, subagents: this.registry?.list() ?? [] };
|
|
50
119
|
}
|
|
51
120
|
|
|
52
121
|
private broadcastIfChanged(): void {
|
|
@@ -55,6 +124,11 @@ export class SessionNotifyEndpoint {
|
|
|
55
124
|
const json = JSON.stringify(snap);
|
|
56
125
|
if (json === this.lastSnapshotJson) return;
|
|
57
126
|
this.lastSnapshotJson = json;
|
|
127
|
+
this.broadcast(json);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Send a pre-serialized frame to every connected socket, best-effort. */
|
|
131
|
+
private broadcast(json: string): void {
|
|
58
132
|
for (const ws of this.sockets) {
|
|
59
133
|
try {
|
|
60
134
|
ws.send(json);
|
|
@@ -62,6 +136,27 @@ export class SessionNotifyEndpoint {
|
|
|
62
136
|
}
|
|
63
137
|
}
|
|
64
138
|
|
|
139
|
+
/** Push the one-time identity header. Safe to call even with no socket
|
|
140
|
+
* connected yet — silently dropped, matching every other push here (the
|
|
141
|
+
* daemon reconnects and gets a fresh `snapshot`, but identity is a single
|
|
142
|
+
* point-in-time fact with no polling fallback, so a session started before
|
|
143
|
+
* the daemon connects will not retroactively announce itself; acceptable
|
|
144
|
+
* since the common case is the daemon already scanning when a session
|
|
145
|
+
* starts). */
|
|
146
|
+
sendIdentity(identity: SessionIdentity): void {
|
|
147
|
+
this.broadcast(JSON.stringify({ type: "identity_header", sessionId: this.sessionId, ...identity }));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Push a turn-boundary summary (`phase: "turn_start" | "turn_end"`). */
|
|
151
|
+
sendContextUpdate(phase: "turn_start" | "turn_end", summary: string, extra?: { model?: string; usage?: string }): void {
|
|
152
|
+
this.broadcast(JSON.stringify({ type: "context_update", sessionId: this.sessionId, phase, summary, ...extra }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Push the finalized reply text for a completed turn. */
|
|
156
|
+
sendTurnStream(text: string): void {
|
|
157
|
+
this.broadcast(JSON.stringify({ type: "turn_stream", sessionId: this.sessionId, phase: "finalized", text }));
|
|
158
|
+
}
|
|
159
|
+
|
|
65
160
|
private handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): void {
|
|
66
161
|
let msg: Record<string, unknown>;
|
|
67
162
|
try {
|
|
@@ -78,7 +173,7 @@ export class SessionNotifyEndpoint {
|
|
|
78
173
|
if (msg.type === "steer") {
|
|
79
174
|
const id = String(msg.id ?? "");
|
|
80
175
|
const message = String(msg.message ?? "");
|
|
81
|
-
const ok = id && message ? this.registry
|
|
176
|
+
const ok = id && message ? (this.registry?.steer(id, message) ?? false) : false;
|
|
82
177
|
try {
|
|
83
178
|
ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
|
|
84
179
|
} catch {}
|
|
@@ -86,13 +181,24 @@ export class SessionNotifyEndpoint {
|
|
|
86
181
|
}
|
|
87
182
|
if (msg.type === "cancel") {
|
|
88
183
|
const ids = Array.isArray(msg.ids) ? msg.ids.filter((x): x is string => typeof x === "string") : [];
|
|
89
|
-
const cancelled = ids.length > 0 ? this.registry
|
|
184
|
+
const cancelled = ids.length > 0 ? (this.registry?.cancel(ids) ?? []) : [];
|
|
90
185
|
try {
|
|
91
186
|
ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: cancelled.length > 0 }));
|
|
92
187
|
ws.send(JSON.stringify(this.snapshot()));
|
|
93
188
|
} catch {}
|
|
94
189
|
return;
|
|
95
190
|
}
|
|
191
|
+
if (msg.type === "user_message" && typeof msg.text === "string") {
|
|
192
|
+
const imagePaths = Array.isArray(msg.imagePaths) ? msg.imagePaths.filter((x): x is string => typeof x === "string") : undefined;
|
|
193
|
+
this.onUserMessage?.({ text: msg.text, imagePaths });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (msg.type === "config_command") {
|
|
197
|
+
const verbosity = msg.verbosity === "lean" || msg.verbosity === "verbose" ? msg.verbosity : undefined;
|
|
198
|
+
const redact = typeof msg.redact === "boolean" ? msg.redact : undefined;
|
|
199
|
+
if (verbosity !== undefined || redact !== undefined) this.onConfigCommand?.({ verbosity, redact });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
96
202
|
}
|
|
97
203
|
|
|
98
204
|
/** Bind the server, start the change-poll, and publish the discovery file.
|
|
@@ -154,23 +260,50 @@ export class SessionNotifyEndpoint {
|
|
|
154
260
|
}
|
|
155
261
|
}
|
|
156
262
|
|
|
157
|
-
// ──
|
|
263
|
+
// ── Session-scoped lifecycle (launch.ts's interactive REPL) ─────────────────
|
|
264
|
+
|
|
265
|
+
/** Best-effort, fire-and-forget: creates + starts a session-scoped endpoint IF
|
|
266
|
+
* `notifications.enabled`, otherwise returns `undefined` (cheap no-op — the
|
|
267
|
+
* common case). Never throws. Call once, right after `sessionId` is
|
|
268
|
+
* established; keep the result for the REPL's lifetime (`attachRegistry`
|
|
269
|
+
* per turn, `stop()` at exit). */
|
|
270
|
+
export async function startSessionNotifyEndpoint(cwd: string, sessionId: string | undefined): Promise<SessionNotifyEndpoint | undefined> {
|
|
271
|
+
try {
|
|
272
|
+
const config = await readGlobalConfig();
|
|
273
|
+
if (!config.notifications?.enabled) return undefined;
|
|
274
|
+
const endpoint = new SessionNotifyEndpoint(cwd, sessionId);
|
|
275
|
+
await endpoint.start();
|
|
276
|
+
return endpoint;
|
|
277
|
+
} catch {
|
|
278
|
+
return undefined; // best-effort: a bind/config failure must never break the session
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Lazy per-registry fallback (task-tool.ts's detached path OUTSIDE any
|
|
283
|
+
// interactive session — e.g. a bare `jeo run`/non-REPL invocation with no
|
|
284
|
+
// session-scoped endpoint to attach to) ───────────────────────────────────
|
|
158
285
|
const endpointsByRegistry = new WeakMap<SubagentRegistry, SessionNotifyEndpoint>();
|
|
159
286
|
const startingRegistries = new WeakSet<SubagentRegistry>();
|
|
160
287
|
|
|
161
|
-
/** Best-effort, fire-and-forget:
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
288
|
+
/** Best-effort, fire-and-forget: attaches `registry` to `sessionEndpoint` when
|
|
289
|
+
* given (the common interactive-session case — no new endpoint, no discovery
|
|
290
|
+
* file, just a registry swap), otherwise falls back to the OLD lazy
|
|
291
|
+
* per-registry endpoint (own random session id, own discovery file) IF
|
|
292
|
+
* `notifications.enabled`. Never throws and never blocks the caller. */
|
|
293
|
+
export function ensureSessionNotifyEndpoint(registry: SubagentRegistry, cwd: string, sessionEndpoint?: SessionNotifyEndpoint): void {
|
|
294
|
+
if (sessionEndpoint) {
|
|
295
|
+
sessionEndpoint.attachRegistry(registry);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
166
298
|
if (endpointsByRegistry.has(registry) || startingRegistries.has(registry)) return;
|
|
167
299
|
startingRegistries.add(registry);
|
|
168
300
|
void (async () => {
|
|
169
301
|
try {
|
|
170
302
|
const config = await readGlobalConfig();
|
|
171
303
|
if (!config.notifications?.enabled) return;
|
|
172
|
-
const endpoint = new SessionNotifyEndpoint(
|
|
304
|
+
const endpoint = new SessionNotifyEndpoint(cwd);
|
|
173
305
|
await endpoint.start();
|
|
306
|
+
endpoint.attachRegistry(registry);
|
|
174
307
|
endpointsByRegistry.set(registry, endpoint);
|
|
175
308
|
} catch {
|
|
176
309
|
// best-effort: a bind/config failure must never break the parent turn.
|
|
@@ -180,11 +313,16 @@ export function ensureSessionNotifyEndpoint(registry: SubagentRegistry, cwd: str
|
|
|
180
313
|
})();
|
|
181
314
|
}
|
|
182
315
|
|
|
183
|
-
/** Turn-boundary teardown
|
|
184
|
-
* was ever started for this registry (the
|
|
316
|
+
/** Turn-boundary teardown for the FALLBACK path only: detaches + stops the
|
|
317
|
+
* lazily-created endpoint, if one was ever started for this registry (the
|
|
318
|
+
* common case — either notifications are off, or an interactive session's
|
|
319
|
+
* endpoint was attached instead and owns its own `stop()` at process exit).
|
|
320
|
+
* A no-op when `registry` was attached to a `sessionEndpoint` above — that
|
|
321
|
+
* endpoint outlives the turn and the caller detaches it separately. */
|
|
185
322
|
export async function stopSessionNotifyEndpoint(registry: SubagentRegistry): Promise<void> {
|
|
186
323
|
const endpoint = endpointsByRegistry.get(registry);
|
|
187
324
|
if (!endpoint) return;
|
|
188
325
|
endpointsByRegistry.delete(registry);
|
|
189
326
|
await endpoint.stop();
|
|
190
327
|
}
|
|
328
|
+
|
|
@@ -49,6 +49,14 @@ export interface TelegramUpdate {
|
|
|
49
49
|
/** Forum-topic thread this message belongs to (supergroups with topics on). */
|
|
50
50
|
message_thread_id?: number;
|
|
51
51
|
is_topic_message?: boolean;
|
|
52
|
+
/** One entry per generated size (largest last); the daemon downloads the
|
|
53
|
+
* largest for inbound relay. Absent unless the message attached a photo. */
|
|
54
|
+
photo?: { file_id: string; width: number; height: number }[];
|
|
55
|
+
/** A non-photo file attachment (image sent "as file", or any document). */
|
|
56
|
+
document?: { file_id: string; file_name?: string; mime_type?: string };
|
|
57
|
+
/** Caption text on a photo/document message (Telegram never sets `text`
|
|
58
|
+
* alongside media — the caption is the media message's own text field). */
|
|
59
|
+
caption?: string;
|
|
52
60
|
};
|
|
53
61
|
callback_query?: TelegramCallbackQuery;
|
|
54
62
|
}
|
|
@@ -75,6 +83,8 @@ export interface SendMessageOptions {
|
|
|
75
83
|
messageThreadId?: number;
|
|
76
84
|
replyMarkup?: InlineKeyboardMarkup;
|
|
77
85
|
disableWebPagePreview?: boolean;
|
|
86
|
+
/** `"HTML"` to enable Telegram's bounded HTML tag subset (see `telegram-html.ts`). */
|
|
87
|
+
parseMode?: "HTML";
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
export interface SendPhotoOptions {
|
|
@@ -126,6 +136,7 @@ export class TelegramApi {
|
|
|
126
136
|
};
|
|
127
137
|
if (options.messageThreadId !== undefined) body.message_thread_id = options.messageThreadId;
|
|
128
138
|
if (options.replyMarkup) body.reply_markup = options.replyMarkup;
|
|
139
|
+
if (options.parseMode) body.parse_mode = options.parseMode;
|
|
129
140
|
return this.postJson<TelegramSendResult>("sendMessage", body);
|
|
130
141
|
}
|
|
131
142
|
|
|
@@ -162,6 +173,66 @@ export class TelegramApi {
|
|
|
162
173
|
const res = await this.fetchImpl(`${this.url("getUpdates")}?${params.toString()}`);
|
|
163
174
|
return (await res.json()) as TelegramGetUpdatesResult;
|
|
164
175
|
}
|
|
176
|
+
|
|
177
|
+
/** Create a forum topic in a chat with Threaded Mode enabled (gjc per-session topic parity). */
|
|
178
|
+
async createForumTopic(chatId: string | number, name: string): Promise<{ ok: boolean; result?: { message_thread_id: number } }> {
|
|
179
|
+
return this.postJson<{ ok: boolean; result?: { message_thread_id: number } }>("createForumTopic", {
|
|
180
|
+
chat_id: chatId,
|
|
181
|
+
name,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Rename an existing forum topic, e.g. once a session's real title is known (gjc identity-rename parity). */
|
|
186
|
+
async editForumTopic(chatId: string | number, messageThreadId: number, name: string): Promise<TelegramSendResult> {
|
|
187
|
+
return this.postJson<TelegramSendResult>("editForumTopic", {
|
|
188
|
+
chat_id: chatId,
|
|
189
|
+
message_thread_id: messageThreadId,
|
|
190
|
+
name,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolve a `file_id` to a short-lived `file_path` for use with {@link downloadFile}. */
|
|
195
|
+
async getFile(fileId: string): Promise<{ ok: boolean; result?: { file_path?: string } }> {
|
|
196
|
+
return this.postJson<{ ok: boolean; result?: { file_path?: string } }>("getFile", { file_id: fileId });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Download a file resolved via {@link getFile}. `filePath` is untrusted
|
|
201
|
+
* remote metadata from a Bot API response, not something jeo controls —
|
|
202
|
+
* reject any `..`, leading `/`, or `\` segment before building the URL
|
|
203
|
+
* (guards against path-traversal/SSRF via a malicious `file_path`) and
|
|
204
|
+
* percent-encode each path segment. Best-effort: returns `undefined` on
|
|
205
|
+
* rejection, fetch failure, or a non-ok response instead of throwing,
|
|
206
|
+
* since this is new network I/O every caller must treat as fallible
|
|
207
|
+
* (gjc `downloadTelegramFile` parity, minus the retry logic gjc layers on
|
|
208
|
+
* top at the daemon level).
|
|
209
|
+
*/
|
|
210
|
+
async downloadFile(filePath: string): Promise<Uint8Array | undefined> {
|
|
211
|
+
if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) return undefined;
|
|
212
|
+
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
213
|
+
const url = `https://api.telegram.org/file/bot${this.token}/${encodedPath}`;
|
|
214
|
+
try {
|
|
215
|
+
const res = await this.fetchImpl(url);
|
|
216
|
+
if (!res.ok) return undefined;
|
|
217
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Look up a chat's type; callers gate per-session topic creation on `result.type === "private"` (gjc `pairedChatIsPrivate` fail-closed parity). */
|
|
224
|
+
async getChat(chatId: string | number): Promise<{ ok: boolean; result?: { type?: string } }> {
|
|
225
|
+
return this.postJson<{ ok: boolean; result?: { type?: string } }>("getChat", { chat_id: chatId });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Attach an emoji reaction to a message (gjc queued/consumed delivery double-check parity). */
|
|
229
|
+
async setMessageReaction(chatId: string | number, messageId: number, emoji: string): Promise<TelegramSendResult> {
|
|
230
|
+
return this.postJson<TelegramSendResult>("setMessageReaction", {
|
|
231
|
+
chat_id: chatId,
|
|
232
|
+
message_id: messageId,
|
|
233
|
+
reaction: [{ type: "emoji", emoji }],
|
|
234
|
+
});
|
|
235
|
+
}
|
|
165
236
|
}
|
|
166
237
|
|
|
167
238
|
/** Mask a secret token for safe display: first 4 chars + length, never the rest
|