@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,121 @@
1
+ // How a turn becomes messages in a Slack thread.
2
+ //
3
+ // Split out of the adapter because it is a separate decision from routing
4
+ // inbound traffic: which renderer to use, how to chunk against that renderer's
5
+ // limit, and what an empty turn still has to say. The adapter keeps the 👀
6
+ // receipts, because those are about the turn ending, not about what was said.
7
+ import { formatTurnMeta, originLabel } from "../core/reply.js";
8
+ import { isBlockRejection } from "./slack-api.js";
9
+ import { actions, chunk, context, escapeMrkdwn, markdown, MARKDOWN_MAX, MRKDWN_MAX, sections, toMrkdwn, } from "./slack-render.js";
10
+ /**
11
+ * The web shows a turn's cost on hover. Slack has a `context` block — genuinely
12
+ * small, muted text — so unlike Telegram the footer needs no italic hack to
13
+ * read as a footnote.
14
+ */
15
+ const footerText = (meta) => escapeMrkdwn(formatTurnMeta(meta));
16
+ export class SlackOutbound {
17
+ api;
18
+ log;
19
+ /**
20
+ * Latched off for the process on the first refusal, so the failed round trip
21
+ * is paid once rather than once per message.
22
+ */
23
+ markdownBlocks = true;
24
+ constructor(api, log) {
25
+ this.api = api;
26
+ this.log = log;
27
+ }
28
+ /**
29
+ * Post one turn, empty text included: the turn settled with nothing to say,
30
+ * and that is still something to show.
31
+ */
32
+ async reply(channel, threadTs, reply) {
33
+ const text = reply.text.trim();
34
+ const footer = reply.meta ? footerText(reply.meta) : "";
35
+ const row = actions(reply.suggestions);
36
+ // A turn that produced no text still posts its footer, and says which kind
37
+ // of nothing it was. Silence must be *observable*: total silence is
38
+ // indistinguishable from a crash, a dropped connection or a bug, and the
39
+ // person waiting has no way to tell. A muted one-liner is the cheapest
40
+ // honest answer.
41
+ // Options count as a reply: the buttons are the answer, so a turn that is
42
+ // only its options is not "nothing".
43
+ const quiet = text || row
44
+ ? ""
45
+ : reply.silence
46
+ ? `_stayed silent — ${escapeMrkdwn(reply.silence)}_`
47
+ : "_no reply_";
48
+ if (!(text || row || footer || quiet))
49
+ return;
50
+ const parts = text ? chunk(text, this.budget()) : [""];
51
+ for (const [i, part] of parts.entries()) {
52
+ const last = i === parts.length - 1;
53
+ // The footer and the buttons ride the last chunk only. The quiet marker
54
+ // shares the footer's block, so an empty turn is one muted line rather
55
+ // than two.
56
+ const note = last ? [quiet, footer].filter(Boolean).join(" · ") : "";
57
+ await this.post(channel, threadTs, part, [
58
+ ...(note ? [context(note)] : []),
59
+ ...(last && row ? [row] : []),
60
+ ]);
61
+ }
62
+ }
63
+ /**
64
+ * A system note: quoted, labelled with where it came from, and deliberately
65
+ * plain — no buttons and no turn footer, because the turn this input
66
+ * triggers has not ended yet.
67
+ */
68
+ async note(channel, threadTs, note) {
69
+ // Markdown's own blockquote, so the note reads as quoted on either path.
70
+ const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
71
+ for (const part of chunk(`_${originLabel(note.origin)}_\n${body}`, this.budget())) {
72
+ await this.post(channel, threadTs, part, []);
73
+ }
74
+ }
75
+ /** Which budget `chunk()` should respect, given the path we are on. */
76
+ budget() {
77
+ return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
78
+ }
79
+ /**
80
+ * Post one message's body, preferring Slack's own markdown renderer.
81
+ *
82
+ * The `markdown` block takes the agent's markdown unmodified — tables,
83
+ * headers and nested lists all survive, none of which the mrkdwn subset can
84
+ * express — and the client never folds it behind "Show more". It is recent
85
+ * enough to be refused by an older workspace, so a rejection degrades to the
86
+ * translated mrkdwn path instead of losing the turn.
87
+ */
88
+ async post(channel, threadTs, body, trailing) {
89
+ // `text` is the notification and accessibility fallback, never shown
90
+ // beside the blocks.
91
+ const notice = body || trailing.length ? body || "…" : "";
92
+ if (this.markdownBlocks) {
93
+ const blocks = [...(body ? [markdown(body)] : []), ...trailing];
94
+ if (!blocks.length)
95
+ return;
96
+ try {
97
+ await this.api.postMessage({ channel, thread_ts: threadTs, text: notice, blocks });
98
+ return;
99
+ }
100
+ catch (err) {
101
+ if (!isBlockRejection(err))
102
+ throw err;
103
+ this.markdownBlocks = false;
104
+ this.log(`markdown block refused, falling back to mrkdwn: ${String(err)}`);
105
+ }
106
+ }
107
+ // Legacy path: translate to mrkdwn and split into section blocks. The body
108
+ // was chunked against the larger budget, so it may need splitting again.
109
+ for (const part of body ? chunk(toMrkdwn(body), MRKDWN_MAX) : [""]) {
110
+ const blocks = [...sections(part), ...trailing];
111
+ if (!blocks.length)
112
+ continue;
113
+ await this.api.postMessage({
114
+ channel,
115
+ thread_ts: threadTs,
116
+ text: part || notice || "…",
117
+ blocks,
118
+ });
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,122 @@
1
+ // Slack's half of the settings panel: mrkdwn markup, Block Kit, and a modal
2
+ // for the one typed answer. The panel itself lives in `panel.ts`.
3
+ //
4
+ // The modal is what makes this half smaller than Telegram's: `private_metadata`
5
+ // carries the conversation with the dialog, so a submitted path needs no
6
+ // adapter-side state to be understood, and survives a reload.
7
+ import { ChatPanel, PANEL_PREFIX, } from "./panel.js";
8
+ import { context, escapeMrkdwn as esc, section } from "./slack-render.js";
9
+ /** The modal's ids. `private_metadata` carries which conversation it is for. */
10
+ const CWD_VIEW = "cfg_cwd";
11
+ const CWD_BLOCK = "cwd_block";
12
+ const CWD_INPUT = "cwd_input";
13
+ const button = (b) => ({
14
+ type: "button",
15
+ action_id: `${PANEL_PREFIX}${b.action}`,
16
+ text: { type: "plain_text", text: b.label, emoji: true },
17
+ });
18
+ const row = (buttons) => ({
19
+ type: "actions",
20
+ elements: buttons.map(button),
21
+ });
22
+ export class SlackPanel extends ChatPanel {
23
+ deps;
24
+ platform = "slack";
25
+ fence = ["`", "`"];
26
+ constructor(deps) {
27
+ super(deps);
28
+ this.deps = deps;
29
+ }
30
+ esc(text) {
31
+ return esc(text);
32
+ }
33
+ // --- rendering -------------------------------------------------------------
34
+ blocks(view, note) {
35
+ return [
36
+ ...view.groups.map((g) => section([`*${g.title}*${g.suffix ?? ""}`, ...g.lines].join("\n"))),
37
+ // Slack fits a page of choices on one row; Telegram would not.
38
+ ...(view.picks?.length ? [row(view.picks)] : []),
39
+ ...view.rows.filter((r) => r.length).map(row),
40
+ ...(note ? [context(esc(note))] : []),
41
+ ];
42
+ }
43
+ /** Open a fresh panel, replacing whichever one this conversation had. */
44
+ async open(key, channel, threadTs) {
45
+ const sent = await this.deps.api.postMessage({
46
+ channel,
47
+ thread_ts: threadTs,
48
+ text: "Settings",
49
+ blocks: this.blocks(await this.view(key, channel)),
50
+ });
51
+ this.remember(key, { chatId: channel, threadTs, ts: sent.ts, models: [] });
52
+ }
53
+ async draw(state, view, note) {
54
+ await this.deps.api.updateMessage({
55
+ channel: state.chatId,
56
+ ts: state.ts,
57
+ text: "Settings",
58
+ blocks: this.blocks(view, note),
59
+ }).catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
60
+ }
61
+ async erase(state) {
62
+ await this.deps.api.deleteMessage(state.chatId, state.ts)
63
+ .catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
64
+ }
65
+ // --- actions ---------------------------------------------------------------
66
+ /**
67
+ * Handle a `cfg:` click. Returns false when the action is not ours, so the
68
+ * caller can treat it as one of the agent's next-step labels instead.
69
+ */
70
+ async onAction(interaction, key, actionId) {
71
+ return this.dispatch(key, actionId, interaction, async () => {
72
+ const channel = interaction.channel?.id;
73
+ const message = interaction.message;
74
+ if (channel && message)
75
+ await this.open(key, channel, message.thread_ts ?? message.ts);
76
+ });
77
+ }
78
+ // --- working directory (one typed answer, in a modal) ----------------------
79
+ async promptCwd(key, _state, interaction) {
80
+ const trigger = interaction.trigger_id;
81
+ if (!trigger) {
82
+ await this.refresh(key, "Could not open the dialog — try again.");
83
+ return;
84
+ }
85
+ await this.deps.api.openView(trigger, {
86
+ type: "modal",
87
+ callback_id: CWD_VIEW,
88
+ private_metadata: key.conversationId,
89
+ title: { type: "plain_text", text: "New session" },
90
+ submit: { type: "plain_text", text: "Start" },
91
+ close: { type: "plain_text", text: "Cancel" },
92
+ blocks: [
93
+ {
94
+ type: "input",
95
+ block_id: CWD_BLOCK,
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
+ },
101
+ element: {
102
+ type: "plain_text_input",
103
+ action_id: CWD_INPUT,
104
+ placeholder: { type: "plain_text", text: "/path/to/project" },
105
+ },
106
+ },
107
+ ],
108
+ }).catch((err) => this.deps.log(`cwd modal failed: ${String(err)}`));
109
+ }
110
+ /** Consume a modal submission. Returns false when the view is not ours. */
111
+ async onViewSubmission(interaction) {
112
+ const view = interaction.view;
113
+ if (view?.callback_id !== CWD_VIEW)
114
+ return false;
115
+ const key = {
116
+ channelId: "slack",
117
+ conversationId: view.private_metadata ?? "",
118
+ };
119
+ await this.startSessionIn(key, (view.state?.values?.[CWD_BLOCK]?.[CWD_INPUT]?.value ?? "").trim());
120
+ return true;
121
+ }
122
+ }
@@ -0,0 +1,214 @@
1
+ // How a reply looks on Slack: mrkdwn text, and buttons as a Block Kit
2
+ // `actions` row.
3
+ //
4
+ // mrkdwn is not markdown. Bold is `*one*` star, italic is `_underscore_`,
5
+ // strikethrough is `~one~` tilde, and a link is `<url|label>` — so the agent's
6
+ // markdown has to be translated, not passed through. Only `&`, `<` and `>` are
7
+ // escaped; unlike Telegram's HTML parser Slack degrades unknown syntax to
8
+ // literal text instead of rejecting the message, so the risk here is an ugly
9
+ // reply rather than a lost one.
10
+ import { chunkText } from "./chunk.js";
11
+ /**
12
+ * A `markdown` block's budget: Slack caps them at 12,000 cumulative chars per
13
+ * message, and one message carries one. This is the normal path.
14
+ */
15
+ export const MARKDOWN_MAX = 11_000;
16
+ /**
17
+ * The legacy fallback's budget: a `section` block's text caps at 3000, and the
18
+ * mrkdwn translation adds a little markup.
19
+ */
20
+ export const MRKDWN_MAX = 2800;
21
+ // Slack truncates a button label past this, mid-word.
22
+ const BUTTON_MAX = 75;
23
+ // Slack's cap on one actions block. `MAX_SUGGESTIONS` in core/reply.ts already
24
+ // holds the agent to 5, so this only guards a caller that bypasses it.
25
+ const MAX_BUTTONS = 25;
26
+ /** Shared: the adapter and the panel escape plain text with this too. */
27
+ export const escapeMrkdwn = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28
+ /**
29
+ * Inline emphasis, applied after escaping so our own markup stays ours.
30
+ *
31
+ * Bold is marked with a private-use sentinel rather than written as `*` right
32
+ * away: mrkdwn spells bold with the single star that markdown uses for italic,
33
+ * so emitting it early would let the italic pass eat it again.
34
+ */
35
+ const BOLD = "\uE002";
36
+ function inline(text) {
37
+ return text
38
+ // Links first: their label may itself carry emphasis. Slack inverts the
39
+ // order of markdown's pair, and `>` inside is already escaped.
40
+ .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => `<${url}|${label}>`)
41
+ // Bold before italic: `**x**` must not be seen as two `*x*` runs.
42
+ .replace(/\*\*([^\n*]+)\*\*/g, `${BOLD}$1${BOLD}`)
43
+ .replace(/~~([^\n~]+)~~/g, "~$1~")
44
+ .replace(/(^|[\s(])[*_]([^\n*_]+)[*_](?=[\s).,!?:;]|$)/g, "$1_$2_")
45
+ // Headings have no size in Slack either; bold is the closest honest render.
46
+ .replace(/^#{1,6}[ \t]+(.+)$/gm, `${BOLD}$1${BOLD}`)
47
+ // Slack renders neither `-` nor `*` as a list marker, so bullets are drawn.
48
+ .replace(/^[ \t]*[-*+][ \t]+/gm, "\u2022 ")
49
+ .replaceAll(BOLD, "*");
50
+ }
51
+ /**
52
+ * Render one assistant turn as mrkdwn. Code spans and fences are extracted
53
+ * before escaping so emphasis inside them stays literal.
54
+ */
55
+ export function toMrkdwn(markdown) {
56
+ const stash = [];
57
+ // Private-use sentinels: markdown cannot contain them, so a stashed block
58
+ // cannot be re-matched by the escaping and emphasis passes that follow.
59
+ const keep = (text) => `\uE000${stash.push(text) - 1}\uE001`;
60
+ // Slack code fences carry no language, so the hint is dropped rather than
61
+ // shown as the first line of the block.
62
+ let out = markdown.replace(/```[\w.+-]*\n?([\s\S]*?)```/g, (_m, code) => keep("```\n" + escapeMrkdwn(code.replace(/\n+$/, "")) + "\n```"));
63
+ out = out.replace(/`([^`\n]+)`/g, (_m, code) => keep(`\`${escapeMrkdwn(code)}\``));
64
+ out = inline(escapeMrkdwn(out));
65
+ return out.replace(/\uE000(\d+)\uE001/g, (_m, i) => stash[Number(i)] ?? "");
66
+ }
67
+ /**
68
+ * Split rendered mrkdwn into sendable chunks at the last blank line or newline
69
+ * that fits, then re-balance code fences across the cut.
70
+ *
71
+ * Telegram can be cut mid-`<pre>` and shrug — its parser closes the tag itself.
72
+ * Slack does not: an unterminated ``` swallows the rest of that message, and
73
+ * the next chunk starts *outside* a fence, so the tail of a long code block
74
+ * renders as prose. Closing and reopening around the boundary is what keeps a
75
+ * split code block readable.
76
+ */
77
+ export const chunk = (text, max) => balanceFences(chunkText(text, max));
78
+ /**
79
+ * Close a fence a chunk left open, and reopen it on the next one. Counting `\`
80
+ * runs is enough because `toMrkdwn` has already normalised every fence to a
81
+ * bare ``` on its own line.
82
+ */
83
+ function balanceFences(parts) {
84
+ let open = false;
85
+ return parts.map((part) => {
86
+ const reopened = open ? `\`\`\`\n${part}` : part;
87
+ // The prepended fence counts too, so a chunk that closes the block it
88
+ // inherited comes out even and clears the flag.
89
+ open = ((reopened.match(/```/g) ?? []).length % 2) === 1;
90
+ return open ? `${reopened}\n\`\`\`` : reopened;
91
+ });
92
+ }
93
+ /**
94
+ * The body of a turn, as Slack's own markdown renderer sees it. Preferred over
95
+ * `section` for everything the agent wrote: it takes the markdown unmodified
96
+ * (so tables and headers survive) and the client never folds it behind
97
+ * "Show more".
98
+ */
99
+ export const markdown = (text) => ({ type: "markdown", text });
100
+ export const section = (text) => ({
101
+ type: "section",
102
+ text: { type: "mrkdwn", text },
103
+ });
104
+ // Slack caps a message at 50 blocks; the footer and the button row need two.
105
+ const MAX_BLOCKS = 45;
106
+ // A section block's hard limit. Nothing should reach it — chunk() caps a whole
107
+ // message below this — but the overflow merge below could in principle.
108
+ const SECTION_MAX = 2900;
109
+ /**
110
+ * Split rendered mrkdwn into paragraphs without ever cutting a fenced code
111
+ * block — a fence split across two blocks would leave both unbalanced, the
112
+ * same hazard `chunk()` handles for messages.
113
+ */
114
+ function paragraphs(text) {
115
+ const out = [];
116
+ let buf = [];
117
+ let fenced = false;
118
+ const flush = () => {
119
+ if (buf.length)
120
+ out.push(buf.join("\n"));
121
+ buf = [];
122
+ };
123
+ for (const line of text.split("\n")) {
124
+ const isFence = line.trimStart().startsWith("```");
125
+ // A blank line only ends a paragraph outside a fence; inside one it is code.
126
+ if (!fenced && !isFence && !line.trim()) {
127
+ flush();
128
+ continue;
129
+ }
130
+ buf.push(line);
131
+ if (isFence) {
132
+ fenced = !fenced;
133
+ // A closed fence stands alone, so it can never be merged apart.
134
+ if (!fenced)
135
+ flush();
136
+ }
137
+ }
138
+ flush();
139
+ return out;
140
+ }
141
+ /**
142
+ * The body of one message as one `section` block per paragraph — the fallback
143
+ * for a workspace whose Slack refuses the `markdown` block.
144
+ *
145
+ * A whole turn in a single section block gets collapsed behind "Show more",
146
+ * hiding most of the answer; several blocks render unfolded. Paragraphs are
147
+ * deliberately *not* packed together to fill a size budget — a paragraph is
148
+ * already the natural short unit, and merging a few of them back into one tall
149
+ * block is exactly what brings the collapse back.
150
+ */
151
+ export function sections(text) {
152
+ const paras = paragraphs(text);
153
+ if (!paras.length)
154
+ return [];
155
+ // Past the block cap the tail is folded into the last block rather than
156
+ // dropped: a truncated reply is worse than a tall one, and silently losing
157
+ // the end of an answer is worst of all.
158
+ const kept = paras.slice(0, MAX_BLOCKS - 1);
159
+ const tail = paras.slice(MAX_BLOCKS - 1);
160
+ if (tail.length)
161
+ kept.push(tail.join("\n\n").slice(0, SECTION_MAX));
162
+ return kept.map(section);
163
+ }
164
+ /**
165
+ * Slack's small muted text. Telegram has none, which is why `formatTurnMeta`
166
+ * lands there as an italic footnote; here the footer gets the block the
167
+ * platform actually has for it.
168
+ */
169
+ export const context = (text) => ({
170
+ type: "context",
171
+ elements: [{ type: "mrkdwn", text }],
172
+ });
173
+ // --- next-step buttons -------------------------------------------------------
174
+ /**
175
+ * `action_id` carries an index, not the label. Slack would allow 2000 chars of
176
+ * `value`, but the index is what makes a button survive a `runtime.reload()`:
177
+ * the label is read back off the message Slack echoes with the click, so no
178
+ * adapter-instance memory is involved.
179
+ */
180
+ export const OFFER_PREFIX = "sg:";
181
+ const truncate = (label) => label.length > BUTTON_MAX ? `${label.slice(0, BUTTON_MAX - 1)}\u2026` : label;
182
+ /**
183
+ * One actions row. Slack wraps buttons on its own and gives each its natural
184
+ * width, so unlike Telegram there is no row packing to budget — a long label
185
+ * beside a short one costs nothing.
186
+ */
187
+ export function actions(labels) {
188
+ if (!labels.length)
189
+ return undefined;
190
+ const elements = labels.slice(0, MAX_BUTTONS).map((label, index) => ({
191
+ type: "button",
192
+ action_id: `${OFFER_PREFIX}${index}`,
193
+ text: { type: "plain_text", text: truncate(label), emoji: true },
194
+ }));
195
+ return { type: "actions", elements };
196
+ }
197
+ /**
198
+ * The label a next-step `action_id` stands for, read off the clicked message's
199
+ * own blocks — which Slack echoes back in the interaction payload. A button
200
+ * therefore keeps working across a restart or a config reload, where an
201
+ * in-memory offer list would not.
202
+ */
203
+ export function offeredLabel(blocks, actionId) {
204
+ if (!actionId.startsWith(OFFER_PREFIX))
205
+ return undefined;
206
+ for (const block of blocks ?? []) {
207
+ if (block.type !== "actions")
208
+ continue;
209
+ const hit = block.elements.find((el) => el.action_id === actionId);
210
+ if (hit)
211
+ return hit.text.text;
212
+ }
213
+ return undefined;
214
+ }