@timqi/pier 0.0.1

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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,113 @@
1
+ // Telegram's half of the settings panel: HTML markup, an inline keyboard, and
2
+ // a forced reply for the one typed answer. The panel itself lives in
3
+ // `panel.ts`.
4
+ //
5
+ // Asking for a working directory costs a Map here: a forced reply arrives as
6
+ // an ordinary message, so the prompt's message id has to be remembered to
7
+ // recognize the answer. Slack's modal carries that context itself.
8
+ import { ChatPanel, PANEL_PREFIX, } from "./panel.js";
9
+ import { escapeHtml as esc } from "./telegram-render.js";
10
+ const button = (b) => ({ text: b.label, callback_data: `${PANEL_PREFIX}${b.action}` });
11
+ export class TelegramPanel extends ChatPanel {
12
+ deps;
13
+ platform = "telegram";
14
+ fence = ["<code>", "</code>"];
15
+ /** Conversations waiting for a typed working directory (ForceReply). */
16
+ cwdPrompts = new Map();
17
+ constructor(deps) {
18
+ super(deps);
19
+ this.deps = deps;
20
+ }
21
+ esc(text) {
22
+ return esc(text);
23
+ }
24
+ /** Topics are a Telegram-only gate, and only on a forum. */
25
+ gateExtras(chat, policy) {
26
+ return chat.kind === "forum" ? ` · topics ${policy.topicMode ? "on" : "off"}` : "";
27
+ }
28
+ // --- rendering ---------------------------------------------------------------
29
+ text(view, note) {
30
+ const body = view.groups
31
+ .map((g) => [`<b>${g.title}</b>${g.suffix ?? ""}`, ...g.lines].join("\n"))
32
+ .join("\n\n");
33
+ return note ? `${body}\n\n<i>${esc(note)}</i>` : body;
34
+ }
35
+ /** A long model id does not share a row with anything. */
36
+ keyboard(view) {
37
+ return {
38
+ inline_keyboard: [
39
+ ...(view.picks ?? []).map((pick) => [button(pick)]),
40
+ ...view.rows.map((row) => row.map(button)),
41
+ ],
42
+ };
43
+ }
44
+ /** Open a fresh panel, replacing whichever one this conversation had. */
45
+ async open(key, chatId, topicId) {
46
+ const view = await this.view(key, chatId);
47
+ const sent = await this.deps.api.sendMessage({
48
+ chat_id: chatId,
49
+ message_thread_id: topicId,
50
+ text: this.text(view),
51
+ parse_mode: "HTML",
52
+ reply_markup: this.keyboard(view),
53
+ });
54
+ this.remember(key, { chatId, topicId, messageId: sent.message_id, models: [] });
55
+ }
56
+ async draw(state, view, note) {
57
+ await this.deps.api.editMessage({
58
+ chat_id: state.chatId,
59
+ message_id: state.messageId,
60
+ text: this.text(view, note),
61
+ parse_mode: "HTML",
62
+ reply_markup: this.keyboard(view),
63
+ }).catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
64
+ }
65
+ async erase(state) {
66
+ await this.deps.api.deleteMessage(state.chatId, state.messageId)
67
+ .catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
68
+ }
69
+ // --- actions -----------------------------------------------------------------
70
+ /**
71
+ * Handle a `cfg:` button. Returns false when the payload is not ours, so the
72
+ * caller can treat it as one of the agent's next-step labels instead.
73
+ */
74
+ async onCallback(query, key) {
75
+ return this.dispatch(key, query.data ?? "", undefined, async () => {
76
+ const message = query.message;
77
+ if (message)
78
+ await this.open(key, String(message.chat.id), message.message_thread_id);
79
+ });
80
+ }
81
+ // --- working directory (one typed answer) ------------------------------------
82
+ async promptCwd(key, state) {
83
+ const sent = await this.deps.api.sendMessage({
84
+ chat_id: state.chatId,
85
+ message_thread_id: state.topicId,
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" },
89
+ });
90
+ this.cwdPrompts.set(key.conversationId, sent.message_id);
91
+ }
92
+ /**
93
+ * Consume a reply to the working-directory prompt. Returns true when this
94
+ * message was that answer and must not reach the agent.
95
+ */
96
+ async consumeCwdReply(msg, key) {
97
+ const pending = this.cwdPrompts.get(key.conversationId);
98
+ if (!pending || msg.reply_to_message?.message_id !== pending)
99
+ return false;
100
+ this.cwdPrompts.delete(key.conversationId);
101
+ const path = (msg.text ?? "").trim();
102
+ const started = await this.startSessionIn(key, path);
103
+ // The answer was typed in the chat, so the outcome is said in the chat:
104
+ // a panel note alone would be easy to miss under one's own message.
105
+ await this.deps.api.sendMessage({
106
+ chat_id: msg.chat.id,
107
+ message_thread_id: msg.message_thread_id,
108
+ text: "error" in started ? started.error : `New session in <code>${esc(path)}</code>.`,
109
+ parse_mode: "HTML",
110
+ });
111
+ return true;
112
+ }
113
+ }
@@ -0,0 +1,96 @@
1
+ import { chunkText } from "./chunk.js";
2
+ // How a reply looks on Telegram: text and buttons.
3
+ //
4
+ // Markdown → Telegram Bot API HTML. Telegram's parser accepts a tiny tag set
5
+ // and rejects the whole message on anything else, so we escape first and
6
+ // reintroduce exactly the tags it documents: b, i, s, code, pre, a.
7
+ // Unsupported markdown (tables, images, nested lists) degrades to plain text
8
+ // rather than losing the message.
9
+ const MAX_CHARS = 3800; // Telegram's hard limit is 4096; leave room for tags.
10
+ /** Shared: the adapter and the panel escape plain text with this too. */
11
+ export const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
12
+ /** Inline emphasis, applied after escaping so `<b>` can only come from us. */
13
+ function inline(text) {
14
+ return text
15
+ // Links first: their label may itself carry emphasis.
16
+ .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => `<a href="${url.replace(/"/g, "&quot;")}">${label}</a>`)
17
+ .replace(/\*\*([^\n*]+)\*\*/g, "<b>$1</b>")
18
+ .replace(/~~([^\n~]+)~~/g, "<s>$1</s>")
19
+ .replace(/(^|[\s(])[*_]([^\n*_]+)[*_](?=[\s).,!?:;]|$)/g, "$1<i>$2</i>")
20
+ // Headings carry no size in Telegram; bold is the closest honest render.
21
+ .replace(/^#{1,6}[ \t]+(.+)$/gm, "<b>$1</b>");
22
+ }
23
+ /**
24
+ * Render one assistant turn. Code spans and fences are extracted before
25
+ * escaping so emphasis inside them stays literal.
26
+ */
27
+ export function toTelegramHtml(markdown) {
28
+ const stash = [];
29
+ // Private-use sentinels: markdown can't contain them, so a stashed block
30
+ // can't be re-matched by the escaping and emphasis passes that follow.
31
+ const keep = (html) => `\uE000${stash.push(html) - 1}\uE001`;
32
+ let out = markdown.replace(/```([\w.+-]*)\n?([\s\S]*?)```/g, (_m, lang, code) => keep(lang
33
+ ? `<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(code.replace(/\n+$/, ""))}</code></pre>`
34
+ : `<pre>${escapeHtml(code.replace(/\n+$/, ""))}</pre>`));
35
+ out = out.replace(/`([^`\n]+)`/g, (_m, code) => keep(`<code>${escapeHtml(code)}</code>`));
36
+ out = inline(escapeHtml(out));
37
+ return out.replace(/\uE000(\d+)\uE001/g, (_m, i) => stash[Number(i)] ?? "");
38
+ }
39
+ /**
40
+ * Split rendered HTML into sendable chunks. A turn long enough to need this can
41
+ * still be cut inside a <pre> block; Telegram closes the tag on its own and the
42
+ * text survives, which beats dropping the turn. (Slack cannot do that, which is
43
+ * why its renderer re-balances fences after the cut.)
44
+ */
45
+ export const chunk = (html) => chunkText(html, MAX_CHARS);
46
+ // --- next-step buttons -------------------------------------------------------
47
+ /**
48
+ * Telegram caps `callback_data` at 64 *bytes* — about 21 CJK characters, far
49
+ * too little to carry a label. Buttons send an index instead, and the label is
50
+ * read back off the message's own keyboard.
51
+ */
52
+ export const OFFER_PREFIX = "sg:";
53
+ // Buttons in one row share the row's width, so packing is budgeted by display
54
+ // width rather than count: a CJK glyph takes about twice an ASCII one.
55
+ const ROW_WIDTH = 26;
56
+ const ROW_BUTTONS = 3;
57
+ /** Rough rendered width: CJK, fullwidth punctuation and emoji take two cells. */
58
+ const displayWidth = (label) => [...label].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1), 0);
59
+ /**
60
+ * Pack short labels onto shared rows, keeping the offered order. A row that is
61
+ * already wide stops accepting: buttons in one Telegram row split the width
62
+ * evenly, so squeezing a long label in truncates everything beside it.
63
+ */
64
+ export function keyboard(labels) {
65
+ if (!labels.length)
66
+ return undefined;
67
+ const rows = [];
68
+ let used = 0;
69
+ labels.forEach((label, index) => {
70
+ const width = displayWidth(label);
71
+ const row = rows.at(-1);
72
+ const button = { text: label, callback_data: `${OFFER_PREFIX}${index}` };
73
+ if (!row || row.length >= ROW_BUTTONS || used + width > ROW_WIDTH) {
74
+ rows.push([button]);
75
+ used = width;
76
+ return;
77
+ }
78
+ row.push(button);
79
+ used += width;
80
+ });
81
+ return { inline_keyboard: rows };
82
+ }
83
+ /**
84
+ * The label a next-step payload stands for. Read off the tapped message's own
85
+ * keyboard, which Telegram echoes back — so a button keeps working across a
86
+ * restart or a config reload, where an in-memory offer list would not.
87
+ * A payload we never wrote (an older label-as-payload button) passes through.
88
+ */
89
+ export function offeredLabel(msg, data) {
90
+ if (!data.startsWith(OFFER_PREFIX))
91
+ return data;
92
+ return msg.reply_markup?.inline_keyboard
93
+ .flat()
94
+ .find((button) => button.callback_data === data)
95
+ ?.text;
96
+ }
@@ -0,0 +1,473 @@
1
+ // Telegram adapter: normalize inbound updates, render outbound turns.
2
+ //
3
+ // Three behaviours are Telegram-specific and live only here:
4
+ // - Topic mode. A message that lands in a forum group's General opens a fresh
5
+ // topic named after its first line, and the conversation (hence the Pi
6
+ // session) is that topic. One group therefore hosts many parallel sessions.
7
+ // - Reaction receipts. Intermediate reasoning is never posted to IM; instead
8
+ // every message that entered a turn wears 👀 until the turn settles, so a
9
+ // steered message gets feedback without a line in the chat.
10
+ // - Bind. `/bind <code>` in a DM redeems a Console-issued code.
11
+ //
12
+ // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
13
+ // config.ts, platform-blind and shared with the adapters still to come.
14
+ import { formatTurnMeta, originLabel } from "../core/reply.js";
15
+ import { logger } from "../log.js";
16
+ import { Chains } from "./chains.js";
17
+ import { parseCommand } from "./commands.js";
18
+ import { Gatekeeper } from "./gatekeeper.js";
19
+ import { ReceiptLedger, Receipts } from "./receipts.js";
20
+ import { TelegramPanel } from "./telegram-panel.js";
21
+ import { chunk, escapeHtml, keyboard, offeredLabel, toTelegramHtml } from "./telegram-render.js";
22
+ import { TelegramApi, } from "./telegram-api.js";
23
+ const WORKING = "👀";
24
+ const POLL_SECONDS = 30;
25
+ // Backpressure: how many chats may be mid-handling before the poll loop waits.
26
+ // Bounds memory without ever advancing the offset past what we accepted.
27
+ const MAX_ACTIVE_CHATS = 16;
28
+ // Longest a 👀 may sit before we assume its turn will never settle (a dispatch
29
+ // that failed, a session that died). Generous: a real coding turn can be long.
30
+ const RECEIPT_STALE_MS = 30 * 60_000;
31
+ // Longest stop() waits for in-flight handlers before letting reload() proceed.
32
+ const DRAIN_TIMEOUT_MS = 5000;
33
+ const TOPIC_TITLE_MAX = 60;
34
+ /**
35
+ * A forum conversation is `<chatId>/<topicId>`; anything else is `<chatId>`.
36
+ * This pair is the only definition of the format — control.ts and the panel
37
+ * decode with it rather than splitting on "/" themselves.
38
+ */
39
+ const conversationId = (chatId, topicId) => topicId ? `${chatId}/${topicId}` : String(chatId);
40
+ export const parseConversation = (id) => {
41
+ const [chatId = "", topic] = id.split("/");
42
+ const topicId = topic ? Number(topic) : undefined;
43
+ return { chatId, topicId: Number.isSafeInteger(topicId) ? topicId : undefined };
44
+ };
45
+ /** Telegram numbers General as topic 1 and omits the id on plain groups. */
46
+ const inGeneral = (msg) => !msg.message_thread_id || msg.message_thread_id === 1;
47
+ function topicTitle(text) {
48
+ const line = (text.split("\n").find((l) => l.trim()) ?? "").trim();
49
+ if (!line)
50
+ return `Session ${new Date().toISOString().slice(5, 16).replace("T", " ")}`;
51
+ return line.length > TOPIC_TITLE_MAX ? `${line.slice(0, TOPIC_TITLE_MAX - 3).trimEnd()}...` : line;
52
+ }
53
+ export class TelegramChannel {
54
+ deps;
55
+ id = "telegram";
56
+ api;
57
+ log;
58
+ /** 👀 lifecycle, durable; see receipts.ts for why it is not just a Map. */
59
+ receipts;
60
+ /** Ordering per chat, concurrency across them; see chains.ts. */
61
+ chains;
62
+ /** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
63
+ gate;
64
+ /** The in-chat settings panel; absent when no control was wired (tests). */
65
+ panel;
66
+ me;
67
+ offset;
68
+ running = false;
69
+ constructor(deps) {
70
+ this.deps = deps;
71
+ this.api = deps.client ?? new TelegramApi(deps.store.get("telegram").token);
72
+ this.log = deps.log ?? ((m) => logger("telegram").warn(m));
73
+ // No cap here: the poll loop applies backpressure itself, before advancing
74
+ // the ack cursor past an update it has not accepted.
75
+ this.chains = new Chains(this.log);
76
+ this.gate = new Gatekeeper(deps.store, "telegram", this.log);
77
+ this.receipts = new Receipts(
78
+ // The ledger keeps message ids as opaque strings (a Slack ts is not a
79
+ // number); Telegram's own are numeric, so the cast happens right here at
80
+ // the API boundary rather than leaking a platform's id type into shared code.
81
+ { setReaction: (chatId, messageId, emoji) => this.api.setReaction(chatId, Number(messageId), emoji) }, deps.receipts ?? new ReceiptLedger("telegram"), this.log, WORKING, RECEIPT_STALE_MS);
82
+ if (deps.control) {
83
+ this.panel = new TelegramPanel({
84
+ api: this.api,
85
+ control: deps.control,
86
+ store: deps.store,
87
+ log: this.log,
88
+ });
89
+ }
90
+ }
91
+ async start(onMessage) {
92
+ const me = await this.api.getMe();
93
+ this.me = { id: me.id, username: me.username ?? "" };
94
+ if (!this.me.username) {
95
+ // Without a handle, "was I mentioned?" can only ever answer no, so every
96
+ // group with require-mention on goes silent. Loud, not a debug line.
97
+ this.log("bot has no username: mention detection is disabled");
98
+ }
99
+ this.running = true;
100
+ // Best-effort and off the critical path.
101
+ void this.receipts.sweep(true);
102
+ void this.poll(onMessage);
103
+ }
104
+ async stop() {
105
+ this.running = false;
106
+ // Let in-flight updates finish: reload() starts a replacement right after,
107
+ // and two adapters handling one message would prompt the session twice.
108
+ // Bounded, because reload() runs on the Console's save request — a stuck
109
+ // handler must not hold that open.
110
+ await this.chains.drain(DRAIN_TIMEOUT_MS);
111
+ }
112
+ // --- inbound ---------------------------------------------------------------
113
+ async poll(onMessage) {
114
+ while (this.running) {
115
+ try {
116
+ // Floor on an empty round trip: getUpdates is supposed to block for
117
+ // POLL_SECONDS, and a proxy that answers instantly would otherwise
118
+ // turn this into a hot loop.
119
+ const startedAt = Date.now();
120
+ const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
121
+ if (!this.running)
122
+ return;
123
+ void this.receipts.sweep();
124
+ if (!updates.length && Date.now() - startedAt < 1000) {
125
+ await new Promise((r) => setTimeout(r, 1000));
126
+ }
127
+ for (const update of updates) {
128
+ if (!this.running)
129
+ return;
130
+ while (this.chains.size >= MAX_ACTIVE_CHATS)
131
+ await this.chains.oldest();
132
+ this.offset = update.update_id + 1;
133
+ const chat = update.message?.chat.id ?? update.callback_query?.message?.chat.id;
134
+ if (chat === undefined)
135
+ continue; // malformed: no chat to answer in
136
+ this.chains.run(String(chat), async () => {
137
+ try {
138
+ if (update.callback_query)
139
+ await this.onCallback(update.callback_query, onMessage);
140
+ else if (update.message)
141
+ await this.onMessage(update.message, onMessage);
142
+ }
143
+ catch (err) {
144
+ this.log(`update ${update.update_id} dropped: ${String(err)}`);
145
+ }
146
+ });
147
+ }
148
+ }
149
+ catch (err) {
150
+ if (!this.running)
151
+ return;
152
+ this.log(`poll failed, retrying: ${String(err)}`);
153
+ await new Promise((r) => setTimeout(r, 3000));
154
+ }
155
+ }
156
+ }
157
+ async onMessage(msg, onMessage) {
158
+ if (!msg.from || msg.from.id === this.me?.id)
159
+ return; // own echo, or malformed
160
+ const raw = (msg.text ?? msg.caption ?? "").trim();
161
+ if (!raw && !msg.photo?.length)
162
+ return;
163
+ const chatId = String(msg.chat.id);
164
+ const isDm = msg.chat.type === "private";
165
+ const kind = isDm ? "dm" : msg.chat.is_forum ? "forum" : "group";
166
+ const name = msg.chat.title ?? [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" ");
167
+ this.deps.store.discoverChat("telegram", { id: chatId, name: name || chatId, kind });
168
+ const text = this.stripMention(raw, msg);
169
+ const command = parseCommand(text);
170
+ // A command aimed at another bot in the same group is not ours to answer.
171
+ const mine = !command?.target || command.target.toLowerCase() === this.me?.username.toLowerCase();
172
+ const bindRequest = mine && command?.name === "bind" && isDm;
173
+ const admitted = this.gate.admit("message", chatId, {
174
+ isDm,
175
+ addressed: this.addressed(raw, msg),
176
+ userId: String(msg.from.id),
177
+ bindRequest,
178
+ });
179
+ if (!admitted) {
180
+ if (isDm)
181
+ await this.hintBind(msg);
182
+ return;
183
+ }
184
+ if (bindRequest)
185
+ return this.bind(msg, command?.args ?? "");
186
+ if (mine && command?.name === "stop")
187
+ return this.abortTurn(msg);
188
+ const here = {
189
+ channelId: this.id,
190
+ conversationId: conversationId(chatId, msg.message_thread_id),
191
+ };
192
+ // A typed answer to the panel's directory prompt, not a prompt for the agent.
193
+ if (await this.panel?.consumeCwdReply(msg, here))
194
+ return;
195
+ // `@bot` on its own (text is empty once the mention is stripped) and
196
+ // `/settings` are the same request: show me this conversation's settings.
197
+ if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length))) {
198
+ return this.panel.open(here, chatId, msg.message_thread_id);
199
+ }
200
+ // Downloading only past the gate: an unauthorized sender must not be able
201
+ // to make the bot pull bytes on their behalf.
202
+ const images = await this.photos(msg);
203
+ const topicId = await this.routeTopic(msg, text);
204
+ const key = { channelId: this.id, conversationId: conversationId(chatId, topicId) };
205
+ this.receipts.mark(key.conversationId, chatId, String(msg.message_id));
206
+ // IM messages steer by default: a follow-up that waits for the turn to end
207
+ // is the wrong default when the human is watching a 👀 in a chat window.
208
+ onMessage({
209
+ key,
210
+ senderId: String(msg.from.id),
211
+ // A group is many people talking into one session; the update already
212
+ // carries the name, so no lookup is needed here.
213
+ sender: { id: String(msg.from.id), name: senderName(msg.from) },
214
+ text,
215
+ images,
216
+ mode: "steer",
217
+ });
218
+ }
219
+ /** Quick-reply buttons send their own label back as an ordinary message. */
220
+ async onCallback(query, onMessage) {
221
+ await this.api.answerCallbackQuery(query.id).catch(() => { });
222
+ const msg = query.message;
223
+ if (!msg || !query.data)
224
+ return;
225
+ const chatId = String(msg.chat.id);
226
+ const admitted = this.gate.admit("callback", chatId, {
227
+ isDm: msg.chat.type === "private",
228
+ addressed: true, // pressing the bot's own button is addressing it
229
+ userId: String(query.from.id),
230
+ });
231
+ if (!admitted)
232
+ return;
233
+ const key = {
234
+ channelId: this.id,
235
+ conversationId: conversationId(chatId, msg.message_thread_id),
236
+ };
237
+ // Panel taps are namespaced `cfg:` and never reach the agent.
238
+ if (await this.panel?.onCallback(query, key))
239
+ return;
240
+ const text = offeredLabel(msg, query.data);
241
+ if (text === undefined) {
242
+ await this.api.answerCallbackQuery(query.id, "That option is no longer on this message.")
243
+ .catch(() => { });
244
+ return;
245
+ }
246
+ // The options belonged to the turn that just ended; once one is taken the
247
+ // rest answer a question the conversation has moved past (the web drops
248
+ // them for the same reason).
249
+ await this.api.clearKeyboard(chatId, msg.message_id).catch(() => { });
250
+ // A bot cannot post as the user, so the pick is echoed and marked as one.
251
+ // Without it the chat shows an answer to a request nobody can see being
252
+ // made, and there is no message of the user's to carry the eyes.
253
+ //
254
+ // No reply quote: the marker already says what this is, and quoting a long
255
+ // answer to show which of its buttons was tapped costs more space than it
256
+ // explains.
257
+ const echo = await this.api.sendMessage({
258
+ chat_id: chatId,
259
+ message_thread_id: msg.message_thread_id,
260
+ text: `\u25b8 ${escapeHtml(text)}`,
261
+ parse_mode: "HTML",
262
+ }).catch((err) => {
263
+ this.log(`option echo failed: ${String(err)}`);
264
+ return undefined;
265
+ });
266
+ // The receipt goes on the echo, not on the bot message that held the
267
+ // buttons: the eyes mean "this input is being worked on".
268
+ if (echo)
269
+ this.receipts.mark(key.conversationId, chatId, String(echo.message_id));
270
+ onMessage({ key, senderId: String(query.from.id), text, mode: "steer" });
271
+ }
272
+ /**
273
+ * Stop the turn this conversation is running. The abort makes Pi end the
274
+ * turn, which reaches send() through the normal turn-end path and clears the
275
+ * 👀 receipts — so nothing here touches them.
276
+ */
277
+ async abortTurn(msg) {
278
+ const key = {
279
+ channelId: this.id,
280
+ conversationId: conversationId(msg.chat.id, msg.message_thread_id),
281
+ };
282
+ await this.deps.control?.abort(key);
283
+ await this.api.sendMessage({
284
+ chat_id: msg.chat.id,
285
+ message_thread_id: msg.message_thread_id,
286
+ text: "⏹ Stopped.",
287
+ });
288
+ }
289
+ /**
290
+ * Tell an unbound DM sender what to do. Groups stay silent (see gate()), but
291
+ * a DM that swallows every message looks broken rather than locked.
292
+ */
293
+ async hintBind(msg) {
294
+ if (!this.gate.mayHint(String(msg.from?.id ?? "")))
295
+ return;
296
+ await this.api.sendMessage({
297
+ chat_id: msg.chat.id,
298
+ text: "You are not bound yet. Ask the operator for a bind code, then send /bind <code>.",
299
+ }).catch((err) => this.log(`bind hint failed: ${String(err)}`));
300
+ }
301
+ async bind(msg, code) {
302
+ const user = msg.from;
303
+ const name = senderName(user);
304
+ const ok = this.deps.store.redeemBindCode("telegram", code, { id: String(user.id), name });
305
+ await this.api.sendMessage({
306
+ chat_id: msg.chat.id,
307
+ text: ok ? `Bound as ${name}.` : "That bind code is invalid or expired.",
308
+ });
309
+ }
310
+ /**
311
+ * Topic mode: a message arriving in a forum group's General starts a new
312
+ * topic, so every request gets its own thread and its own Pi session. A
313
+ * reply or a slash command stays put — it is continuing something, not
314
+ * starting it. Failure falls back to the current thread rather than losing
315
+ * the message.
316
+ */
317
+ async routeTopic(msg, text) {
318
+ // Every reason to decline is named and logged: "why did it not open a
319
+ // topic" is the question this feature will be asked forever, and silence
320
+ // makes six invisible conditions indistinguishable from a bug.
321
+ const decline = !this.deps.store.policy("telegram", String(msg.chat.id)).topicMode
322
+ ? "topic mode off for this chat"
323
+ : msg.chat.type !== "supergroup"
324
+ ? `chat is a ${msg.chat.type}, not a supergroup`
325
+ : !msg.chat.is_forum
326
+ ? "group has Topics disabled in Telegram"
327
+ : !inGeneral(msg)
328
+ ? `already inside topic ${msg.message_thread_id}`
329
+ : msg.reply_to_message
330
+ ? "message is a reply, so it continues an existing thread"
331
+ : text.startsWith("/")
332
+ ? "message is a command"
333
+ : "";
334
+ if (decline) {
335
+ this.log(`no new topic in chat ${msg.chat.id}: ${decline}`);
336
+ return msg.message_thread_id;
337
+ }
338
+ const title = topicTitle(text);
339
+ try {
340
+ const topic = await this.api.createForumTopic(msg.chat.id, title);
341
+ // The whole point of the notice is to get out of General, so the title is
342
+ // the link into the new topic rather than decoration.
343
+ await this.api.sendMessage({
344
+ chat_id: msg.chat.id,
345
+ text: `→ <a href="${topicLink(msg.chat, topic.message_thread_id)}">${escapeHtml(title)}</a>`,
346
+ parse_mode: "HTML",
347
+ message_thread_id: msg.message_thread_id,
348
+ reply_to_message_id: msg.message_id,
349
+ }).catch(() => { });
350
+ return topic.message_thread_id;
351
+ }
352
+ catch (err) {
353
+ this.log(`topic creation failed, staying in General: ${String(err)}`);
354
+ return msg.message_thread_id;
355
+ }
356
+ }
357
+ async photos(msg) {
358
+ const largest = msg.photo?.at(-1);
359
+ if (!largest)
360
+ return [];
361
+ try {
362
+ return [await this.api.downloadPhoto(largest.file_id)];
363
+ }
364
+ catch (err) {
365
+ this.log(`photo download failed: ${String(err)}`);
366
+ return [];
367
+ }
368
+ }
369
+ // --- addressing ------------------------------------------------------------
370
+ /** Mentioned, replying to the bot, or a slash command aimed at this bot. */
371
+ addressed(text, msg) {
372
+ if (msg.reply_to_message?.from?.id === this.me?.id)
373
+ return true;
374
+ if (text.startsWith("/")) {
375
+ const target = /^\/\S+?@(\S+)/.exec(text)?.[1];
376
+ return !target || target.toLowerCase() === this.me?.username.toLowerCase();
377
+ }
378
+ const handle = `@${this.me?.username.toLowerCase()}`;
379
+ return !!this.me?.username && text.toLowerCase().includes(handle);
380
+ }
381
+ /** A leading @bot is addressing, not content — the agent should not see it. */
382
+ stripMention(text, msg) {
383
+ const handle = `@${this.me?.username ?? ""}`;
384
+ if (!this.me?.username)
385
+ return text;
386
+ const mention = msg.entities?.find((e) => e.type === "mention" && e.offset === 0);
387
+ if (mention && text.slice(0, mention.length).toLowerCase() === handle.toLowerCase()) {
388
+ return text.slice(mention.length).replace(/^[\s,:-]+/, "");
389
+ }
390
+ return text.toLowerCase().startsWith(handle.toLowerCase())
391
+ ? text.slice(handle.length).replace(/^[\s,:-]+/, "")
392
+ : text;
393
+ }
394
+ // --- outbound --------------------------------------------------------------
395
+ /**
396
+ * Called on every turn-end, empty text included: the turn settled with
397
+ * nothing to say, and the 👀 receipts still have to come off.
398
+ */
399
+ async send(conversation, reply) {
400
+ const { chatId, topicId } = parseConversation(conversation);
401
+ const text = reply.text.trim();
402
+ // A turn that produced no text still posts its footer, and says which kind
403
+ // of nothing it was: total silence is indistinguishable from a crash, and
404
+ // the person waiting cannot tell. See AGENTS.md — an empty turn is still an
405
+ // event, and an event nobody can see is not observable.
406
+ const buttons = keyboard(reply.suggestions);
407
+ // Options count as a reply: the buttons are the answer.
408
+ const quiet = text || buttons
409
+ ? ""
410
+ : reply.silence
411
+ ? `<i>stayed silent \u2014 ${escapeHtml(reply.silence)}</i>`
412
+ : "<i>no reply</i>";
413
+ const body = (text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta);
414
+ try {
415
+ if (body.trim()) {
416
+ const parts = chunk(body);
417
+ for (const [i, part] of parts.entries()) {
418
+ await this.api.sendMessage({
419
+ chat_id: chatId,
420
+ message_thread_id: topicId,
421
+ text: part,
422
+ parse_mode: "HTML",
423
+ // Next-step buttons ride the last chunk; a click sends the label.
424
+ reply_markup: i === parts.length - 1 ? buttons : undefined,
425
+ });
426
+ }
427
+ }
428
+ }
429
+ finally {
430
+ // Always: a 👀 left up because the reply failed would sit there until the
431
+ // stale sweep, looking like the agent is still working.
432
+ await this.receipts.settle(conversation);
433
+ }
434
+ }
435
+ /**
436
+ * A system note: quoted, labelled with where it came from, and deliberately
437
+ * plain — no buttons, no turn footer, and the 👀 receipts stay up, because
438
+ * the turn this input triggers has not ended yet.
439
+ */
440
+ async notify(conversation, note) {
441
+ const { chatId, topicId } = parseConversation(conversation);
442
+ const label = originLabel(note.origin);
443
+ for (const part of chunk(`<i>${label}</i>\n<blockquote>${toTelegramHtml(note.text)}</blockquote>`)) {
444
+ await this.api.sendMessage({
445
+ chat_id: chatId,
446
+ message_thread_id: topicId,
447
+ text: part,
448
+ parse_mode: "HTML",
449
+ });
450
+ }
451
+ }
452
+ }
453
+ /** Display name from an update, which always carries enough to build one. */
454
+ const senderName = (user) => [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username || String(user.id);
455
+ /**
456
+ * Deep link to a forum topic. A public supergroup links by username; a private
457
+ * one uses the `/c/<internal id>` form, which is the chat id with its `-100`
458
+ * supergroup prefix removed. Both only resolve for members — exactly the
459
+ * audience standing in General.
460
+ */
461
+ function topicLink(chat, topicId) {
462
+ if (chat.username)
463
+ return `https://t.me/${chat.username}/${topicId}`;
464
+ const internal = String(chat.id).replace(/^-100(?=\d)/, "").replace(/^-/, "");
465
+ return `https://t.me/c/${internal}/${topicId}`;
466
+ }
467
+ /**
468
+ * The web shows a turn's cost on hover; IM has none, so it becomes a footer.
469
+ * One newline, not a blank line: Telegram has no small or muted text, so the
470
+ * only way to make it read as a footnote instead of its own paragraph is to
471
+ * keep it tucked against the reply.
472
+ */
473
+ const turnFooter = (meta) => meta ? `\n<i>${formatTurnMeta(meta)}</i>` : "";