@timqi/pier 0.0.4 → 0.0.6

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.
Files changed (43) hide show
  1. package/README.md +9 -6
  2. package/dist/agent/events.js +6 -1
  3. package/dist/agent/pi.js +28 -3
  4. package/dist/channels/chunk.js +34 -0
  5. package/dist/channels/control.js +6 -12
  6. package/dist/channels/dedup.js +45 -0
  7. package/dist/channels/lark-api.js +233 -0
  8. package/dist/channels/lark-outbound.js +101 -0
  9. package/dist/channels/lark-panel.js +95 -0
  10. package/dist/channels/lark-render.js +107 -0
  11. package/dist/channels/lark.js +501 -0
  12. package/dist/channels/lines.js +19 -0
  13. package/dist/channels/panel.js +4 -0
  14. package/dist/channels/receipts.js +15 -0
  15. package/dist/channels/routes.js +0 -1
  16. package/dist/channels/runtime.js +5 -1
  17. package/dist/channels/slack-api.js +4 -2
  18. package/dist/channels/slack-panel.js +3 -6
  19. package/dist/channels/slack-render.js +3 -23
  20. package/dist/channels/slack.js +39 -72
  21. package/dist/channels/telegram-api.js +4 -2
  22. package/dist/channels/telegram-panel.js +3 -3
  23. package/dist/channels/telegram.js +55 -51
  24. package/dist/channels/types.js +9 -0
  25. package/dist/cli.js +3 -1
  26. package/dist/core/inbox.js +67 -1
  27. package/dist/core/types.js +4 -0
  28. package/dist/db.js +83 -20
  29. package/dist/main.js +5 -0
  30. package/dist/service.js +1 -1
  31. package/dist/web/auth.js +36 -9
  32. package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
  33. package/dist/web/public/assets/ghostty-web-ODXT71Ln.js +13 -0
  34. package/dist/web/public/assets/index-BUNGxtMe.css +2 -0
  35. package/dist/web/public/assets/index-QPYgeBhQ.js +90 -0
  36. package/dist/web/public/index.html +10 -2
  37. package/dist/web/server.js +86 -19
  38. package/dist/web/session-state.js +59 -25
  39. package/dist/web/terminal.js +334 -0
  40. package/docs/deploy.md +33 -21
  41. package/package.json +10 -2
  42. package/dist/web/public/assets/index-B3MvJUJP.js +0 -90
  43. package/dist/web/public/assets/index-CwBoxtXP.css +0 -2
@@ -1,13 +1,17 @@
1
1
  // Channel lifecycle: which adapters are running, and where their sessions
2
2
  // live. Keeps main.ts wiring-only and gives the Console one call to apply a
3
- // config change. Lark is configurable but has no adapter yet.
3
+ // config change.
4
4
  import { logger } from "../log.js";
5
+ import { LarkChannel } from "./lark.js";
5
6
  import { SlackChannel } from "./slack.js";
6
7
  import { TelegramChannel } from "./telegram.js";
7
8
  /** Platforms with an adapter, and what each needs before it can start. */
8
9
  const ADAPTERS = [
9
10
  { platform: "telegram", needsAppToken: false, build: (deps) => new TelegramChannel(deps) },
10
11
  { platform: "slack", needsAppToken: true, build: (deps) => new SlackChannel(deps) },
12
+ // Lark's "token" is the App ID and "appToken" the App Secret; the adapter
13
+ // needs both before it can start, same gate as Slack's two credentials.
14
+ { platform: "lark", needsAppToken: true, build: (deps) => new LarkChannel(deps) },
11
15
  ];
12
16
  // Lifecycle news, which is not what the injected sink below is for: that one
13
17
  // is a warning sink the adapters share, and "slack started" is not a warning.
@@ -9,6 +9,7 @@
9
9
  //
10
10
  // No SDK: `apps.connections.open` plus Node's built-in WebSocket is the whole
11
11
  // protocol, and @slack/socket-mode would pull a dependency tree to wrap it.
12
+ import { readCapped } from "../core/inbox.js";
12
13
  const BASE = "https://slack.com/api";
13
14
  /**
14
15
  * Did Slack refuse the payload because of the *block* itself? The `markdown`
@@ -278,7 +279,7 @@ export class SlackApi {
278
279
  * Slack file URLs are private: they need the bot token as a bearer header and
279
280
  * answer HTML (a login page) rather than an error when it is missing.
280
281
  */
281
- async downloadFile(file) {
282
+ async downloadFile(file, maxBytes) {
282
283
  const url = file.url_private_download ?? file.url_private;
283
284
  if (!url)
284
285
  throw new Error("slack file has no private url");
@@ -289,6 +290,7 @@ export class SlackApi {
289
290
  if (!res.ok)
290
291
  throw new Error(`slack file download: ${res.status}`);
291
292
  const mimeType = res.headers.get("content-type")?.split(";")[0] ?? file.mimetype ?? "application/octet-stream";
292
- return { bytes: new Uint8Array(await res.arrayBuffer()), mimeType };
293
+ // Bounded mid-stream: the event's size metadata is the platform's word.
294
+ return { bytes: await readCapped(res.body, maxBytes), mimeType };
293
295
  }
294
296
  }
@@ -4,7 +4,7 @@
4
4
  // The modal is what makes this half smaller than Telegram's: `private_metadata`
5
5
  // carries the conversation with the dialog, so a submitted path needs no
6
6
  // adapter-side state to be understood, and survives a reload.
7
- import { ChatPanel, PANEL_PREFIX, } from "./panel.js";
7
+ import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
8
8
  import { context, escapeMrkdwn as esc, section } from "./slack-render.js";
9
9
  /** The modal's ids. `private_metadata` carries which conversation it is for. */
10
10
  const CWD_VIEW = "cfg_cwd";
@@ -94,14 +94,11 @@ export class SlackPanel extends ChatPanel {
94
94
  type: "input",
95
95
  block_id: CWD_BLOCK,
96
96
  label: { type: "plain_text", text: "Working directory" },
97
- hint: {
98
- type: "plain_text",
99
- text: "An absolute path. A new session starts there; the current one stays in its own directory.",
100
- },
97
+ hint: { type: "plain_text", text: `An absolute path. ${CWD_TAIL}` },
101
98
  element: {
102
99
  type: "plain_text_input",
103
100
  action_id: CWD_INPUT,
104
- placeholder: { type: "plain_text", text: "/path/to/project" },
101
+ placeholder: { type: "plain_text", text: CWD_PLACEHOLDER },
105
102
  },
106
103
  },
107
104
  ],
@@ -7,7 +7,7 @@
7
7
  // escaped; unlike Telegram's HTML parser Slack degrades unknown syntax to
8
8
  // literal text instead of rejecting the message, so the risk here is an ugly
9
9
  // reply rather than a lost one.
10
- import { chunkText } from "./chunk.js";
10
+ import { balanceFences, chunkText } from "./chunk.js";
11
11
  /**
12
12
  * A `markdown` block's budget: Slack caps them at 12,000 cumulative chars per
13
13
  * message, and one message carries one. This is the normal path.
@@ -63,30 +63,10 @@ export function toMrkdwn(markdown) {
63
63
  }
64
64
  /**
65
65
  * Split rendered mrkdwn into sendable chunks at the last blank line or newline
66
- * that fits, then re-balance code fences across the cut.
67
- *
68
- * Telegram can be cut mid-`<pre>` and shrug — its parser closes the tag itself.
69
- * Slack does not: an unterminated ``` swallows the rest of that message, and
70
- * the next chunk starts *outside* a fence, so the tail of a long code block
71
- * renders as prose. Closing and reopening around the boundary is what keeps a
72
- * split code block readable.
66
+ * that fits, then re-balance code fences across the cut (see chunk.ts for why
67
+ * an unbalanced fence is a mangled reply here and not on Telegram).
73
68
  */
74
69
  export const chunk = (text, max) => balanceFences(chunkText(text, max));
75
- /**
76
- * Close a fence a chunk left open, and reopen it on the next one. Counting `\`
77
- * runs is enough because `toMrkdwn` has already normalised every fence to a
78
- * bare ``` on its own line.
79
- */
80
- function balanceFences(parts) {
81
- let open = false;
82
- return parts.map((part) => {
83
- const reopened = open ? `\`\`\`\n${part}` : part;
84
- // The prepended fence counts too, so a chunk that closes the block it
85
- // inherited comes out even and clears the flag.
86
- open = ((reopened.match(/```/g) ?? []).length % 2) === 1;
87
- return open ? `${reopened}\n\`\`\`` : reopened;
88
- });
89
- }
90
70
  /**
91
71
  * The body of a turn, as Slack's own markdown renderer sees it. Preferred over
92
72
  * `section` for everything the agent wrote: it takes the markdown unmodified
@@ -22,11 +22,13 @@
22
22
  //
23
23
  // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
24
24
  // config.ts, platform-blind and shared with Telegram.
25
- import { saveInbound } from "../core/inbox.js";
26
- import { fileMarker, lostMarker, MAX_INBOUND_BYTES } from "../core/inbound-file.js";
25
+ import { saveInboundAll } from "../core/inbox.js";
26
+ import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
27
+ import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
27
28
  import { logger } from "../log.js";
28
29
  import { Chains } from "./chains.js";
29
30
  import { parseCommand } from "./commands.js";
31
+ import { Dedup } from "./dedup.js";
30
32
  import { Gatekeeper } from "./gatekeeper.js";
31
33
  import { ReceiptLedger, Receipts } from "./receipts.js";
32
34
  import { SlackDirectory } from "./slack-directory.js";
@@ -112,7 +114,7 @@ export class SlackChannel {
112
114
  /** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
113
115
  gate;
114
116
  /** `event_id`s already handled, against Slack's at-least-once delivery. */
115
- seen = new Map();
117
+ seen;
116
118
  /** The in-chat settings panel; absent when no control was wired (tests). */
117
119
  panel;
118
120
  /** Channel kinds/names and user names, cached; see slack-directory.ts. */
@@ -138,6 +140,7 @@ export class SlackChannel {
138
140
  this.chains = new Chains(this.log, MAX_ACTIVE_CHATS);
139
141
  this.directory = deps.directory ?? new SlackDirectory(this.log);
140
142
  this.gate = new Gatekeeper(deps.store, "slack", this.log, "channel");
143
+ this.seen = new Dedup(this.log, DEDUP_TTL_MS, DEDUP_MAX);
141
144
  this.api = deps.client ?? new SlackApi(config.token, config.appToken, this.log);
142
145
  this.out = new SlackOutbound(this.api, this.log);
143
146
  this.receipts = new Receipts(
@@ -210,7 +213,7 @@ export class SlackChannel {
210
213
  this.log(`ignored event type ${event.type}`);
211
214
  return;
212
215
  }
213
- if (this.duplicate(payload?.event_id))
216
+ if (this.seen.duplicate(payload?.event_id))
214
217
  return;
215
218
  const channel = event.channel;
216
219
  if (!channel)
@@ -230,29 +233,6 @@ export class SlackChannel {
230
233
  }
231
234
  this.log(`ignored envelope type ${env.type}`);
232
235
  }
233
- /**
234
- * Slack redelivers an envelope it did not see acknowledged, so the same
235
- * `event_id` can arrive twice. Bounded and time-limited: the map is fed by
236
- * every message in every channel the bot is in.
237
- */
238
- duplicate(eventId) {
239
- if (!eventId)
240
- return false;
241
- const now = Date.now();
242
- if (this.seen.size > DEDUP_MAX) {
243
- for (const [id, at] of this.seen) {
244
- if (now - at > DEDUP_TTL_MS)
245
- this.seen.delete(id);
246
- }
247
- }
248
- const at = this.seen.get(eventId);
249
- if (at !== undefined && now - at <= DEDUP_TTL_MS) {
250
- this.log(`duplicate event ${eventId} ignored`);
251
- return true;
252
- }
253
- this.seen.set(eventId, now);
254
- return false;
255
- }
256
236
  async onMessage(event, onMessage) {
257
237
  // Our own echo, another app, or a subtype that is not a person talking.
258
238
  if (event.bot_id || !event.user || event.user === this.me)
@@ -304,15 +284,19 @@ export class SlackChannel {
304
284
  // Downloading only past the gate: an unauthorized sender must not be able
305
285
  // to make the bot pull bytes on their behalf.
306
286
  const markers = await this.saveAttachments(files);
287
+ // A Slack thread is many people talking into one session, so the agent is
288
+ // told who spoke — and the id, which is what a mention needs. Resolved
289
+ // *before* the mark: every await between mark() and dispatch is a window
290
+ // in which a previous turn can end and settle, taking this receipt with it
291
+ // (paid for on Lark first).
292
+ const sender = { id: event.user, name: await this.directory.user(this.api, event.user) };
307
293
  this.receipts.mark(here.conversationId, channel, ts);
308
294
  // IM messages steer by default: a follow-up that waits for the turn to end
309
295
  // is the wrong default when the human is watching a 👀 in a thread.
310
296
  onMessage({
311
297
  key: here,
312
298
  senderId: event.user,
313
- // A Slack thread is many people talking into one session, so the agent is
314
- // told who spoke — and the id, which is what a mention needs.
315
- sender: { id: event.user, name: await this.directory.user(this.api, event.user) },
299
+ sender,
316
300
  text: [text, ...markers].filter(Boolean).join("\n"),
317
301
  mode: "steer",
318
302
  });
@@ -356,7 +340,10 @@ export class SlackChannel {
356
340
  return;
357
341
  const text = offeredLabel(message.blocks, actionId);
358
342
  if (text === undefined) {
343
+ // The person clicked and would otherwise see nothing happen (5b).
359
344
  this.log(`unknown action ${actionId} in channel ${channel}`);
345
+ await this.api.postMessage({ channel, thread_ts: threadTs, text: STALE_OPTION })
346
+ .catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
360
347
  return;
361
348
  }
362
349
  // The options belonged to the turn that just ended; once one is taken the
@@ -366,25 +353,21 @@ export class SlackChannel {
366
353
  // A bot cannot post as the user, so the pick is echoed and marked as one.
367
354
  // Without it the thread shows an answer to a request nobody can see being
368
355
  // made, and there is no message of the user's to carry the eyes.
356
+ const sender = { id: user, name: await this.directory.user(this.api, user) };
369
357
  const echo = await this.api.postMessage({
370
358
  channel,
371
359
  thread_ts: threadTs,
372
- text: `\u25b8 ${escapeMrkdwn(text)}`,
360
+ text: picked(escapeMrkdwn(text)),
373
361
  }).catch((err) => {
374
362
  this.log(`option echo failed: ${String(err)}`);
375
363
  return undefined;
376
364
  });
377
365
  // The receipt goes on the echo, not on the bot message that held the
378
- // buttons: the eyes mean "this input is being worked on".
366
+ // buttons: the eyes mean "this input is being worked on". No await
367
+ // between mark and dispatch — see onMessage.
379
368
  if (echo?.ts)
380
369
  this.receipts.mark(key.conversationId, channel, echo.ts);
381
- onMessage({
382
- key,
383
- senderId: user,
384
- sender: { id: user, name: await this.directory.user(this.api, user) },
385
- text,
386
- mode: "steer",
387
- });
370
+ onMessage({ key, senderId: user, sender, text, mode: "steer" });
388
371
  }
389
372
  /**
390
373
  * Drop the actions row, keeping the reply itself exactly as it was. A turn
@@ -404,7 +387,7 @@ export class SlackChannel {
404
387
  */
405
388
  async abortTurn(key, channel, threadTs) {
406
389
  await this.deps.control?.abort(key);
407
- await this.api.postMessage({ channel, thread_ts: threadTs, text: "⏹ Stopped." });
390
+ await this.api.postMessage({ channel, thread_ts: threadTs, text: STOPPED });
408
391
  }
409
392
  // --- bind ------------------------------------------------------------------
410
393
  /**
@@ -417,7 +400,7 @@ export class SlackChannel {
417
400
  await this.api.postMessage({
418
401
  channel,
419
402
  thread_ts: threadTs,
420
- text: "You are not bound yet. Ask the operator for a bind code, then send `bind <code>`.",
403
+ text: bindHint("`bind <code>`"),
421
404
  }).catch((err) => this.log(`bind hint failed: ${String(err)}`));
422
405
  }
423
406
  async bind(channel, userId, threadTs, code) {
@@ -426,7 +409,7 @@ export class SlackChannel {
426
409
  await this.api.postMessage({
427
410
  channel,
428
411
  thread_ts: threadTs,
429
- text: ok ? `Bound as ${escapeMrkdwn(name)}.` : "That bind code is invalid or expired.",
412
+ text: bindResult(ok, escapeMrkdwn(name)),
430
413
  });
431
414
  }
432
415
  // --- addressing ------------------------------------------------------------
@@ -459,27 +442,17 @@ export class SlackChannel {
459
442
  }
460
443
  return name ?? channel;
461
444
  }
462
- /** Save uploads to the inbox; each becomes a prompt line a failed one
463
- * becomes a lost-marker line, never silence (5b). */
464
- async saveAttachments(files) {
465
- const markers = [];
466
- for (const file of files) {
467
- // Metadata check before the fetch: any type is welcome, but a movie
468
- // buffered whole into memory is not.
469
- if (file.size !== undefined && file.size > MAX_INBOUND_BYTES) {
470
- markers.push(lostMarker(file.name ?? "attachment", "too large"));
471
- continue;
472
- }
473
- try {
474
- const { bytes, mimeType } = await this.api.downloadFile(file);
475
- markers.push(fileMarker(await saveInbound(this.id, file.name, mimeType, bytes)));
476
- }
477
- catch (err) {
478
- this.log(`file download failed: ${String(err)}`);
479
- markers.push(lostMarker(file.name ?? "attachment", "download failed"));
480
- }
481
- }
482
- return markers;
445
+ /** The upload list as the shared save loop wants it (the loop itself, size
446
+ * gate and lost markers included, is core/inbox.ts). */
447
+ saveAttachments(files) {
448
+ return saveInboundAll(this.id, files.map((file) => ({
449
+ label: file.name ?? "attachment",
450
+ name: file.name,
451
+ mimeType: file.mimetype ?? "application/octet-stream",
452
+ size: file.size,
453
+ // The response's content-type wins: Slack's metadata is a guess.
454
+ fetch: async () => this.api.downloadFile(file, MAX_INBOUND_BYTES),
455
+ })), this.log);
483
456
  }
484
457
  // --- outbound --------------------------------------------------------------
485
458
  /**
@@ -498,15 +471,9 @@ export class SlackChannel {
498
471
  await this.receipts.settle(conversation);
499
472
  return;
500
473
  }
501
- try {
502
- await this.out.reply(channel, threadTs, reply);
503
- }
504
- finally {
505
- // Always: the turn ended either way, and a 👀 left on a user's message
506
- // because the reply failed to send would sit there until the stale sweep
507
- // half an hour later, looking like the agent is still working.
508
- await this.receipts.settle(conversation);
509
- }
474
+ // settleAfter: the turn ended either way, and a 👀 left up because the
475
+ // reply failed to send looks like work until the stale sweep.
476
+ await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply));
510
477
  }
511
478
  /** A system note, posted without touching the receipts: the turn it triggers
512
479
  * has not ended yet. */
@@ -5,6 +5,7 @@
5
5
  //
6
6
  // Behind a proxy, run node with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY set;
7
7
  // nothing here needs to know.
8
+ import { readCapped } from "../core/inbox.js";
8
9
  const BASE = "https://api.telegram.org";
9
10
  export class TelegramApi {
10
11
  token;
@@ -62,7 +63,8 @@ export class TelegramApi {
62
63
  async answerCallbackQuery(id, text) {
63
64
  await this.call("answerCallbackQuery", { callback_query_id: id, text });
64
65
  }
65
- async downloadFile(fileId) {
66
+ /** Bounded mid-stream: metadata size is the platform's word, not a cap. */
67
+ async downloadFile(fileId, maxBytes) {
66
68
  const file = await this.call("getFile", { file_id: fileId });
67
69
  if (!file.file_path)
68
70
  throw new Error("telegram getFile: no file_path");
@@ -72,6 +74,6 @@ export class TelegramApi {
72
74
  if (!res.ok)
73
75
  throw new Error(`telegram file download: ${res.status}`);
74
76
  const name = file.file_path.split("/").pop() || "file";
75
- return { bytes: new Uint8Array(await res.arrayBuffer()), name };
77
+ return { bytes: await readCapped(res.body, maxBytes), name };
76
78
  }
77
79
  }
@@ -5,7 +5,7 @@
5
5
  // Asking for a working directory costs a Map here: a forced reply arrives as
6
6
  // an ordinary message, so the prompt's message id has to be remembered to
7
7
  // recognize the answer. Slack's modal carries that context itself.
8
- import { ChatPanel, PANEL_PREFIX, } from "./panel.js";
8
+ import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
9
9
  import { escapeHtml as esc } from "./telegram-render.js";
10
10
  const button = (b) => ({ text: b.label, callback_data: `${PANEL_PREFIX}${b.action}` });
11
11
  export class TelegramPanel extends ChatPanel {
@@ -84,8 +84,8 @@ export class TelegramPanel extends ChatPanel {
84
84
  chat_id: state.chatId,
85
85
  message_thread_id: state.topicId,
86
86
  // Said plainly: this is not an edit, it is a new session.
87
- text: "Reply with an absolute path. A new session starts there; the current one stays in its own directory.",
88
- reply_markup: { force_reply: true, input_field_placeholder: "/path/to/project" },
87
+ text: `Reply with an absolute path. ${CWD_TAIL}`,
88
+ reply_markup: { force_reply: true, input_field_placeholder: CWD_PLACEHOLDER },
89
89
  });
90
90
  this.cwdPrompts.set(key.conversationId, sent.message_id);
91
91
  }
@@ -12,8 +12,9 @@
12
12
  // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
13
13
  // config.ts, platform-blind and shared with the adapters still to come.
14
14
  import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
15
- import { saveInbound } from "../core/inbox.js";
16
- import { fileMarker, lostMarker, MAX_INBOUND_BYTES } from "../core/inbound-file.js";
15
+ import { saveInboundAll } from "../core/inbox.js";
16
+ import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
17
+ import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
17
18
  import { logger } from "../log.js";
18
19
  import { Chains } from "./chains.js";
19
20
  import { parseCommand } from "./commands.js";
@@ -240,8 +241,14 @@ export class TelegramChannel {
240
241
  return;
241
242
  const text = offeredLabel(msg, query.data);
242
243
  if (text === undefined) {
243
- await this.api.answerCallbackQuery(query.id, "That option is no longer on this message.")
244
- .catch(() => { });
244
+ // Said in the chat, not as a second answerCallbackQuery: a query may be
245
+ // answered exactly once, and the toast already went to the ack above —
246
+ // against the real API the second answer is silently dropped.
247
+ await this.api.sendMessage({
248
+ chat_id: chatId,
249
+ message_thread_id: msg.message_thread_id,
250
+ text: STALE_OPTION,
251
+ }).catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
245
252
  return;
246
253
  }
247
254
  // The options belonged to the turn that just ended; once one is taken the
@@ -258,7 +265,7 @@ export class TelegramChannel {
258
265
  const echo = await this.api.sendMessage({
259
266
  chat_id: chatId,
260
267
  message_thread_id: msg.message_thread_id,
261
- text: `\u25b8 ${escapeHtml(text)}`,
268
+ text: picked(escapeHtml(text)),
262
269
  parse_mode: "HTML",
263
270
  }).catch((err) => {
264
271
  this.log(`option echo failed: ${String(err)}`);
@@ -284,7 +291,7 @@ export class TelegramChannel {
284
291
  await this.api.sendMessage({
285
292
  chat_id: msg.chat.id,
286
293
  message_thread_id: msg.message_thread_id,
287
- text: "⏹ Stopped.",
294
+ text: STOPPED,
288
295
  });
289
296
  }
290
297
  /**
@@ -296,7 +303,7 @@ export class TelegramChannel {
296
303
  return;
297
304
  await this.api.sendMessage({
298
305
  chat_id: msg.chat.id,
299
- text: "You are not bound yet. Ask the operator for a bind code, then send /bind <code>.",
306
+ text: bindHint("/bind <code>"),
300
307
  }).catch((err) => this.log(`bind hint failed: ${String(err)}`));
301
308
  }
302
309
  async bind(msg, code) {
@@ -305,7 +312,7 @@ export class TelegramChannel {
305
312
  const ok = this.deps.store.redeemBindCode("telegram", code, { id: String(user.id), name });
306
313
  await this.api.sendMessage({
307
314
  chat_id: msg.chat.id,
308
- text: ok ? `Bound as ${name}.` : "That bind code is invalid or expired.",
315
+ text: bindResult(ok, name),
309
316
  });
310
317
  }
311
318
  /**
@@ -355,32 +362,28 @@ export class TelegramChannel {
355
362
  return msg.message_thread_id;
356
363
  }
357
364
  }
358
- /** Save the message's attachments to the inbox; each becomes a prompt
359
- * line a failed one becomes a lost-marker line, never silence (5b). */
360
- async saveAttachments(msg) {
365
+ /** The message's attachments as the shared save loop wants them (the loop
366
+ * itself, size gate and lost markers included, is core/inbox.ts). */
367
+ saveAttachments(msg) {
361
368
  // Telegram sends a photo as a size ladder — the last entry is the largest.
362
369
  const photo = msg.photo?.at(-1);
363
370
  const doc = msg.document;
364
- const wanted = [
365
- ...(photo ? [{ id: photo.file_id, name: undefined, label: "photo", mime: "image/jpeg", size: photo.file_size }] : []),
366
- ...(doc ? [{ id: doc.file_id, name: doc.file_name, label: doc.file_name ?? "file", mime: doc.mime_type ?? "application/octet-stream", size: doc.file_size }] : []),
367
- ];
368
- const markers = [];
369
- for (const { id, name, label, mime, size } of wanted) {
370
- if (size !== undefined && size > MAX_INBOUND_BYTES) {
371
- markers.push(lostMarker(label, "too large"));
372
- continue;
373
- }
374
- try {
375
- const file = await this.api.downloadFile(id);
376
- markers.push(fileMarker(await saveInbound(this.id, name ?? file.name, mime, file.bytes)));
377
- }
378
- catch (err) {
379
- this.log(`attachment download failed: ${String(err)}`);
380
- markers.push(lostMarker(label, "download failed"));
381
- }
382
- }
383
- return markers;
371
+ return saveInboundAll(this.id, [
372
+ ...(photo ? [{
373
+ label: "photo",
374
+ mimeType: "image/jpeg",
375
+ size: photo.file_size,
376
+ // Telegram only learns a filename from getFile, so the fetch's wins.
377
+ fetch: async () => this.api.downloadFile(photo.file_id, MAX_INBOUND_BYTES),
378
+ }] : []),
379
+ ...(doc ? [{
380
+ label: doc.file_name ?? "file",
381
+ name: doc.file_name,
382
+ mimeType: doc.mime_type ?? "application/octet-stream",
383
+ size: doc.file_size,
384
+ fetch: async () => this.api.downloadFile(doc.file_id, MAX_INBOUND_BYTES),
385
+ }] : []),
386
+ ], this.log);
384
387
  }
385
388
  // --- addressing ------------------------------------------------------------
386
389
  /** Mentioned, replying to the bot, or a slash command aimed at this bot. */
@@ -419,27 +422,28 @@ export class TelegramChannel {
419
422
  const quiet = isSilentReply(reply)
420
423
  ? `<i>${quietLabel(reply.silence && escapeHtml(reply.silence))}</i>`
421
424
  : "";
422
- const body = (text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta);
423
- try {
424
- if (body.trim()) {
425
- const parts = chunk(body);
426
- for (const [i, part] of parts.entries()) {
427
- await this.api.sendMessage({
428
- chat_id: chatId,
429
- message_thread_id: topicId,
430
- text: part,
431
- parse_mode: "HTML",
432
- // Next-step buttons ride the last chunk; a click sends the label.
433
- reply_markup: i === parts.length - 1 ? buttons : undefined,
434
- });
435
- }
425
+ // A turn that is only its options still has to carry them: with no text,
426
+ // no silence marker and no meta the body is empty, and a keyboard cannot
427
+ // ride a message that was never sent — so it gets the smallest one.
428
+ const body = ((text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta)) ||
429
+ (buttons ? "…" : "");
430
+ // settleAfter: a 👀 left up because the reply failed would sit there until
431
+ // the stale sweep, looking like the agent is still working.
432
+ await this.receipts.settleAfter(conversation, async () => {
433
+ if (!body.trim())
434
+ return;
435
+ const parts = chunk(body);
436
+ for (const [i, part] of parts.entries()) {
437
+ await this.api.sendMessage({
438
+ chat_id: chatId,
439
+ message_thread_id: topicId,
440
+ text: part,
441
+ parse_mode: "HTML",
442
+ // Next-step buttons ride the last chunk; a click sends the label.
443
+ reply_markup: i === parts.length - 1 ? buttons : undefined,
444
+ });
436
445
  }
437
- }
438
- finally {
439
- // Always: a 👀 left up because the reply failed would sit there until the
440
- // stale sweep, looking like the agent is still working.
441
- await this.receipts.settle(conversation);
442
- }
446
+ });
443
447
  }
444
448
  /**
445
449
  * A system note: quoted, labelled with where it came from, and deliberately
@@ -10,6 +10,15 @@
10
10
  const PLATFORMS = ["telegram", "slack", "lark"];
11
11
  /** Validate at the boundary: an unknown platform is a 404, not a new row. */
12
12
  export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
13
+ /**
14
+ * The chat a conversation id belongs to. Every adapter spells its ids
15
+ * `<chatId>` or `<chatId>/<thread>` — Telegram's topic, Slack's thread_ts,
16
+ * Lark's root message — so the chat half has one decoder instead of one per
17
+ * platform (control.ts used to import all three adapters for exactly this).
18
+ * The *thread* half stays with each adapter: its type and meaning genuinely
19
+ * differ per platform.
20
+ */
21
+ export const chatOf = (conversationId) => conversationId.split("/", 1)[0] ?? "";
13
22
  export const defaultChannelConfig = () => ({
14
23
  enabled: false,
15
24
  token: "",
package/dist/cli.js CHANGED
@@ -178,7 +178,9 @@ async function signalService(command) {
178
178
  }
179
179
  async function backup() {
180
180
  const [{ backupDb }, { PIER_DB }] = await Promise.all([import("./db.js"), import("./paths.js")]);
181
- const path = backupDb(PIER_DB);
181
+ // This tree's version: the updater runs `backup` before npm replaces it, so
182
+ // it is the release the copy pairs with.
183
+ const path = backupDb(version, PIER_DB);
182
184
  process.stdout.write(path ? `backed up ${path}\n` : `no database yet — nothing to back up.\n`);
183
185
  }
184
186
  function commandPath(name) {