@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
@@ -0,0 +1,107 @@
1
+ // How a reply looks on Lark: an interactive card whose body is a markdown
2
+ // element, buttons as flow columns, and the turn footer as notation-sized grey
3
+ // text.
4
+ //
5
+ // Everything is a card, never a plain `text` message, because three checklist
6
+ // features live or die on it: buttons (next-step suggestions, the settings
7
+ // panel), edit-in-place (the panel's one-message contract, via message.patch),
8
+ // and the muted footer. The costs are known and accepted: the chat list
9
+ // previews a card as「卡片」rather than its first line, and card interactions
10
+ // expire after 30 days — fine for buttons that answer the turn they rode on.
11
+ //
12
+ // The markdown element takes the agent's markdown near-unmodified — Lark's
13
+ // dialect covers emphasis, fences, lists, links and quotes — so unlike
14
+ // Telegram (HTML) and Slack's mrkdwn fallback there is no translation layer,
15
+ // and no escaping either: Lark treats what it cannot parse as literal text
16
+ // rather than rejecting the message (avibe ships the same identity escape).
17
+ import { balanceFences, chunkText } from "./chunk.js";
18
+ /**
19
+ * Chunk budget in characters. The binding limit is the card's 30KB request
20
+ * cap in *bytes*; a CJK character spends three, plus JSON string overhead, so
21
+ * 7000 chars keeps the worst all-CJK turn near 21KB with room for the card
22
+ * scaffolding around it.
23
+ */
24
+ export const LARK_MAX = 7000;
25
+ // Lark truncates a button label around this, mid-word.
26
+ const BUTTON_MAX = 60;
27
+ /**
28
+ * Cut at the last break that fits, then re-balance code fences across the cut:
29
+ * Lark, like Slack, lets an unterminated ``` swallow the rest of the message.
30
+ */
31
+ export const chunk = (text, max) => balanceFences(chunkText(text, max));
32
+ /** The body of a turn, rendered by Lark's own markdown dialect. */
33
+ export const markdown = (content) => ({ tag: "markdown", content });
34
+ /**
35
+ * Small muted text. Card schema 2.0 removed the `note` component; its
36
+ * replacement is a notation-sized markdown element, and the grey must come
37
+ * from the inline font tag because 2.0's markdown element rejects a
38
+ * `text_color` property. Both verified against the live API (by avibe).
39
+ *
40
+ * Standalone cards only (a quiet turn, an options row): beside a body it
41
+ * would render a blank gap — elements space themselves apart and no spacing
42
+ * knob is documented — so there the footer folds into the body's own element
43
+ * (`withFooter`), where a newline is just a newline.
44
+ */
45
+ export const footer = (content) => ({
46
+ tag: "markdown",
47
+ content: `<font color='grey'>${content}</font>`,
48
+ text_size: "notation",
49
+ });
50
+ /**
51
+ * Markdown constructs a following line *continues* instead of leaving: a list
52
+ * item, a blockquote, a table row. After one of these a single newline is
53
+ * lazy continuation — the footer rendered glued onto "5. 麻辣烫:…" in the
54
+ * field — so the footer needs the blank line that ends the construct. After a
55
+ * plain paragraph the single newline stays, because that is the tight spacing
56
+ * this helper exists for.
57
+ */
58
+ const LAZY_LINE = /^\s*(?:[-*+]\s|\d+[.)]\s|>|\|)/;
59
+ /** A body and its footer in one markdown element — grey, gapless. */
60
+ export const withFooter = (body, note) => {
61
+ const last = body.trimEnd().split("\n").at(-1) ?? "";
62
+ const brk = LAZY_LINE.test(last) ? "\n\n" : "\n";
63
+ return markdown(`${body}${brk}<font color='grey'>${note}</font>`);
64
+ };
65
+ const truncate = (label) => label.length > BUTTON_MAX ? `${label.slice(0, BUTTON_MAX - 1)}\u2026` : label;
66
+ export const button = (label, value) => ({
67
+ tag: "button",
68
+ text: { tag: "plain_text", content: truncate(label) },
69
+ type: "default",
70
+ behaviors: [{ type: "callback", value }],
71
+ });
72
+ /**
73
+ * One row of buttons as a `flow` column set: each column sizes to its button
74
+ * and the row wraps on narrow screens, so a long label beside a short one
75
+ * costs nothing (the same property Slack's actions row has natively).
76
+ */
77
+ export const buttonRow = (buttons) => ({
78
+ tag: "column_set",
79
+ flex_mode: "flow",
80
+ background_style: "default",
81
+ columns: buttons.map((b) => ({ tag: "column", width: "auto", elements: [b] })),
82
+ });
83
+ export const card = (elements) => ({
84
+ schema: "2.0",
85
+ body: { direction: "vertical", elements },
86
+ });
87
+ /** A typed answer inside a card — Lark's stand-in for a modal. */
88
+ export const formInput = (name, label, placeholder) => ({
89
+ tag: "input",
90
+ name,
91
+ required: true,
92
+ label: { tag: "plain_text", content: label },
93
+ placeholder: { tag: "plain_text", content: placeholder },
94
+ });
95
+ // --- next-step buttons -----------------------------------------------------------
96
+ /**
97
+ * The callback value carries the key (`sg:0`) *and* the label. On Slack the
98
+ * label is read back off the message the platform echoes with the click; Lark
99
+ * echoes only the value, and `message.get` cannot return a 2.0 card at all
100
+ * (see LarkActionValue), so the value is this platform's "read it back off
101
+ * the message" — still platform state, never adapter memory, so a button
102
+ * survives a restart and the Console's reload the same way (avibe ships the
103
+ * label in the value too: `quick_reply:<label>`).
104
+ */
105
+ export const OFFER_PREFIX = "sg:";
106
+ /** The card minus its button rows — how taken options are retired. */
107
+ export const withoutButtons = (cardIn) => card(cardIn.body.elements.filter((el) => el.tag !== "column_set" && el.tag !== "button"));
@@ -0,0 +1,501 @@
1
+ // Lark (Feishu) adapter: normalize long-connection events, render outbound
2
+ // turns as cards.
3
+ //
4
+ // The anchor is threads, exactly as on Slack: Pier never posts into a chat's
5
+ // main flow — a message in the chat is answered in *its own* topic
6
+ // (`reply_in_thread`), a message inside a topic is answered there. So a
7
+ // conversation is `<chatId>/<rootMessageId>`, the thread is the session, and
8
+ // DMs follow the same rule (Feishu DMs thread; avibe verified it). Telegram's
9
+ // `topicMode` toggle is meaningless here — there is no other behaviour.
10
+ //
11
+ // Four more things are Lark-specific and live only here:
12
+ // - Message bodies are JSON *strings* (`content` is double-encoded), and a
13
+ // mention is a `@_user_N` placeholder resolved through `mentions[]`.
14
+ // - Reactions are named keys: 👀 is `OnIt`, and removal is list-then-delete
15
+ // because the API deletes by reaction_id.
16
+ // - A card callback does not say which thread its message lives in, so every
17
+ // button's value carries the thread root (`LarkActionValue.root`).
18
+ // - Delivery is at-least-once and the transport acks only after the handler
19
+ // returns, so handlers queue work and return; `event_id` is deduplicated.
20
+ //
21
+ // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
22
+ // config.ts, platform-blind and shared with Telegram and Slack.
23
+ import { saveInboundAll } from "../core/inbox.js";
24
+ import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
25
+ import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
26
+ import { logger } from "../log.js";
27
+ import { Chains } from "./chains.js";
28
+ import { parseCommand } from "./commands.js";
29
+ import { Dedup } from "./dedup.js";
30
+ import { Gatekeeper } from "./gatekeeper.js";
31
+ import { LarkApi, } from "./lark-api.js";
32
+ import { LarkOutbound } from "./lark-outbound.js";
33
+ import { CWD_SUBMIT_PREFIX, LarkPanel } from "./lark-panel.js";
34
+ import { card, markdown, OFFER_PREFIX } from "./lark-render.js";
35
+ import { PANEL_PREFIX } from "./panel.js";
36
+ import { ReceiptLedger, Receipts } from "./receipts.js";
37
+ /** Lark wants a named key here; 👀 has none, `OnIt` is its "being handled". */
38
+ const WORKING = "OnIt";
39
+ // Backpressure: bounds concurrency (downloads, API calls), not the backlog —
40
+ // the event is already acked by the transport, so nothing slows the source.
41
+ const MAX_ACTIVE_CHATS = 16;
42
+ const RECEIPT_STALE_MS = 30 * 60_000;
43
+ const DRAIN_TIMEOUT_MS = 5000;
44
+ /** How long a delivered event id is remembered, against redelivery. */
45
+ const DEDUP_TTL_MS = 5 * 60_000;
46
+ const DEDUP_MAX = 2000;
47
+ const SWEEP_EVERY_MS = 60_000;
48
+ /**
49
+ * A Lark conversation is always `<chatId>/<rootMessageId>` — the thread is
50
+ * the session. This pair is the only definition of the format; control.ts
51
+ * decodes with it rather than splitting on "/" itself.
52
+ */
53
+ const conversationId = (chatId, root) => `${chatId}/${root}`;
54
+ export const parseConversation = (id) => {
55
+ const at = id.indexOf("/");
56
+ return at < 0
57
+ ? { chatId: id, root: "" }
58
+ : { chatId: id.slice(0, at), root: id.slice(at + 1) };
59
+ };
60
+ /**
61
+ * The thread a message belongs to. A message already in a topic keeps its
62
+ * root; one posted in the chat becomes the root of its own — which is what
63
+ * makes every request its own session without asking Lark for anything.
64
+ */
65
+ const threadOf = (msg) => msg.rootId || msg.messageId;
66
+ export class LarkChannel {
67
+ deps;
68
+ id = "lark";
69
+ api;
70
+ log;
71
+ /** 👀 lifecycle, durable; see receipts.ts for why it is not just a Map. */
72
+ receipts;
73
+ /** Ordering per chat, concurrency across them; see chains.ts. */
74
+ chains;
75
+ /** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
76
+ gate;
77
+ /** Event ids already handled, against at-least-once delivery. */
78
+ seen;
79
+ /** The in-chat settings panel; absent when no control was wired (tests). */
80
+ panel;
81
+ /** User names, cached for the process — one contact lookup per person. */
82
+ names = new Map();
83
+ /** Chats already reported to the store this process; a rename waits for a
84
+ * restart, which is soon enough for a Console display label. */
85
+ discovered = new Set();
86
+ me = "";
87
+ out;
88
+ socket;
89
+ running = false;
90
+ sweptAt = 0;
91
+ constructor(deps) {
92
+ this.deps = deps;
93
+ const config = deps.store.get("lark");
94
+ this.log = deps.log ?? ((m) => logger("lark").warn(m));
95
+ this.chains = new Chains(this.log, MAX_ACTIVE_CHATS);
96
+ this.gate = new Gatekeeper(deps.store, "lark", this.log, "chat");
97
+ this.seen = new Dedup(this.log, DEDUP_TTL_MS, DEDUP_MAX);
98
+ // token = App ID, appToken = App Secret (see lark-api.ts).
99
+ this.api = deps.client ?? new LarkApi(config.token, config.appToken, this.log);
100
+ this.out = new LarkOutbound(this.api, this.log);
101
+ this.receipts = new Receipts(
102
+ // Reaction removal needs the emoji key back, and Pier only applies one.
103
+ {
104
+ setReaction: (_chatId, messageId, emoji) => emoji
105
+ ? this.api.addReaction(messageId, emoji)
106
+ : this.api.removeReaction(messageId, WORKING),
107
+ }, deps.receipts ?? new ReceiptLedger("lark"), this.log, WORKING, RECEIPT_STALE_MS);
108
+ if (deps.control) {
109
+ this.panel = new LarkPanel({
110
+ api: this.api,
111
+ control: deps.control,
112
+ store: deps.store,
113
+ log: this.log,
114
+ });
115
+ }
116
+ }
117
+ async start(onMessage) {
118
+ this.me = await this.api.botOpenId();
119
+ if (!this.me) {
120
+ // Without our own open_id, "was I mentioned?" can only answer no, so
121
+ // every chat with require-mention on goes silent. Loud, not a debug line.
122
+ this.log("bot info returned no open_id: mention detection is disabled");
123
+ }
124
+ this.running = true;
125
+ // Best-effort and off the critical path.
126
+ void this.receipts.sweep(true);
127
+ this.socket = await this.api.connect({
128
+ onMessage: (event) => this.onEvent(event, onMessage),
129
+ onCardAction: (action) => this.onCardEvent(action, onMessage),
130
+ });
131
+ }
132
+ async stop() {
133
+ this.running = false;
134
+ await this.socket?.close().catch((err) => this.log(`lark socket did not close cleanly: ${String(err)}`));
135
+ this.socket = undefined;
136
+ await this.chains.drain(DRAIN_TIMEOUT_MS);
137
+ }
138
+ // --- inbound ---------------------------------------------------------------
139
+ /**
140
+ * Already (about to be) acked by the transport — the SDK answers the frame
141
+ * when this returns, so routing is synchronous and the work is queued.
142
+ */
143
+ onEvent(event, onMessage) {
144
+ if (!this.running)
145
+ return;
146
+ if (Date.now() - this.sweptAt > SWEEP_EVERY_MS) {
147
+ this.sweptAt = Date.now();
148
+ void this.receipts.sweep();
149
+ }
150
+ // Our own echo or another app's message.
151
+ if (event.senderType === "app")
152
+ return;
153
+ if (this.seen.duplicate(event.eventId))
154
+ return;
155
+ const chatId = event.message.chatId;
156
+ if (!chatId)
157
+ return this.log("message event without a chat id, dropped");
158
+ this.chains.run(chatId, () => this.onMessage(event, onMessage));
159
+ }
160
+ onCardEvent(action, onMessage) {
161
+ if (!this.running)
162
+ return;
163
+ // A card callback carries its own event id; the composed key is the
164
+ // fallback for a payload that arrives without one.
165
+ const dedupId = action.eventId ??
166
+ `card:${action.messageId}:${action.operatorId}:${action.value?.key ?? action.name ?? ""}`;
167
+ if (this.seen.duplicate(dedupId))
168
+ return;
169
+ if (!action.chatId || !action.messageId || !action.operatorId) {
170
+ this.log("incomplete card action payload, dropped");
171
+ return;
172
+ }
173
+ this.chains.run(action.chatId, () => this.onAction(action, onMessage));
174
+ }
175
+ async onMessage(event, onMessage) {
176
+ const msg = event.message;
177
+ const senderId = event.senderId;
178
+ if (!senderId || !msg.messageId)
179
+ return;
180
+ const { text: raw, attachments, mentioned } = this.readContent(msg);
181
+ if (!raw && !attachments.length && !mentioned)
182
+ return;
183
+ const isDm = msg.chatType === "p2p";
184
+ if (!this.discovered.has(msg.chatId)) {
185
+ this.discovered.add(msg.chatId);
186
+ const name = isDm
187
+ ? `DM · ${await this.userName(senderId)}`
188
+ : (await this.api.chatName(msg.chatId).catch((err) => {
189
+ // Named, not silent: this failing usually means a missing scope.
190
+ this.log(`chat lookup failed for ${msg.chatId}: ${String(err)}`);
191
+ return undefined;
192
+ })) ?? msg.chatId;
193
+ this.deps.store.discoverChat("lark", {
194
+ id: msg.chatId,
195
+ name,
196
+ kind: isDm ? "dm" : "group",
197
+ });
198
+ }
199
+ const text = raw.trim();
200
+ // A command aimed at another bot (`/stop@other`) is not ours to answer
201
+ // and travels on as ordinary text — Lark gives Pier no @username a target
202
+ // could positively match, so any target means "not us".
203
+ const parsed = parseCommand(text);
204
+ const command = parsed?.target ? undefined : parsed;
205
+ const root = threadOf(msg);
206
+ const here = { channelId: this.id, conversationId: conversationId(msg.chatId, root) };
207
+ const bindRequest = command?.name === "bind" && isDm;
208
+ const admitted = this.gate.admit("message", msg.chatId, {
209
+ isDm,
210
+ // Mentioned, or continuing a topic Pier already owns — Lark's
211
+ // equivalent of Telegram's "replying to the bot", durable so it still
212
+ // holds after a restart.
213
+ addressed: mentioned || (!!msg.rootId && !!this.deps.control?.knows(here)),
214
+ userId: senderId,
215
+ bindRequest,
216
+ });
217
+ if (!admitted) {
218
+ if (isDm)
219
+ await this.hintBind(senderId, msg.messageId);
220
+ return;
221
+ }
222
+ if (bindRequest)
223
+ return this.bind(senderId, msg.messageId, command?.args ?? "");
224
+ if (command?.name === "stop")
225
+ return this.abortTurn(here, msg.messageId);
226
+ // `@bot` on its own (the text is empty once the mention is stripped) and
227
+ // `/settings` are the same request: show me this conversation's settings.
228
+ if (this.panel && (command?.name === "settings" || (!text && !attachments.length && mentioned))) {
229
+ return this.panel.open(here, msg.chatId, root);
230
+ }
231
+ // Downloading only past the gate: an unauthorized sender must not be able
232
+ // to make the bot pull bytes on their behalf.
233
+ const markers = await this.saveAttachments(msg.messageId, attachments);
234
+ // Every await between mark() and dispatch is a window in which a previous
235
+ // turn can end and settle — taking this receipt with it before its own
236
+ // turn even starts — so the name is resolved first and the mark→dispatch
237
+ // pair stays synchronous.
238
+ const sender = { id: senderId, name: await this.userName(senderId) };
239
+ this.receipts.mark(here.conversationId, msg.chatId, msg.messageId);
240
+ // IM messages steer by default: a follow-up that waits for the turn to
241
+ // end is the wrong default when the human is watching a 👀 in a topic.
242
+ onMessage({
243
+ key: here,
244
+ senderId,
245
+ sender,
246
+ text: [text, ...markers].filter(Boolean).join("\n"),
247
+ mode: "steer",
248
+ });
249
+ }
250
+ /**
251
+ * One message's readable content: text with mentions resolved, attachments
252
+ * to fetch, and whether the bot was addressed. `content` is a JSON string;
253
+ * malformed or unreadable types are logged and dropped at this boundary.
254
+ */
255
+ readContent(msg) {
256
+ let content = {};
257
+ try {
258
+ content = JSON.parse(msg.content ?? "{}");
259
+ }
260
+ catch {
261
+ this.log(`unparseable message content in ${msg.messageId}, dropped`);
262
+ }
263
+ let text = "";
264
+ const attachments = [];
265
+ switch (msg.messageType) {
266
+ case "text":
267
+ text = String(content.text ?? "");
268
+ break;
269
+ case "post": {
270
+ const post = this.readPost(content);
271
+ text = post.text;
272
+ attachments.push(...post.images);
273
+ break;
274
+ }
275
+ case "image":
276
+ if (content.image_key) {
277
+ attachments.push({ key: String(content.image_key), type: "image", name: "image.png" });
278
+ }
279
+ break;
280
+ case "file":
281
+ case "media":
282
+ case "audio":
283
+ if (content.file_key) {
284
+ // `file_size` is optional and sometimes a numeric string; a missing
285
+ // one is fine — download() enforces the cap mid-stream regardless.
286
+ const size = Number(content.file_size);
287
+ attachments.push({
288
+ key: String(content.file_key),
289
+ type: "file",
290
+ name: content.file_name ? String(content.file_name) : undefined,
291
+ size: Number.isFinite(size) && size > 0 ? size : undefined,
292
+ });
293
+ }
294
+ break;
295
+ default:
296
+ this.log(`ignored message type ${msg.messageType ?? "?"}`);
297
+ }
298
+ // A mention arrives as a `@_user_N` placeholder: the bot's own is
299
+ // addressing, not content, and is removed; anyone else's becomes their
300
+ // name, so the agent sees who was meant.
301
+ let mentioned = false;
302
+ for (const mention of msg.mentions ?? []) {
303
+ const isMe = !!this.me && mention.id?.open_id === this.me;
304
+ mentioned ||= isMe;
305
+ text = text.replaceAll(mention.key, isMe ? "" : `@${mention.name ?? "?"}`);
306
+ }
307
+ return { text, attachments, mentioned };
308
+ }
309
+ /** Rich text: the readable runs, and any images embedded in it. */
310
+ readPost(raw) {
311
+ // A post body may arrive wrapped in a locale (`{zh_cn: {title, content}}`)
312
+ // rather than flat — both shapes are real. Take the flat body when it is
313
+ // one, else the first locale entry that is an object.
314
+ const content = Array.isArray(raw.content) || typeof raw.title === "string"
315
+ ? raw
316
+ : (Object.values(raw).find((v) => !!v && typeof v === "object" && !Array.isArray(v)) ??
317
+ {});
318
+ const lines = [];
319
+ const images = [];
320
+ const title = typeof content.title === "string" ? content.title : "";
321
+ if (title)
322
+ lines.push(title);
323
+ const rows = Array.isArray(content.content) ? content.content : [];
324
+ for (const row of rows) {
325
+ if (!Array.isArray(row))
326
+ continue;
327
+ const parts = [];
328
+ for (const run of row) {
329
+ if (run.tag === "text" || run.tag === "a")
330
+ parts.push(String(run.text ?? ""));
331
+ else if (run.tag === "at") {
332
+ // Inline, not a `@_user_N` placeholder: rich text carries the at run
333
+ // itself. The bot's own is addressing (detected via `mentions[]`),
334
+ // not content; anyone else's becomes their name.
335
+ if (run.user_id !== this.me)
336
+ parts.push(`@${run.user_name ?? run.user_id ?? "?"}`);
337
+ }
338
+ else if (run.tag === "img" && run.image_key) {
339
+ images.push({ key: String(run.image_key), type: "image", name: "image.png" });
340
+ }
341
+ }
342
+ if (parts.length)
343
+ lines.push(parts.join(""));
344
+ }
345
+ return { text: lines.join("\n"), images };
346
+ }
347
+ // --- card actions ------------------------------------------------------------
348
+ async onAction(action, onMessage) {
349
+ // The thread root travels in the button payload (a callback does not say
350
+ // which topic its message lives in); a form submit carries it in the
351
+ // button's name. Absent both, the payload is not one Pier minted.
352
+ const payload = action.value?.key ?? "";
353
+ const formRoot = action.name?.startsWith(CWD_SUBMIT_PREFIX)
354
+ ? action.name.slice(CWD_SUBMIT_PREFIX.length)
355
+ : "";
356
+ const root = action.value?.root ?? formRoot;
357
+ if (!root) {
358
+ this.log(`card action without a thread root in ${action.chatId}, dropped`);
359
+ return;
360
+ }
361
+ const key = {
362
+ channelId: this.id,
363
+ conversationId: conversationId(action.chatId, root),
364
+ };
365
+ const admitted = this.gate.admit("action", action.chatId, {
366
+ isDm: this.deps.store.chat("lark", action.chatId)?.kind === "dm",
367
+ addressed: true, // clicking the bot's own button is addressing it
368
+ userId: action.operatorId,
369
+ });
370
+ if (!admitted)
371
+ return;
372
+ if (formRoot && action.formValue) {
373
+ await this.panel?.onCwdSubmit(key, action, root);
374
+ return;
375
+ }
376
+ // Panel clicks are namespaced `cfg:` and never reach the agent.
377
+ if (payload.startsWith(PANEL_PREFIX)) {
378
+ if (!(await this.panel?.onAction(action, key, payload, root))) {
379
+ this.log(`panel action ${payload} with no panel wired, dropped`);
380
+ }
381
+ return;
382
+ }
383
+ // A next-step button. The label travels in the value the platform echoes
384
+ // back — the only durable place, since Lark cannot return a 2.0 card
385
+ // (LarkActionValue documents the probe) — so a click needs no adapter
386
+ // state and survives a restart. A value without one is a stale card from
387
+ // before this convention, and the user clicked expecting something.
388
+ const label = payload.startsWith(OFFER_PREFIX) && typeof action.value?.label === "string"
389
+ ? action.value.label
390
+ : undefined;
391
+ if (label === undefined) {
392
+ this.log(`unknown or stale action ${payload} in chat ${action.chatId}`);
393
+ await this.api.replyCard(root, card([markdown(STALE_OPTION)]))
394
+ .catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
395
+ return;
396
+ }
397
+ // The taken row comes off (best-effort; see LarkOutbound.retire), and the
398
+ // pick is echoed — a bot cannot post as the user, so without the echo the
399
+ // topic shows an answer to a request nobody can see being made, and there
400
+ // is nothing to carry the eyes.
401
+ const sender = { id: action.operatorId, name: await this.userName(action.operatorId) };
402
+ await this.out.retire(action.messageId);
403
+ const echo = await this.api.replyCard(root, card([markdown(picked(label))]))
404
+ .catch((err) => {
405
+ this.log(`option echo failed: ${String(err)}`);
406
+ return undefined;
407
+ });
408
+ // No await between mark and dispatch — see onMessage.
409
+ if (echo?.messageId)
410
+ this.receipts.mark(key.conversationId, action.chatId, echo.messageId);
411
+ onMessage({
412
+ key,
413
+ senderId: action.operatorId,
414
+ sender,
415
+ text: label,
416
+ mode: "steer",
417
+ });
418
+ }
419
+ /**
420
+ * Stop the turn this conversation is running. The abort makes Pi end the
421
+ * turn, which reaches send() through the normal turn-end path and clears
422
+ * the 👀 receipts — so nothing here touches them.
423
+ */
424
+ async abortTurn(key, messageId) {
425
+ await this.deps.control?.abort(key);
426
+ await this.api.replyCard(messageId, card([markdown(STOPPED)]));
427
+ }
428
+ // --- bind ------------------------------------------------------------------
429
+ /**
430
+ * Tell an unbound DM sender what to do. Groups stay silent (see gate()),
431
+ * but a DM that swallows every message looks broken rather than locked.
432
+ */
433
+ async hintBind(userId, messageId) {
434
+ if (!this.gate.mayHint(userId))
435
+ return;
436
+ await this.api.replyCard(messageId, card([markdown(bindHint("`/bind <code>`"))]))
437
+ .catch((err) => this.log(`bind hint failed: ${String(err)}`));
438
+ }
439
+ async bind(userId, messageId, code) {
440
+ const name = await this.userName(userId);
441
+ const ok = this.deps.store.redeemBindCode("lark", code, { id: userId, name });
442
+ await this.api.replyCard(messageId, card([markdown(bindResult(ok, name))]));
443
+ }
444
+ // --- lookups ---------------------------------------------------------------
445
+ async userName(openId) {
446
+ const hit = this.names.get(openId);
447
+ if (hit)
448
+ return hit;
449
+ const name = await this.api.userName(openId).catch((err) => {
450
+ // The id is the honest fallback label; the reason still gets said.
451
+ this.log(`user lookup failed for ${openId}: ${String(err)}`);
452
+ return openId;
453
+ });
454
+ this.names.set(openId, name);
455
+ return name;
456
+ }
457
+ /** The message's attachments as the shared save loop wants them (the loop
458
+ * itself, size gate and lost markers included, is core/inbox.ts; the
459
+ * mid-stream refusal in download() names "too large" so the loop's marker
460
+ * stays honest when the metadata lied by omission). */
461
+ saveAttachments(messageId, files) {
462
+ return saveInboundAll(this.id, files.map((file) => ({
463
+ label: file.name ?? "attachment",
464
+ name: file.name,
465
+ mimeType: file.type === "image" ? "image/png" : "application/octet-stream",
466
+ size: file.size,
467
+ fetch: async () => this.api.download(messageId, file.key, file.type, MAX_INBOUND_BYTES),
468
+ })), this.log);
469
+ }
470
+ // --- outbound --------------------------------------------------------------
471
+ /**
472
+ * Called on every turn-end, empty text included: the turn settled with
473
+ * nothing to say, and the 👀 receipts still have to come off.
474
+ */
475
+ async send(conversation, reply) {
476
+ const { root } = parseConversation(conversation);
477
+ // Every id this adapter mints carries a thread root, so an empty one is a
478
+ // corrupted or foreign conversation id. Posting it would put an agent
479
+ // turn in the chat's main flow — the one thing this adapter promises
480
+ // never to do — so it is refused loudly instead, and the receipts still
481
+ // come off so no 👀 is stranded.
482
+ if (!root) {
483
+ this.log(`refusing to answer ${conversation}: no thread root in the conversation id`);
484
+ await this.receipts.settle(conversation);
485
+ return;
486
+ }
487
+ // settleAfter: the turn ended either way, and a 👀 left up because the
488
+ // reply failed to send looks like work until the stale sweep.
489
+ await this.receipts.settleAfter(conversation, () => this.out.reply(root, reply));
490
+ }
491
+ /** A system note, posted without touching the receipts: the turn it
492
+ * triggers has not ended yet. */
493
+ async notify(conversation, note) {
494
+ const { root } = parseConversation(conversation);
495
+ if (!root) {
496
+ this.log(`refusing to post a system note to ${conversation}: no thread root in the conversation id`);
497
+ return;
498
+ }
499
+ await this.out.note(root, note);
500
+ }
501
+ }
@@ -0,0 +1,19 @@
1
+ // What the shared control moments say — one spelling for three platforms.
2
+ //
3
+ // Bind, stop and the option echo behave identically everywhere by contract,
4
+ // and their lines were copied per adapter until Lark made three of each; the
5
+ // same wording drifting apart is how the panels went (see panel.ts). How a
6
+ // line is *sent* stays with the adapter; the only legitimate variation is the
7
+ // spelling of the bind command, so it is the parameter.
8
+ /** DM-only, throttled by Gatekeeper.mayHint; groups stay silent by contract. */
9
+ export const bindHint = (command) => `You are not bound yet. Ask the operator for a bind code, then send ${command}.`;
10
+ /** The answer to a bind attempt. The name arrives escaped by the caller. */
11
+ export const bindResult = (ok, name) => ok ? `Bound as ${name}.` : "That bind code is invalid or expired.";
12
+ /** Acknowledges /stop; the turn's own end still arrives through send(). */
13
+ export const STOPPED = "\u23f9 Stopped.";
14
+ /** A picked option, echoed because a bot cannot post as the user. */
15
+ export const picked = (label) => `\u25b8 ${label}`;
16
+ /** A click on options that are gone — retired, or from before this process'
17
+ * conventions. Said in the chat: the person clicked and would otherwise see
18
+ * nothing happen, which reads as broken (5b). */
19
+ export const STALE_OPTION = "⚠ That option is no longer available — please type the choice instead.";
@@ -14,6 +14,10 @@
14
14
  import { compact, thinkingLabel } from "../core/reply.js";
15
15
  export const PANEL_PREFIX = "cfg:";
16
16
  const MODELS_PER_PAGE = 8;
17
+ /** The cwd prompt's one sentence and placeholder — each platform owns only
18
+ * its widget's lead-in ("Reply with…", a modal hint, a form label). */
19
+ export const CWD_TAIL = "A new session starts there; the current one stays in its own directory.";
20
+ export const CWD_PLACEHOLDER = "/path/to/project";
17
21
  const onOff = (v) => (v ? "on" : "off");
18
22
  const btn = (label, action) => ({ label, action });
19
23
  export class ChatPanel {
@@ -86,6 +86,21 @@ export class Receipts {
86
86
  settle(conversationId) {
87
87
  return this.clear(this.ledger.take(conversationId));
88
88
  }
89
+ /**
90
+ * Deliver a turn and settle its receipts *whatever happens* — a 👀 left on
91
+ * a message because the reply failed to send sits there looking like work
92
+ * until the stale sweep. The try/finally was copied into all three
93
+ * adapters' send() before landing here; the error still propagates, because
94
+ * a failed delivery is the router's to report.
95
+ */
96
+ async settleAfter(conversationId, deliver) {
97
+ try {
98
+ await deliver();
99
+ }
100
+ finally {
101
+ await this.settle(conversationId);
102
+ }
103
+ }
89
104
  /**
90
105
  * Everything on the books at startup is orphaned — nothing in memory can be
91
106
  * ours yet — and past `staleMs` a receipt's turn is never going to settle.
@@ -59,7 +59,6 @@ export function registerChannelRoutes(app, store, runtime) {
59
59
  ...config,
60
60
  token: maskToken(config.token),
61
61
  appToken: maskToken(config.appToken),
62
- supported: platform === "telegram" || platform === "slack",
63
62
  });
64
63
  });
65
64
  app.put("/api/channels/:platform", async (c) => {