pi-braid-telegram 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +204 -2
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +60 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +809 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram inline-keyboard pause/resume control.
|
|
3
|
+
*
|
|
4
|
+
* The for a button to control the agent through Telegram
|
|
5
|
+
* ("Seems need a button for pause or whatever control this through tg").
|
|
6
|
+
* This module adds:
|
|
7
|
+
* - an inline keyboard (reply_markup) with ⏸ Pause / ▶ Resume / 📊 Status
|
|
8
|
+
* buttons (callback_data: "pause" | "resume" | "status");
|
|
9
|
+
* - callback_query handling: pause → paused=true, resume → paused=false,
|
|
10
|
+
* status → reply the current state; every tap is answered via
|
|
11
|
+
* answerCallbackQuery and the keyboard is updated in place via
|
|
12
|
+
* editMessageText (paused shows ▶ Resume, running shows ⏸ Pause);
|
|
13
|
+
* - a persisted pause state file (default
|
|
14
|
+
* `<braidDir>/telegram/pause-state.json`, atomic write-temp-rename) that
|
|
15
|
+
* survives restarts; the pre-move `~/.pi/agent/telegram-pause-state.json`
|
|
16
|
+
* stays readable until the first post-move write replaces it (reads prefer
|
|
17
|
+
* the braid-root file, writes only ever land there);
|
|
18
|
+
* - `getPauseState()` — the exported read seam for the CONSUMER (the
|
|
19
|
+
* orchestrator decides how to honor the flag; this module never blocks the
|
|
20
|
+
* poller or the core loop).
|
|
21
|
+
*
|
|
22
|
+
* Design: the pause flag is a signal for the consumer, not a gate here. The
|
|
23
|
+
* poller keeps polling and delivering; pause/resume/status callbacks are
|
|
24
|
+
* handled entirely within this module (answer + edit) and never a turn.
|
|
25
|
+
*
|
|
26
|
+
* Config follows the pi "settings" philosophy (packages/core braid-settings):
|
|
27
|
+
* the state-file path defaults to the fixed path above and can be overridden
|
|
28
|
+
* via `braid.telegram.pauseStateFile` in `~/.pi/agent/settings.json` — no
|
|
29
|
+
* process.env keys are introduced.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
|
33
|
+
import { dirname, join } from "node:path";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
36
|
+
import {
|
|
37
|
+
defaultPauseStateFile,
|
|
38
|
+
legacyPauseStateFile,
|
|
39
|
+
warnUnderivableRoot,
|
|
40
|
+
} from "./state-paths.js";
|
|
41
|
+
import type { TelegramClient, TelegramReplyMarkup } from "./telegram-client.js";
|
|
42
|
+
|
|
43
|
+
export { defaultPauseStateFile, legacyPauseStateFile };
|
|
44
|
+
|
|
45
|
+
// Callback actions (button callback_data)
|
|
46
|
+
|
|
47
|
+
export const CALLBACK_PAUSE = "pause";
|
|
48
|
+
export const CALLBACK_RESUME = "resume";
|
|
49
|
+
export const CALLBACK_STATUS = "status";
|
|
50
|
+
|
|
51
|
+
/** Control-message body shown while the agent is running. */
|
|
52
|
+
export const CONTROL_RUNNING_TEXT =
|
|
53
|
+
"▶ Agent is running — inbound flows normally.";
|
|
54
|
+
/** Control-message body shown while the agent is paused. */
|
|
55
|
+
export const CONTROL_PAUSED_TEXT = "⏸ Agent is paused — inbound will be held.";
|
|
56
|
+
|
|
57
|
+
export interface PauseState {
|
|
58
|
+
paused: boolean;
|
|
59
|
+
/** Epoch ms of the last pause/resume transition (0 when never changed). */
|
|
60
|
+
updatedAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** settings.json (pi-native) — braid.telegram block, mirrors braid-settings.ts. */
|
|
64
|
+
const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
|
|
65
|
+
|
|
66
|
+
interface BraidSettings {
|
|
67
|
+
braid?: {
|
|
68
|
+
telegram?: {
|
|
69
|
+
/** Override for the pause-state file path (default: defaultPauseStateFile()). */
|
|
70
|
+
pauseStateFile?: string;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The `braid.telegram.pauseStateFile` override, or undefined. Never throws. */
|
|
76
|
+
function configuredPauseStateFile(): string | undefined {
|
|
77
|
+
try {
|
|
78
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
79
|
+
const parsed = JSON.parse(
|
|
80
|
+
readFileSync(SETTINGS_PATH, "utf8"),
|
|
81
|
+
) as BraidSettings;
|
|
82
|
+
const override = parsed?.braid?.telegram?.pauseStateFile;
|
|
83
|
+
if (typeof override === "string" && override.trim())
|
|
84
|
+
return override.trim();
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// non-fatal — fall through to the default
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write resolution: `braid.telegram.pauseStateFile` when configured, else the
|
|
94
|
+
* braid-root default. Every write goes here — the pre-move agent-dir file is
|
|
95
|
+
* read-only history. Null when no absolute root resolves: the caller refuses
|
|
96
|
+
* to persist instead of writing beside the cwd.
|
|
97
|
+
*/
|
|
98
|
+
export function resolvePauseStateFile(): string | null {
|
|
99
|
+
return configuredPauseStateFile() ?? defaultPauseStateFile();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read resolution: the configured override, else the braid-root file when it
|
|
104
|
+
* exists, else the pre-move agent-dir file when that exists (so a pause set
|
|
105
|
+
* before the relocation keeps holding), else the braid-root path (a miss the
|
|
106
|
+
* caller treats as "not paused"). Null when no absolute root resolves.
|
|
107
|
+
*/
|
|
108
|
+
export function resolvePauseStateReadFile(): string | null {
|
|
109
|
+
const configured = configuredPauseStateFile();
|
|
110
|
+
if (configured) return configured;
|
|
111
|
+
const current = defaultPauseStateFile();
|
|
112
|
+
if (current !== null && existsSync(current)) return current;
|
|
113
|
+
const legacy = legacyPauseStateFile();
|
|
114
|
+
if (legacy !== null && existsSync(legacy)) return legacy;
|
|
115
|
+
return current;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read the pause state; a missing/corrupt file means "not paused" (never throws). */
|
|
119
|
+
export async function loadPauseState(
|
|
120
|
+
stateFile: string | null,
|
|
121
|
+
): Promise<PauseState> {
|
|
122
|
+
if (stateFile === null) return { paused: false, updatedAt: 0 };
|
|
123
|
+
try {
|
|
124
|
+
const raw = await readFile(stateFile, "utf8");
|
|
125
|
+
const parsed = JSON.parse(raw) as Partial<PauseState>;
|
|
126
|
+
return {
|
|
127
|
+
paused: parsed?.paused === true,
|
|
128
|
+
updatedAt: Number(parsed?.updatedAt) || 0,
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
return { paused: false, updatedAt: 0 };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Persist the pause state ATOMICALLY (write-temp-rename): the JSON is written
|
|
137
|
+
* to `<stateFile>.tmp` first, then renamed over the target, so a crash
|
|
138
|
+
* mid-write can never leave a truncated state file behind.
|
|
139
|
+
*/
|
|
140
|
+
export async function savePauseState(
|
|
141
|
+
stateFile: string,
|
|
142
|
+
state: PauseState,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const dir = dirname(stateFile);
|
|
145
|
+
try {
|
|
146
|
+
await mkdir(dir, { recursive: true });
|
|
147
|
+
} catch {
|
|
148
|
+
// parent may already exist; write will surface real errors
|
|
149
|
+
}
|
|
150
|
+
const tmp = `${stateFile}.tmp`;
|
|
151
|
+
await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
152
|
+
await rename(tmp, stateFile);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Exported read seam for the CONSUMER (orchestrator): check the persisted
|
|
157
|
+
* pause flag before processing inbound. `stateFile` defaults to the resolved
|
|
158
|
+
* settings/default path. Never throws.
|
|
159
|
+
*/
|
|
160
|
+
export async function getPauseState(
|
|
161
|
+
stateFile: string | null = resolvePauseStateReadFile(),
|
|
162
|
+
): Promise<PauseState> {
|
|
163
|
+
return loadPauseState(stateFile);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the control keyboard for the given state: running shows ⏸ Pause +
|
|
168
|
+
* 📊 Status; paused shows ▶ Resume + 📊 Status (the action that changes the
|
|
169
|
+
* current state is always the primary button).
|
|
170
|
+
*/
|
|
171
|
+
export function buildControlKeyboard(paused: boolean): TelegramReplyMarkup {
|
|
172
|
+
return {
|
|
173
|
+
inline_keyboard: [
|
|
174
|
+
[
|
|
175
|
+
paused
|
|
176
|
+
? { text: "▶ Resume", callback_data: CALLBACK_RESUME }
|
|
177
|
+
: { text: "⏸ Pause", callback_data: CALLBACK_PAUSE },
|
|
178
|
+
{ text: "📊 Status", callback_data: CALLBACK_STATUS },
|
|
179
|
+
],
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface SendControlKeyboardResult {
|
|
185
|
+
ok: boolean;
|
|
186
|
+
messageId?: number;
|
|
187
|
+
error?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Send the control message (status line + inline keyboard) to a chat. The
|
|
192
|
+
* orchestrator/agent can call this to place the control in a chat; the
|
|
193
|
+
* keyboard is then kept in sync by handlePauseCallbackQuery's edit.
|
|
194
|
+
*/
|
|
195
|
+
export async function sendControlKeyboard(
|
|
196
|
+
client: Pick<TelegramClient, "sendMessage">,
|
|
197
|
+
token: string,
|
|
198
|
+
chatId: number | string,
|
|
199
|
+
paused: boolean,
|
|
200
|
+
): Promise<SendControlKeyboardResult> {
|
|
201
|
+
const text = paused ? CONTROL_PAUSED_TEXT : CONTROL_RUNNING_TEXT;
|
|
202
|
+
const res = await client.sendMessage(token, chatId, text, {
|
|
203
|
+
replyMarkup: buildControlKeyboard(paused),
|
|
204
|
+
});
|
|
205
|
+
return res.ok
|
|
206
|
+
? { ok: true, messageId: res.messageId }
|
|
207
|
+
: { ok: false, error: res.error };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Comparable pause-state reader for the placement helper (see PauseControlDeps.getPauseState). */
|
|
211
|
+
export type PauseStateReaderLike = () => Promise<PauseState>;
|
|
212
|
+
|
|
213
|
+
export interface PlaceControlKeyboardDeps {
|
|
214
|
+
client: Pick<TelegramClient, "sendMessage">;
|
|
215
|
+
token: string;
|
|
216
|
+
/**
|
|
217
|
+
* Pause-state reader — defaults to the (unpersisted-default) getPauseState().
|
|
218
|
+
* Injectable so the consumer can reuse its gate's reader (index.ts passes the
|
|
219
|
+
* same readPauseState the poller gate uses: placement and gate always agree).
|
|
220
|
+
*/
|
|
221
|
+
getPauseState?: PauseStateReaderLike;
|
|
222
|
+
log?: (
|
|
223
|
+
level: "debug" | "info" | "warn" | "error",
|
|
224
|
+
message: string,
|
|
225
|
+
...rest: unknown[]
|
|
226
|
+
) => void;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface PlaceControlKeyboardResult {
|
|
230
|
+
ok: boolean;
|
|
231
|
+
/**
|
|
232
|
+
* True when no usable chat id was given (owner chat not known yet — pure
|
|
233
|
+
* first-message bootstrap). NOTHING is sent; the caller retries once the
|
|
234
|
+
* first owner message captures the real chat id. Never true alongside ok:true.
|
|
235
|
+
*/
|
|
236
|
+
deferred: boolean;
|
|
237
|
+
messageId?: number;
|
|
238
|
+
error?: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Consumer-side placement helper: ONE call sends the
|
|
243
|
+
* control keyboard to `chatId`, reflecting the CURRENT persisted pause flag —
|
|
244
|
+
* running shows ⏸ Pause, paused shows ▶ Resume. This stays delivery-neutral:
|
|
245
|
+
* NOTHING self-sends on import — the CONSUMER (index.ts) calls this at the
|
|
246
|
+
* poller-start seam. Best-effort: failures are returned in the result, never
|
|
247
|
+
* thrown. When `chatId` is empty/0 the call returns `deferred: true` without
|
|
248
|
+
* sending, so a poller that started before the owner chat was discovered
|
|
249
|
+
* (bootstrap) can place once the first owner message provides a chat id.
|
|
250
|
+
*/
|
|
251
|
+
export async function placeControlKeyboard(
|
|
252
|
+
chatId: number | string | undefined,
|
|
253
|
+
deps: PlaceControlKeyboardDeps,
|
|
254
|
+
): Promise<PlaceControlKeyboardResult> {
|
|
255
|
+
const log = deps.log ?? (() => {});
|
|
256
|
+
if (chatId == null || chatId === "" || chatId === 0) {
|
|
257
|
+
log(
|
|
258
|
+
"debug",
|
|
259
|
+
"telegram: control keyboard deferred — owner chat not known yet",
|
|
260
|
+
);
|
|
261
|
+
return { ok: false, deferred: true };
|
|
262
|
+
}
|
|
263
|
+
const state = deps.getPauseState
|
|
264
|
+
? await deps.getPauseState()
|
|
265
|
+
: await getPauseState();
|
|
266
|
+
const res = await sendControlKeyboard(
|
|
267
|
+
deps.client,
|
|
268
|
+
deps.token,
|
|
269
|
+
chatId,
|
|
270
|
+
state.paused,
|
|
271
|
+
);
|
|
272
|
+
if (res.ok) {
|
|
273
|
+
log(
|
|
274
|
+
"info",
|
|
275
|
+
`telegram: control keyboard placed in chat ${chatId} (${state.paused ? "paused — ▶ Resume" : "running — ⏸ Pause"}${res.messageId != null ? `, msg ${res.messageId}` : ""})`,
|
|
276
|
+
);
|
|
277
|
+
} else {
|
|
278
|
+
log(
|
|
279
|
+
"warn",
|
|
280
|
+
`telegram: control keyboard placement failed for chat ${chatId}: ${res.error ?? "unknown"}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return { ...res, deferred: false };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The callback_query shape this handler consumes (subset of Telegram's). */
|
|
287
|
+
export interface PauseCallbackQuery {
|
|
288
|
+
id: string;
|
|
289
|
+
from?: { id: number; username?: string; first_name?: string };
|
|
290
|
+
/** The message the button is attached to (absent in inline mode). */
|
|
291
|
+
message?: { message_id: number; chat: { id: number; type?: string } };
|
|
292
|
+
data?: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface PauseControlDeps {
|
|
296
|
+
client: Pick<TelegramClient, "answerCallbackQuery" | "editMessageText">;
|
|
297
|
+
token: string;
|
|
298
|
+
/** Path to the pause-state file (persistence). Null when no absolute braid
|
|
299
|
+
* root resolves: reads degrade to "not paused", writes are refused. */
|
|
300
|
+
stateFile: string | null;
|
|
301
|
+
log?: (
|
|
302
|
+
level: "debug" | "info" | "warn" | "error",
|
|
303
|
+
message: string,
|
|
304
|
+
...rest: unknown[]
|
|
305
|
+
) => void;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface PauseCallbackOutcome {
|
|
309
|
+
ok: boolean;
|
|
310
|
+
action: "pause" | "resume" | "status" | "unknown";
|
|
311
|
+
/** State AFTER the tap (persisted truth). */
|
|
312
|
+
paused: boolean;
|
|
313
|
+
/** answerCallbackQuery succeeded. */
|
|
314
|
+
answered: boolean;
|
|
315
|
+
/** editMessageText (keyboard refresh) succeeded. */
|
|
316
|
+
edited: boolean;
|
|
317
|
+
error?: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function answerCallback(
|
|
321
|
+
deps: PauseControlDeps,
|
|
322
|
+
callbackQueryId: string,
|
|
323
|
+
text: string,
|
|
324
|
+
): Promise<boolean> {
|
|
325
|
+
const res = await deps.client.answerCallbackQuery(
|
|
326
|
+
deps.token,
|
|
327
|
+
callbackQueryId,
|
|
328
|
+
{ text },
|
|
329
|
+
);
|
|
330
|
+
return res.ok;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Handle one inline-keyboard tap: pause → persist paused=true, resume →
|
|
335
|
+
* persist paused=false, status → no change. Every tap is answered via
|
|
336
|
+
* answerCallbackQuery (toast with the current state) and — when the tap
|
|
337
|
+
* carries a message — the keyboard is updated in place via editMessageText so
|
|
338
|
+
* paused shows ▶ Resume and running shows ⏸ Pause. Idempotent: re-tapping the
|
|
339
|
+
* same action is a no-op on state (still answered + keyboard refreshed).
|
|
340
|
+
* Never throws; API failures are reported in the outcome.
|
|
341
|
+
*/
|
|
342
|
+
export async function handlePauseCallbackQuery(
|
|
343
|
+
deps: PauseControlDeps,
|
|
344
|
+
callbackQuery: PauseCallbackQuery,
|
|
345
|
+
): Promise<PauseCallbackOutcome> {
|
|
346
|
+
const log = deps.log ?? (() => {});
|
|
347
|
+
const data =
|
|
348
|
+
typeof callbackQuery.data === "string" ? callbackQuery.data.trim() : "";
|
|
349
|
+
const stateFile = deps.stateFile;
|
|
350
|
+
// Reads prefer the braid-root file but fall back to the pre-move agent-dir
|
|
351
|
+
// copy while the braid-root file does not exist yet; the writes below always
|
|
352
|
+
// target `stateFile` (the braid-root default or the configured override).
|
|
353
|
+
const readFile =
|
|
354
|
+
stateFile === resolvePauseStateFile()
|
|
355
|
+
? resolvePauseStateReadFile()
|
|
356
|
+
: stateFile;
|
|
357
|
+
const state = await loadPauseState(readFile);
|
|
358
|
+
|
|
359
|
+
// Unknown action: answer with a hint, no state change, no keyboard edit.
|
|
360
|
+
if (
|
|
361
|
+
data !== CALLBACK_PAUSE &&
|
|
362
|
+
data !== CALLBACK_RESUME &&
|
|
363
|
+
data !== CALLBACK_STATUS
|
|
364
|
+
) {
|
|
365
|
+
const answered = await answerCallback(
|
|
366
|
+
deps,
|
|
367
|
+
callbackQuery.id,
|
|
368
|
+
`Unknown action: ${data || "(empty)"}`,
|
|
369
|
+
);
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
action: "unknown",
|
|
373
|
+
paused: state.paused,
|
|
374
|
+
answered,
|
|
375
|
+
edited: false,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (data === CALLBACK_PAUSE && !state.paused) {
|
|
380
|
+
if (stateFile === null) {
|
|
381
|
+
warnUnderivableRoot("pause-state write refused");
|
|
382
|
+
} else {
|
|
383
|
+
await savePauseState(stateFile, { paused: true, updatedAt: Date.now() });
|
|
384
|
+
log(
|
|
385
|
+
"info",
|
|
386
|
+
`telegram pause: agent paused via callback (${callbackQuery.id})`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
} else if (data === CALLBACK_RESUME && state.paused) {
|
|
390
|
+
if (stateFile === null) {
|
|
391
|
+
warnUnderivableRoot("pause-state write refused");
|
|
392
|
+
} else {
|
|
393
|
+
await savePauseState(stateFile, { paused: false, updatedAt: Date.now() });
|
|
394
|
+
log(
|
|
395
|
+
"info",
|
|
396
|
+
`telegram pause: agent resumed via callback (${callbackQuery.id})`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Re-read so `paused` reflects the persisted truth (idempotent re-taps).
|
|
401
|
+
const current = await loadPauseState(readFile);
|
|
402
|
+
|
|
403
|
+
const statusText = current.paused ? "⏸ Agent paused" : "▶ Agent running";
|
|
404
|
+
const answered = await answerCallback(deps, callbackQuery.id, statusText);
|
|
405
|
+
|
|
406
|
+
let edited = false;
|
|
407
|
+
const msg = callbackQuery.message;
|
|
408
|
+
if (msg?.message_id && msg?.chat?.id != null) {
|
|
409
|
+
const res = await deps.client.editMessageText(
|
|
410
|
+
deps.token,
|
|
411
|
+
msg.chat.id,
|
|
412
|
+
msg.message_id,
|
|
413
|
+
current.paused ? CONTROL_PAUSED_TEXT : CONTROL_RUNNING_TEXT,
|
|
414
|
+
{ replyMarkup: buildControlKeyboard(current.paused) },
|
|
415
|
+
);
|
|
416
|
+
edited = res.ok;
|
|
417
|
+
if (!res.ok)
|
|
418
|
+
log(
|
|
419
|
+
"warn",
|
|
420
|
+
`telegram pause: keyboard edit failed (${res.error ?? "unknown"})`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { ok: true, action: data, paused: current.paused, answered, edited };
|
|
425
|
+
}
|
package/src/lib/photo.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* photo.ts — shared inbound photo extraction (owner photo attachments).
|
|
3
|
+
*
|
|
4
|
+
* Both inbound consumers (background poller in inbound-poller.ts and the
|
|
5
|
+
* agentic pull in telegram-inbox.ts) route accepted photo messages through
|
|
6
|
+
* these helpers: pick the largest photo variant, resolve it to a download
|
|
7
|
+
* path via getFile, fetch the bytes, save them under the media dir, and
|
|
8
|
+
* produce the agent-facing delivery text carrying the absolute path (or, on
|
|
9
|
+
* failure, the file_id + an error note — never drop the message).
|
|
10
|
+
*
|
|
11
|
+
* Pure + injectable-fetch driven, mirroring the client's test conventions:
|
|
12
|
+
* `fetchFn` defaults to globalThis.fetch, so tests substitute a recorded
|
|
13
|
+
* fixture fake with NO network.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { defaultMediaDir, warnUnderivableRoot } from "./state-paths.js";
|
|
19
|
+
import {
|
|
20
|
+
getFile,
|
|
21
|
+
downloadFile,
|
|
22
|
+
type TelegramFetch,
|
|
23
|
+
} from "./telegram-client.js";
|
|
24
|
+
|
|
25
|
+
/** One element of a Telegram `photo` array (PhotoSize). */
|
|
26
|
+
export interface TelegramPhotoSize {
|
|
27
|
+
file_id: string;
|
|
28
|
+
file_unique_id?: string;
|
|
29
|
+
width?: number;
|
|
30
|
+
height?: number;
|
|
31
|
+
file_size?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when a message carries a non-empty `photo` array (photo inbound). */
|
|
35
|
+
export function hasPhoto(message: unknown): boolean {
|
|
36
|
+
if (message == null || typeof message !== "object") return false;
|
|
37
|
+
const photo = (message as { photo?: unknown }).photo;
|
|
38
|
+
return Array.isArray(photo) && photo.length > 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pick the largest photo variant: the one with the greatest `file_size`, or
|
|
43
|
+
* (when no sizes are present) the LAST array element — Telegram orders the
|
|
44
|
+
* `photo` array by size ascending, so the last is the largest. Returns
|
|
45
|
+
* undefined when no usable variant (with a file_id) exists.
|
|
46
|
+
*/
|
|
47
|
+
export function pickPhotoVariant(
|
|
48
|
+
photo: unknown,
|
|
49
|
+
): TelegramPhotoSize | undefined {
|
|
50
|
+
if (!Array.isArray(photo) || photo.length === 0) return undefined;
|
|
51
|
+
const variants = photo.filter(
|
|
52
|
+
(v): v is TelegramPhotoSize =>
|
|
53
|
+
v != null &&
|
|
54
|
+
typeof v === "object" &&
|
|
55
|
+
typeof (v as { file_id?: unknown }).file_id === "string",
|
|
56
|
+
);
|
|
57
|
+
if (variants.length === 0) return undefined;
|
|
58
|
+
let largest = variants[variants.length - 1];
|
|
59
|
+
for (const v of variants) {
|
|
60
|
+
if (
|
|
61
|
+
typeof v.file_size === "number" &&
|
|
62
|
+
v.file_size > (largest.file_size ?? 0)
|
|
63
|
+
) {
|
|
64
|
+
largest = v;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return largest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Derive a safe short file extension from the Telegram file_path (fallback jpg). */
|
|
71
|
+
function extFromFilePath(filePath?: string): string {
|
|
72
|
+
if (!filePath) return "jpg";
|
|
73
|
+
const last = filePath.split("/").pop() ?? "";
|
|
74
|
+
const dot = last.lastIndexOf(".");
|
|
75
|
+
if (dot >= 0 && dot < last.length - 1) {
|
|
76
|
+
const ext = last.slice(dot + 1).toLowerCase();
|
|
77
|
+
if (/^[a-z0-9]{1,8}$/.test(ext)) return ext;
|
|
78
|
+
}
|
|
79
|
+
return "jpg"; // Telegram photos are JPEG
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Outcome of materializePhoto: saved path, or a degraded file_id + error. */
|
|
83
|
+
export type MaterializePhotoResult =
|
|
84
|
+
| { ok: true; fileId: string; filePath: string; filePathOnTelegram: string }
|
|
85
|
+
| { ok: false; fileId: string; error: string };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Download one photo variant and save it to `<mediaDir>/<updateId>.<ext>`.
|
|
89
|
+
* Never throws: a getFile / download / write failure returns
|
|
90
|
+
* `{ ok: false, fileId, error }` so the caller can degrade instead of drop.
|
|
91
|
+
*/
|
|
92
|
+
export async function materializePhoto(opts: {
|
|
93
|
+
token: string;
|
|
94
|
+
updateId: number;
|
|
95
|
+
photo: TelegramPhotoSize;
|
|
96
|
+
fetchFn?: TelegramFetch;
|
|
97
|
+
baseUrl?: string;
|
|
98
|
+
mediaDir?: string;
|
|
99
|
+
}): Promise<MaterializePhotoResult> {
|
|
100
|
+
const { token, updateId, photo, fetchFn, baseUrl } = opts;
|
|
101
|
+
const fileId = photo.file_id;
|
|
102
|
+
const mediaDir = opts.mediaDir ?? defaultMediaDir();
|
|
103
|
+
if (mediaDir === null) {
|
|
104
|
+
warnUnderivableRoot("photo download refused");
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
fileId,
|
|
108
|
+
error:
|
|
109
|
+
"braid media directory is underivable (BRAID_HOME unset and no absolute home); photo not saved",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fileInfo = await getFile(token, fileId, { fetch: fetchFn, baseUrl });
|
|
114
|
+
const filePathOnTelegram = fileInfo.ok ? fileInfo.file?.file_path : undefined;
|
|
115
|
+
if (!fileInfo.ok || !filePathOnTelegram) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
fileId,
|
|
119
|
+
error: fileInfo.error ?? "getFile returned no file_path",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fileDownload = await downloadFile(token, filePathOnTelegram, {
|
|
124
|
+
fetch: fetchFn,
|
|
125
|
+
baseUrl,
|
|
126
|
+
});
|
|
127
|
+
if (!fileDownload.ok || !fileDownload.bytes) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
fileId,
|
|
131
|
+
error: fileDownload.error ?? "file download failed",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dest = join(
|
|
136
|
+
mediaDir,
|
|
137
|
+
`${updateId}.${extFromFilePath(filePathOnTelegram)}`,
|
|
138
|
+
);
|
|
139
|
+
try {
|
|
140
|
+
await mkdir(mediaDir, { recursive: true });
|
|
141
|
+
await writeFile(dest, fileDownload.bytes);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
fileId,
|
|
146
|
+
error: `failed to save ${dest}: ${error instanceof Error ? error.message : String(error)}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, fileId, filePath: dest, filePathOnTelegram };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Options for formatPhotoText — the agent-facing delivery string. */
|
|
153
|
+
export interface PhotoDeliveryTextOptions {
|
|
154
|
+
/** Sender label, e.g. "@owner" or "id 123" (matches the whisper header). */
|
|
155
|
+
senderLabel: string;
|
|
156
|
+
/** Absolute path of the saved file (when the download succeeded). */
|
|
157
|
+
filePath?: string;
|
|
158
|
+
/** Telegram file_id (always present for a photo message). */
|
|
159
|
+
fileId?: string;
|
|
160
|
+
/** Message caption (trimmed), if any. */
|
|
161
|
+
caption?: string;
|
|
162
|
+
/** Download/save failure note (degraded delivery). */
|
|
163
|
+
error?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the delivered text the receiving agent can act on. Downloaded form:
|
|
168
|
+
* 📷 Photo from @owner: /abs/path.jpg (caption: <caption or none>)
|
|
169
|
+
* Degraded (download failed) form — never dropped:
|
|
170
|
+
* 📷 Photo from @owner: file_id <id> (caption: <caption or none>) (download failed: <error>)
|
|
171
|
+
*/
|
|
172
|
+
export function formatPhotoText(opts: PhotoDeliveryTextOptions): string {
|
|
173
|
+
const captionPart = opts.caption
|
|
174
|
+
? ` (caption: ${opts.caption})`
|
|
175
|
+
: " (caption: none)";
|
|
176
|
+
if (opts.filePath) {
|
|
177
|
+
return `📷 Photo from ${opts.senderLabel}: ${opts.filePath}${captionPart}`;
|
|
178
|
+
}
|
|
179
|
+
const filePart = opts.fileId ? `file_id ${opts.fileId}` : "file_id unknown";
|
|
180
|
+
const errorPart = opts.error ? ` (download failed: ${opts.error})` : "";
|
|
181
|
+
return `📷 Photo from ${opts.senderLabel}: ${filePart}${errorPart}${captionPart}`;
|
|
182
|
+
}
|