@timqi/pier 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/dist/agent/config.js +9 -3
- package/dist/agent/events.js +13 -5
- package/dist/agent/listing.js +10 -2
- package/dist/agent/packages.js +4 -20
- package/dist/agent/pi.js +18 -86
- package/dist/channels/attach.js +2 -2
- package/dist/channels/commands.js +10 -8
- package/dist/channels/config.js +32 -24
- package/dist/channels/control.js +60 -14
- package/dist/channels/conversations.js +60 -13
- package/dist/channels/handoff.js +90 -0
- package/dist/channels/lark-api.js +7 -0
- package/dist/channels/lark-outbound.js +19 -0
- package/dist/channels/lark-panel.js +41 -26
- package/dist/channels/lark-render.js +2 -1
- package/dist/channels/lark.js +35 -26
- package/dist/channels/lines.js +1 -1
- package/dist/channels/panel.js +324 -115
- package/dist/channels/receipts.js +25 -12
- package/dist/channels/routes.js +21 -7
- package/dist/channels/runtime.js +21 -7
- package/dist/channels/slack-api.js +19 -10
- package/dist/channels/slack-cli.js +503 -0
- package/dist/channels/slack-directory.js +2 -2
- package/dist/channels/slack-outbound.js +11 -1
- package/dist/channels/slack-panel.js +81 -40
- package/dist/channels/slack-render.js +3 -1
- package/dist/channels/slack-thread.js +41 -0
- package/dist/channels/slack-transcript.js +107 -0
- package/dist/channels/slack.js +38 -31
- package/dist/channels/types.js +1 -3
- package/dist/cli.js +166 -7
- package/dist/core/identity.js +61 -17
- package/dist/core/reply.js +19 -15
- package/dist/core/router.js +23 -3
- package/dist/db.js +33 -0
- package/dist/main.js +61 -41
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/settings.js +3 -12
- package/dist/socket.js +99 -0
- package/dist/tasks/agent.js +5 -6
- package/dist/tasks/callbacks.js +2 -2
- package/dist/tasks/cli.js +225 -0
- package/dist/tasks/definitions.js +3 -3
- package/dist/tasks/execution.js +1 -3
- package/dist/tasks/groups.js +2 -5
- package/dist/tasks/messages.js +18 -158
- package/dist/tasks/operations.js +335 -0
- package/dist/tasks/routes.js +1 -11
- package/dist/tasks/runs.js +5 -20
- package/dist/tasks/service.js +18 -43
- package/dist/tasks/store.js +15 -15
- package/dist/tools.js +18 -3
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
- package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
- package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
- package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
- package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
- package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
- package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
- package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
- package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css +2 -0
- package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
- package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
- package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
- package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
- package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
- package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
- package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
- package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
- package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/push.js +4 -10
- package/dist/web/vault.js +68 -0
- package/dist/{extensions/web → websearch}/artifacts.js +2 -2
- package/dist/websearch/cli.js +73 -0
- package/dist/websearch/run.js +273 -0
- package/docs/deploy.md +34 -11
- package/package.json +2 -3
- package/skills/pier-help/SKILL.md +32 -16
- package/skills/pier-slack/SKILL.md +52 -71
- package/skills/pier-tasks/SKILL.md +60 -148
- package/skills/pier-vault/SKILL.md +36 -0
- package/skills/pier-web/SKILL.md +50 -0
- package/dist/channels/slack-tool.js +0 -416
- package/dist/channels/telegram-api.js +0 -86
- package/dist/channels/telegram-panel.js +0 -97
- package/dist/channels/telegram-render.js +0 -69
- package/dist/channels/telegram.js +0 -421
- package/dist/extensions/index.js +0 -10
- package/dist/extensions/web/index.js +0 -9
- package/dist/extensions/web/tools.js +0 -265
- package/dist/tasks/tool.js +0 -416
- package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
- package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
- package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css +0 -2
- package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
- package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
- /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
- /package/dist/{extensions/web → websearch}/content.js +0 -0
- /package/dist/{extensions/web → websearch}/http.js +0 -0
- /package/dist/{extensions/web → websearch}/json.js +0 -0
- /package/dist/{extensions/web → websearch}/language.js +0 -0
- /package/dist/{extensions/web → websearch}/openai.js +0 -0
- /package/dist/{extensions/web → websearch}/provider.js +0 -0
|
@@ -1,416 +0,0 @@
|
|
|
1
|
-
// The agent-facing Slack tool: the model states an intent, Pier performs it,
|
|
2
|
-
// and the token never leaves Pier. Every read goes to Slack — only it knows
|
|
3
|
-
// about an edit or a deletion. Affordable because a workspace-internal app gets
|
|
4
|
-
// Tier 3 (~50+ req/min) on history/replies; a distributed non-Marketplace app
|
|
5
|
-
// is capped at 1 req/min, and that is the decision to revisit.
|
|
6
|
-
import { Type } from "typebox";
|
|
7
|
-
import { saveInboundAll } from "../core/inbox.js";
|
|
8
|
-
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
9
|
-
import { MARKDOWN_MAX } from "./slack-render.js";
|
|
10
|
-
/** So a wide range cannot blow up the model's context. */
|
|
11
|
-
const MAX_MESSAGES = 400;
|
|
12
|
-
const MAX_PAGES = 10;
|
|
13
|
-
const INBOX_CHANNEL = "slack";
|
|
14
|
-
/** A `ts` is `<epoch seconds>.<microseconds>`: sorts as a number, not as a
|
|
15
|
-
* string. Never rewritten — it is the id a reply must match exactly. */
|
|
16
|
-
const tsToNumber = (ts) => Number(ts);
|
|
17
|
-
const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
|
|
18
|
-
const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
|
|
19
|
-
/** Epoch seconds at the year 2100: past this, the caller meant milliseconds. */
|
|
20
|
-
const MAX_SECONDS = 4_102_444_800;
|
|
21
|
-
/** Accepts an ISO date, an epoch-seconds number, or a raw Slack ts. */
|
|
22
|
-
export function toTs(value) {
|
|
23
|
-
if (value === undefined || value === "")
|
|
24
|
-
return undefined;
|
|
25
|
-
const raw = typeof value === "number" ? String(value) : value.trim();
|
|
26
|
-
const numeric = /^\d+(\.\d+)?$/.test(raw);
|
|
27
|
-
const seconds = numeric ? Number(raw) : Date.parse(raw) / 1000;
|
|
28
|
-
if (Number.isNaN(seconds))
|
|
29
|
-
throw new Error(`not a time: ${value}`);
|
|
30
|
-
// Milliseconds: Slack takes the window without complaint and answers an empty
|
|
31
|
-
// read indistinguishable from a channel where nobody spoke.
|
|
32
|
-
if (seconds > MAX_SECONDS) {
|
|
33
|
-
throw new Error(`${value} is past the year 2100 — Slack times are epoch seconds, not milliseconds`);
|
|
34
|
-
}
|
|
35
|
-
return numeric ? raw : String(seconds);
|
|
36
|
-
}
|
|
37
|
-
/** Asked before the description is paid for. `handleSlackTool` keeps its own
|
|
38
|
-
* checks: a session opened while Slack was configured outlives the switch. */
|
|
39
|
-
export function slackToolAvailable(store) {
|
|
40
|
-
const config = store.get("slack");
|
|
41
|
-
return config.enabled && !!config.token && config.agentTool;
|
|
42
|
-
}
|
|
43
|
-
export function slackToolSpec(execute, available) {
|
|
44
|
-
return {
|
|
45
|
-
name: "slack",
|
|
46
|
-
label: "Slack",
|
|
47
|
-
description: "Read and write Slack through Pier, which holds the bot token. Operations: context (which Slack conversation this session is in), read_channel (transcript for a time range), read_thread (one thread; only what is new since a message via after), read_message (the one at ts), fetch_file (save a file a transcript names, by its F… id), post, edit/delete (Pier's own messages only), channels (what Pier can reach). Omit channel and thread_ts to act on the conversation you are in. since/until/after accept ISO 8601, epoch seconds or a ts. Every read fetches live; nothing is kept between calls. @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
|
|
48
|
-
parameters: Type.Object({
|
|
49
|
-
// A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
|
|
50
|
-
operation: Type.Unsafe({
|
|
51
|
-
type: "string",
|
|
52
|
-
enum: [
|
|
53
|
-
"context",
|
|
54
|
-
"read_channel",
|
|
55
|
-
"read_thread",
|
|
56
|
-
"read_message",
|
|
57
|
-
"fetch_file",
|
|
58
|
-
"post",
|
|
59
|
-
"edit",
|
|
60
|
-
"delete",
|
|
61
|
-
"channels",
|
|
62
|
-
],
|
|
63
|
-
}),
|
|
64
|
-
channel: Type.Optional(Type.String()),
|
|
65
|
-
since: Type.Optional(Type.String()),
|
|
66
|
-
until: Type.Optional(Type.String()),
|
|
67
|
-
after: Type.Optional(Type.String()),
|
|
68
|
-
ts: Type.Optional(Type.String()),
|
|
69
|
-
file: Type.Optional(Type.String()),
|
|
70
|
-
limit: Type.Optional(Type.Number()),
|
|
71
|
-
thread_ts: Type.Optional(Type.String()),
|
|
72
|
-
text: Type.Optional(Type.String()),
|
|
73
|
-
}),
|
|
74
|
-
available,
|
|
75
|
-
skill: "pier-slack",
|
|
76
|
-
execute,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
const required = (value, field) => {
|
|
80
|
-
if (typeof value !== "string" || !value.trim())
|
|
81
|
-
throw new Error(`${field} is required`);
|
|
82
|
-
return value.trim();
|
|
83
|
-
};
|
|
84
|
-
/** Slack rejects an oversized message outright; a refusal the agent can act
|
|
85
|
-
* on beats a post that silently never happened. */
|
|
86
|
-
const messageText = (raw) => {
|
|
87
|
-
const text = required(raw, "text");
|
|
88
|
-
if (text.length > MARKDOWN_MAX) {
|
|
89
|
-
throw new Error(`text is ${text.length} chars; Slack accepts ${MARKDOWN_MAX} per message`);
|
|
90
|
-
}
|
|
91
|
-
return text;
|
|
92
|
-
};
|
|
93
|
-
const record = (raw) => raw && typeof raw === "object" && !Array.isArray(raw) ? raw : undefined;
|
|
94
|
-
export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
95
|
-
const input = record(raw);
|
|
96
|
-
if (!input)
|
|
97
|
-
throw new Error("slack tool parameters required");
|
|
98
|
-
const config = deps.store.get("slack");
|
|
99
|
-
// The error says which switch: "it does nothing" is the most expensive
|
|
100
|
-
// failure for a model to diagnose.
|
|
101
|
-
if (!config.enabled || !config.token)
|
|
102
|
-
throw new Error("the Slack channel is not configured in Pier");
|
|
103
|
-
if (!config.agentTool)
|
|
104
|
-
throw new Error("Slack agent access is switched off in Pier's Console");
|
|
105
|
-
const client = deps.client();
|
|
106
|
-
if (!client)
|
|
107
|
-
throw new Error("the Slack client is unavailable");
|
|
108
|
-
if (input.operation === "channels") {
|
|
109
|
-
// Only what Pier has seen: Slack has no reliable "list my channels".
|
|
110
|
-
return config.chats.map((chat) => ({
|
|
111
|
-
id: chat.id,
|
|
112
|
-
name: chat.name,
|
|
113
|
-
kind: chat.kind,
|
|
114
|
-
respondsToMessages: chat.enabled,
|
|
115
|
-
}));
|
|
116
|
-
}
|
|
117
|
-
const at = deps.here(callerSessionId);
|
|
118
|
-
if (input.operation === "context") {
|
|
119
|
-
if (!at) {
|
|
120
|
-
return {
|
|
121
|
-
inSlack: false,
|
|
122
|
-
note: "This session was not reached through Slack, so there is no current conversation. Name a channel explicitly.",
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
const chat = config.chats.find((c) => c.id === at.channel);
|
|
126
|
-
return {
|
|
127
|
-
inSlack: true,
|
|
128
|
-
channel: at.channel,
|
|
129
|
-
channelName: chat?.name ?? at.channel,
|
|
130
|
-
kind: chat?.kind ?? null,
|
|
131
|
-
threadTs: at.threadTs,
|
|
132
|
-
note: "Omit channel and thread_ts to read or post here. Speaker ids for mentions come from read_thread.",
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
// Before the channel default: a file id is unique workspace-wide.
|
|
136
|
-
if (input.operation === "fetch_file") {
|
|
137
|
-
return fetchFile(deps, client, required(input.file, "file"));
|
|
138
|
-
}
|
|
139
|
-
const channel = input.channel === undefined || input.channel === ""
|
|
140
|
-
? at?.channel ??
|
|
141
|
-
(() => {
|
|
142
|
-
throw new Error("channel is required: this session was not reached through Slack, so there is no current conversation");
|
|
143
|
-
})()
|
|
144
|
-
: resolveChannel(deps, required(input.channel, "channel"));
|
|
145
|
-
// Slack's bounds are inclusive-ish, so the boundary message is dropped here.
|
|
146
|
-
const after = toTs(input.after);
|
|
147
|
-
const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
|
|
148
|
-
if (input.operation === "read_channel") {
|
|
149
|
-
const since = after ?? toTs(input.since);
|
|
150
|
-
const until = toTs(input.until);
|
|
151
|
-
return readChannel(deps, client, channel, since, until, after, limit);
|
|
152
|
-
}
|
|
153
|
-
if (input.operation === "read_thread") {
|
|
154
|
-
const threadTs = typeof input.thread_ts === "string" && input.thread_ts.trim()
|
|
155
|
-
? input.thread_ts.trim()
|
|
156
|
-
: at?.threadTs;
|
|
157
|
-
if (!threadTs)
|
|
158
|
-
throw new Error("thread_ts is required outside a Slack thread");
|
|
159
|
-
return readThread(deps, client, channel, threadTs, after, limit);
|
|
160
|
-
}
|
|
161
|
-
if (input.operation === "read_message") {
|
|
162
|
-
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
163
|
-
return readMessage(deps, client, channel, required(input.ts, "ts"), asked || undefined);
|
|
164
|
-
}
|
|
165
|
-
if (input.operation === "post") {
|
|
166
|
-
const text = messageText(input.text);
|
|
167
|
-
// `thread_ts: "none"` is the explicit way to start a top-level message.
|
|
168
|
-
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
169
|
-
const threadTs = asked === "none"
|
|
170
|
-
? undefined
|
|
171
|
-
: asked || (channel === at?.channel ? at?.threadTs : undefined);
|
|
172
|
-
const sent = await client.postMessage({
|
|
173
|
-
channel,
|
|
174
|
-
thread_ts: threadTs,
|
|
175
|
-
text,
|
|
176
|
-
// Slack's own markdown renderer: the agent writes markdown, not mrkdwn.
|
|
177
|
-
blocks: [{ type: "markdown", text }],
|
|
178
|
-
});
|
|
179
|
-
return {
|
|
180
|
-
channel,
|
|
181
|
-
ts: sent.ts,
|
|
182
|
-
at: sent.ts ? tsToIso(sent.ts) : null,
|
|
183
|
-
threadTs: threadTs ?? sent.ts,
|
|
184
|
-
...inertMention(text),
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
if (input.operation === "edit") {
|
|
188
|
-
const ts = required(input.ts, "ts");
|
|
189
|
-
const text = messageText(input.text);
|
|
190
|
-
// `conversations.history` cannot see inside a thread, so the read below
|
|
191
|
-
// needs the thread `post` would default to.
|
|
192
|
-
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
193
|
-
const inThread = asked || (channel === at?.channel ? at?.threadTs : undefined);
|
|
194
|
-
// Slack keeps no version, so this log is the only record of what was replaced.
|
|
195
|
-
const was = await previousText(client, channel, ts, inThread);
|
|
196
|
-
try {
|
|
197
|
-
await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
|
|
198
|
-
}
|
|
199
|
-
catch (err) {
|
|
200
|
-
throw new Error(explain(err));
|
|
201
|
-
}
|
|
202
|
-
deps.log(`slack tool edited ${ts} in ${channel}; was: ${was ?? "(not captured)"}`);
|
|
203
|
-
return { channel, ts, edited: true, ...inertMention(text) };
|
|
204
|
-
}
|
|
205
|
-
if (input.operation === "delete") {
|
|
206
|
-
// Never defaulted from `here`: an implied target is the one mistake with no undo.
|
|
207
|
-
const ts = required(input.ts, "ts");
|
|
208
|
-
try {
|
|
209
|
-
await client.deleteMessage(channel, ts);
|
|
210
|
-
}
|
|
211
|
-
catch (err) {
|
|
212
|
-
throw new Error(explain(err));
|
|
213
|
-
}
|
|
214
|
-
deps.log(`slack tool deleted ${ts} in ${channel}`);
|
|
215
|
-
return { channel, ts, deleted: true };
|
|
216
|
-
}
|
|
217
|
-
throw new Error(`unknown slack operation: ${String(input.operation)}`);
|
|
218
|
-
}
|
|
219
|
-
/** `conversations.history` never returns what was posted inside a thread, so
|
|
220
|
-
* a reply has to be asked for through its thread. */
|
|
221
|
-
async function oneMessage(client, channel, ts, threadTs) {
|
|
222
|
-
const page = threadTs
|
|
223
|
-
? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
|
|
224
|
-
: await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
|
|
225
|
-
return page.messages.find((msg) => msg.ts === ts);
|
|
226
|
-
}
|
|
227
|
-
async function previousText(client, channel, ts, threadTs) {
|
|
228
|
-
try {
|
|
229
|
-
const text = (await oneMessage(client, channel, ts, threadTs))?.text;
|
|
230
|
-
return text === undefined ? undefined : text.slice(0, 200).replace(/\s+/g, " ");
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
// The caller logs "not captured"; refusing the edit over it would be worse.
|
|
234
|
-
return undefined;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
/** A plain `@alice` looks like it worked and notifies nobody. Reported, not
|
|
238
|
-
* refused: a name in prose is legitimate. */
|
|
239
|
-
function inertMention(text) {
|
|
240
|
-
const prose = text
|
|
241
|
-
.replace(/```[\s\S]*?```|`[^`]*`/g, "") // code says @ and # for other reasons
|
|
242
|
-
.replace(/<[^>]*>/g, ""); // already Slack syntax
|
|
243
|
-
const hit = /(?:^|\s)([@#][A-Za-z][\w.-]*)/.exec(prose)?.[1];
|
|
244
|
-
if (!hit)
|
|
245
|
-
return {};
|
|
246
|
-
const as = hit.startsWith("@")
|
|
247
|
-
? /^@(here|channel|everyone)$/.test(hit) ? `<!${hit.slice(1)}>` : "<@U…>"
|
|
248
|
-
: "<#C…>";
|
|
249
|
-
return {
|
|
250
|
-
hint: `${hit} is plain text and notified nobody — Slack needs ${as}. ` +
|
|
251
|
-
`Edit this ts if it was meant to reach someone.`,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
function resolveChannel(deps, given) {
|
|
255
|
-
if (/^[CDG][A-Z0-9]+$/.test(given))
|
|
256
|
-
return given;
|
|
257
|
-
const wanted = given.replace(/^#/, "").toLowerCase();
|
|
258
|
-
const chats = deps.store.get("slack").chats;
|
|
259
|
-
const hit = chats.find((chat) => chat.name.replace(/^#/, "").toLowerCase() === wanted);
|
|
260
|
-
if (hit)
|
|
261
|
-
return hit.id;
|
|
262
|
-
throw new Error(`unknown channel ${given}; use an id or one of: ${chats.map((c) => c.name).join(", ") || "(none discovered yet)"}`);
|
|
263
|
-
}
|
|
264
|
-
async function readChannel(deps, client, channel, since, until, after, limit) {
|
|
265
|
-
const fetched = await fetchPages(deps, (cursor) => client.history(channel, { oldest: since, latest: until, cursor }), `history for ${channel}`);
|
|
266
|
-
const all = newerThan(transcript(fetched.messages), after);
|
|
267
|
-
// Truncated at the newest end: the oldest `limit` messages read as a transcript.
|
|
268
|
-
const window = all.slice(0, limit);
|
|
269
|
-
return {
|
|
270
|
-
channel,
|
|
271
|
-
range: `${since ? tsToMinute(since) : "start"} → ${until ? tsToMinute(until) : "now"}`,
|
|
272
|
-
count: window.length,
|
|
273
|
-
...(fetched.truncated || all.length > window.length ? { truncated: true } : {}),
|
|
274
|
-
...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
|
|
275
|
-
format: LINE_FORMAT,
|
|
276
|
-
messages: await lines(deps, client, window),
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
/** Exported for the adapter, which inlines a small shared thread. */
|
|
280
|
-
export async function readThread(deps, client, channel, threadTs, after, limit) {
|
|
281
|
-
const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
|
|
282
|
-
const all = newerThan(transcript(fetched.messages), after);
|
|
283
|
-
const messages = all.slice(0, limit);
|
|
284
|
-
return {
|
|
285
|
-
channel,
|
|
286
|
-
threadTs,
|
|
287
|
-
count: messages.length,
|
|
288
|
-
...(fetched.truncated || all.length > messages.length ? { truncated: true } : {}),
|
|
289
|
-
...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
|
|
290
|
-
format: LINE_FORMAT,
|
|
291
|
-
messages: await lines(deps, client, messages),
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
async function readMessage(deps, client, channel, ts, threadTs) {
|
|
295
|
-
const found = await oneMessage(client, channel, ts, threadTs);
|
|
296
|
-
if (!found) {
|
|
297
|
-
throw new Error(threadTs
|
|
298
|
-
? `no message ${ts} in thread ${threadTs}`
|
|
299
|
-
: `no message ${ts} in that channel — a reply posted inside a thread needs thread_ts`);
|
|
300
|
-
}
|
|
301
|
-
const [line] = await lines(deps, client, [found]);
|
|
302
|
-
return {
|
|
303
|
-
channel,
|
|
304
|
-
...(found.thread_ts ? { threadTs: found.thread_ts } : {}),
|
|
305
|
-
format: LINE_FORMAT,
|
|
306
|
-
message: line,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
/** Explicit, not automatic: one read can name a hundred uploads, and the agent
|
|
310
|
-
* knows which one the question is about. `files.info` every call: the signed
|
|
311
|
-
* url expires. */
|
|
312
|
-
async function fetchFile(deps, client, id) {
|
|
313
|
-
let file;
|
|
314
|
-
try {
|
|
315
|
-
file = await client.filesInfo(id);
|
|
316
|
-
}
|
|
317
|
-
catch (err) {
|
|
318
|
-
// Manifest scopes apply only at app creation, so an older install refuses
|
|
319
|
-
// for a reason no agent can guess.
|
|
320
|
-
if (/missing_scope/.test(String(err))) {
|
|
321
|
-
throw new Error("Pier's Slack app cannot read files — add the files:read scope to the Slack app " +
|
|
322
|
-
"under OAuth & Permissions and reinstall it");
|
|
323
|
-
}
|
|
324
|
-
throw new Error(explain(err));
|
|
325
|
-
}
|
|
326
|
-
const [marker] = await saveInboundAll(INBOX_CHANNEL, [{
|
|
327
|
-
label: file.name ?? id,
|
|
328
|
-
name: file.name,
|
|
329
|
-
mimeType: file.mimetype ?? "application/octet-stream",
|
|
330
|
-
size: file.size,
|
|
331
|
-
// The response's content-type wins: Slack's metadata is a guess.
|
|
332
|
-
fetch: () => client.downloadFile(file, MAX_INBOUND_BYTES),
|
|
333
|
-
}], deps.log);
|
|
334
|
-
return marker;
|
|
335
|
-
}
|
|
336
|
-
/** A page that fails mid-walk keeps the pages before it, plus why it stopped:
|
|
337
|
-
* an exception alone cannot be told from a quiet channel. */
|
|
338
|
-
async function fetchPages(deps, page, what) {
|
|
339
|
-
const messages = [];
|
|
340
|
-
let cursor;
|
|
341
|
-
for (let i = 0; i < MAX_PAGES; i++) {
|
|
342
|
-
let batch;
|
|
343
|
-
try {
|
|
344
|
-
batch = await page(cursor);
|
|
345
|
-
}
|
|
346
|
-
catch (err) {
|
|
347
|
-
if (messages.length === 0)
|
|
348
|
-
throw new Error(explain(err));
|
|
349
|
-
deps.log(`${what} stopped after ${messages.length} messages: ${String(err)}`);
|
|
350
|
-
return { messages, truncated: true, incomplete: explain(err) };
|
|
351
|
-
}
|
|
352
|
-
messages.push(...batch.messages);
|
|
353
|
-
cursor = batch.nextCursor;
|
|
354
|
-
if (!cursor || messages.length >= MAX_MESSAGES)
|
|
355
|
-
break;
|
|
356
|
-
}
|
|
357
|
-
if (cursor)
|
|
358
|
-
deps.log(`${what} truncated at ${messages.length} messages`);
|
|
359
|
-
return { messages, truncated: cursor !== undefined };
|
|
360
|
-
}
|
|
361
|
-
/** Error codes an agent can act on become the action; the rest keep their
|
|
362
|
-
* searchable raw code. */
|
|
363
|
-
function explain(err) {
|
|
364
|
-
const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
|
|
365
|
-
return {
|
|
366
|
-
channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
|
|
367
|
-
not_in_channel: "Pier's bot is not in that channel; someone has to run `/invite @Pier` there before it can read",
|
|
368
|
-
missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
|
|
369
|
-
ratelimited: "Slack rate-limited Pier; wait a minute, and narrow the range if this was a read",
|
|
370
|
-
thread_not_found: "no thread with that ts in this channel",
|
|
371
|
-
cant_delete_message: "Slack only lets Pier delete what its own bot posted; a person's message has to be deleted by them",
|
|
372
|
-
cant_update_message: "Slack only lets Pier edit what its own bot posted; anyone else's message can only be replied to",
|
|
373
|
-
edit_window_closed: "Slack's edit window for that message has closed; post a correction instead of rewriting it",
|
|
374
|
-
message_not_found: "no message with that ts in this channel — a ts only means anything in the conversation it came from",
|
|
375
|
-
file_not_found: "no file with that id, or Pier's bot cannot see it — the F… id comes from a transcript line",
|
|
376
|
-
}[code] ?? String(err);
|
|
377
|
-
}
|
|
378
|
-
/** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
|
|
379
|
-
const newerThan = (messages, after) => after === undefined ? messages : messages.filter((m) => tsToNumber(m.ts) > tsToNumber(after));
|
|
380
|
-
/** Oldest first, one per ts: page bounds are inclusive-ish, so a paged read
|
|
381
|
-
* can repeat the message on the seam. */
|
|
382
|
-
function transcript(messages) {
|
|
383
|
-
const byTs = new Map();
|
|
384
|
-
for (const msg of messages)
|
|
385
|
-
if (msg.ts)
|
|
386
|
-
byTs.set(msg.ts, msg);
|
|
387
|
-
return [...byTs.values()].sort((a, b) => tsToNumber(a.ts) - tsToNumber(b.ts));
|
|
388
|
-
}
|
|
389
|
-
/** Lines, not objects: four hundred six-key objects spend most of their tokens
|
|
390
|
-
* on key names. The id is the only thing `<@…>` can be built from. Terse: the
|
|
391
|
-
* adapter puts this string in a prompt, so every word is paid for per read. */
|
|
392
|
-
const LINE_FORMAT = "<ts> | <time, UTC> | <name>[<id>] | <text>, then — when there are any —"
|
|
393
|
-
+ " [thread: <n> replies] and one [file: <name> <F… id> <size>] per upload";
|
|
394
|
-
/** Enough to judge a fetch against the 32 MB cap without arithmetic. */
|
|
395
|
-
const sizeLabel = (bytes) => bytes < 1024
|
|
396
|
-
? `${bytes}B`
|
|
397
|
-
: bytes < 1024 * 1024
|
|
398
|
-
? `${Math.round(bytes / 1024)}KB`
|
|
399
|
-
: `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
400
|
-
/** Without this a PDF somebody posted reads as an empty message. */
|
|
401
|
-
const uploads = (files) => (files ?? []).map((file) => ` [file: ${file.name ?? file.mimetype ?? "file"} ${file.id}${file.size === undefined ? "" : ` ${sizeLabel(file.size)}`}]`).join("");
|
|
402
|
-
const speaker = (msg) => msg.user ?? msg.bot_id ?? null;
|
|
403
|
-
async function lines(deps, client, messages) {
|
|
404
|
-
// The store is not consulted: a member need not be bound to have spoken.
|
|
405
|
-
const ids = messages.map(speaker).filter((id) => !!id);
|
|
406
|
-
const names = await deps.directory.names(client, ids);
|
|
407
|
-
return messages.map((msg) => {
|
|
408
|
-
const id = speaker(msg);
|
|
409
|
-
const known = id ? names.get(id) : undefined;
|
|
410
|
-
const who = id ? (known && known !== id ? `${known}[${id}]` : `[${id}]`) : "[unknown]";
|
|
411
|
-
const replies = msg.reply_count && (msg.thread_ts ?? msg.ts) === msg.ts
|
|
412
|
-
? ` [thread: ${msg.reply_count} replies]`
|
|
413
|
-
: "";
|
|
414
|
-
return `${msg.ts} | ${tsToMinute(msg.ts)} | ${who} | ${msg.text ?? ""}${replies}${uploads(msg.files)}`;
|
|
415
|
-
});
|
|
416
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// Thin Telegram Bot API client: HTTP and payload shapes only, no policy; the
|
|
2
|
-
// one file that talks to api.telegram.org. Long polling, so Pier needs no
|
|
3
|
-
// inbound HTTP. Behind a proxy: NODE_USE_ENV_PROXY=1 and HTTPS_PROXY.
|
|
4
|
-
import { readCapped } from "../core/inbox.js";
|
|
5
|
-
const BASE = "https://api.telegram.org";
|
|
6
|
-
export class TelegramApi {
|
|
7
|
-
token;
|
|
8
|
-
constructor(token) {
|
|
9
|
-
this.token = token;
|
|
10
|
-
}
|
|
11
|
-
async call(method, payload, timeoutMs = 30_000, retry = true) {
|
|
12
|
-
// FormData is re-sendable, so the flood retry below still works on it.
|
|
13
|
-
const multipart = payload instanceof FormData;
|
|
14
|
-
const res = await fetch(`${BASE}/bot${this.token}/${method}`, {
|
|
15
|
-
method: "POST",
|
|
16
|
-
headers: multipart ? undefined : { "content-type": "application/json" },
|
|
17
|
-
body: multipart ? payload : JSON.stringify(payload),
|
|
18
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
19
|
-
});
|
|
20
|
-
const body = (await res.json());
|
|
21
|
-
if (body.ok)
|
|
22
|
-
return body.result;
|
|
23
|
-
// A long turn split into chunks hits ~1 msg/s per chat; `retry_after` is
|
|
24
|
-
// the exact wait. Obeyed once; a second 429 throws.
|
|
25
|
-
const after = body.parameters?.retry_after;
|
|
26
|
-
if (retry && after !== undefined && after <= 60) {
|
|
27
|
-
await new Promise((r) => setTimeout(r, (after + 1) * 1000));
|
|
28
|
-
return this.call(method, payload, timeoutMs, false);
|
|
29
|
-
}
|
|
30
|
-
throw new Error(`telegram ${method}: ${body.description ?? res.status}`);
|
|
31
|
-
}
|
|
32
|
-
getMe() {
|
|
33
|
-
return this.call("getMe", {});
|
|
34
|
-
}
|
|
35
|
-
getUpdates(offset, timeoutSeconds) {
|
|
36
|
-
return this.call("getUpdates", { offset, timeout: timeoutSeconds, allowed_updates: ["message", "callback_query"] }, (timeoutSeconds + 15) * 1000);
|
|
37
|
-
}
|
|
38
|
-
sendMessage(payload) {
|
|
39
|
-
return this.call("sendMessage", payload);
|
|
40
|
-
}
|
|
41
|
-
async sendFile({ chat_id, message_thread_id, file }) {
|
|
42
|
-
const form = new FormData();
|
|
43
|
-
form.set("chat_id", String(chat_id));
|
|
44
|
-
if (message_thread_id !== undefined)
|
|
45
|
-
form.set("message_thread_id", String(message_thread_id));
|
|
46
|
-
const field = file.image ? "photo" : "document";
|
|
47
|
-
// A Blob part must be backed by an ArrayBuffer, not a Buffer's ArrayBufferLike.
|
|
48
|
-
form.set(field, new Blob([new Uint8Array(file.bytes)]), file.name);
|
|
49
|
-
// 30s is the budget for a JSON call, not for megabytes on a slow link.
|
|
50
|
-
await this.call(file.image ? "sendPhoto" : "sendDocument", form, 120_000);
|
|
51
|
-
}
|
|
52
|
-
async editMessage(payload) {
|
|
53
|
-
await this.call("editMessageText", payload);
|
|
54
|
-
}
|
|
55
|
-
async deleteMessage(chatId, messageId) {
|
|
56
|
-
await this.call("deleteMessage", { chat_id: chatId, message_id: messageId });
|
|
57
|
-
}
|
|
58
|
-
async clearKeyboard(chatId, messageId) {
|
|
59
|
-
await this.call("editMessageReplyMarkup", { chat_id: chatId, message_id: messageId });
|
|
60
|
-
}
|
|
61
|
-
async setReaction(chatId, messageId, emoji) {
|
|
62
|
-
await this.call("setMessageReaction", {
|
|
63
|
-
chat_id: chatId,
|
|
64
|
-
message_id: messageId,
|
|
65
|
-
reaction: emoji ? [{ type: "emoji", emoji }] : [],
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
createForumTopic(chatId, name) {
|
|
69
|
-
return this.call("createForumTopic", { chat_id: chatId, name });
|
|
70
|
-
}
|
|
71
|
-
async answerCallbackQuery(id, text) {
|
|
72
|
-
await this.call("answerCallbackQuery", { callback_query_id: id, text });
|
|
73
|
-
}
|
|
74
|
-
async downloadFile(fileId, maxBytes) {
|
|
75
|
-
const file = await this.call("getFile", { file_id: fileId });
|
|
76
|
-
if (!file.file_path)
|
|
77
|
-
throw new Error("telegram getFile: no file_path");
|
|
78
|
-
const res = await fetch(`${BASE}/file/bot${this.token}/${file.file_path}`, {
|
|
79
|
-
signal: AbortSignal.timeout(60_000),
|
|
80
|
-
});
|
|
81
|
-
if (!res.ok)
|
|
82
|
-
throw new Error(`telegram file download: ${res.status}`);
|
|
83
|
-
const name = file.file_path.split("/").pop() || "file";
|
|
84
|
-
return { bytes: await readCapped(res.body, maxBytes), name };
|
|
85
|
-
}
|
|
86
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
// Telegram's half of the settings panel (panel.ts has the rest). A forced reply
|
|
2
|
-
// arrives as an ordinary message, so the prompt's id is remembered to recognize it.
|
|
3
|
-
import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
|
|
4
|
-
import { escapeHtml as esc } from "./telegram-render.js";
|
|
5
|
-
const button = (b) => ({ text: b.label, callback_data: `${PANEL_PREFIX}${b.action}` });
|
|
6
|
-
export class TelegramPanel extends ChatPanel {
|
|
7
|
-
deps;
|
|
8
|
-
platform = "telegram";
|
|
9
|
-
fence = ["<code>", "</code>"];
|
|
10
|
-
cwdPrompts = new Map();
|
|
11
|
-
constructor(deps) {
|
|
12
|
-
super(deps);
|
|
13
|
-
this.deps = deps;
|
|
14
|
-
}
|
|
15
|
-
esc(text) {
|
|
16
|
-
return esc(text);
|
|
17
|
-
}
|
|
18
|
-
gateExtras(chat, policy) {
|
|
19
|
-
return chat.kind === "forum" ? ` · topics ${policy.topicMode ? "on" : "off"}` : "";
|
|
20
|
-
}
|
|
21
|
-
// --- rendering ---------------------------------------------------------------
|
|
22
|
-
text(view, note) {
|
|
23
|
-
const body = view.groups
|
|
24
|
-
.map((g) => [`<b>${g.title}</b>${g.suffix ?? ""}`, ...g.lines].join("\n"))
|
|
25
|
-
.join("\n\n");
|
|
26
|
-
return note ? `${body}\n\n<i>${esc(note)}</i>` : body;
|
|
27
|
-
}
|
|
28
|
-
/** A long model id does not share a row with anything. */
|
|
29
|
-
keyboard(view) {
|
|
30
|
-
return {
|
|
31
|
-
inline_keyboard: [
|
|
32
|
-
...(view.picks ?? []).map((pick) => [button(pick)]),
|
|
33
|
-
...view.rows.map((row) => row.map(button)),
|
|
34
|
-
],
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
async open(key, chatId, topicId) {
|
|
38
|
-
const view = await this.view(key, chatId);
|
|
39
|
-
const sent = await this.deps.api.sendMessage({
|
|
40
|
-
chat_id: chatId,
|
|
41
|
-
message_thread_id: topicId,
|
|
42
|
-
text: this.text(view),
|
|
43
|
-
parse_mode: "HTML",
|
|
44
|
-
reply_markup: this.keyboard(view),
|
|
45
|
-
});
|
|
46
|
-
this.remember(key, { chatId, topicId, messageId: sent.message_id, models: [] });
|
|
47
|
-
}
|
|
48
|
-
async draw(state, view, note) {
|
|
49
|
-
await this.deps.api.editMessage({
|
|
50
|
-
chat_id: state.chatId,
|
|
51
|
-
message_id: state.messageId,
|
|
52
|
-
text: this.text(view, note),
|
|
53
|
-
parse_mode: "HTML",
|
|
54
|
-
reply_markup: this.keyboard(view),
|
|
55
|
-
}).catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
|
|
56
|
-
}
|
|
57
|
-
async erase(state) {
|
|
58
|
-
await this.deps.api.deleteMessage(state.chatId, state.messageId)
|
|
59
|
-
.catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
|
|
60
|
-
}
|
|
61
|
-
// --- actions -----------------------------------------------------------------
|
|
62
|
-
/** Returns false when the payload is not ours. */
|
|
63
|
-
async onCallback(query, key) {
|
|
64
|
-
return this.dispatch(key, query.data ?? "", undefined, async () => {
|
|
65
|
-
const message = query.message;
|
|
66
|
-
if (message)
|
|
67
|
-
await this.open(key, String(message.chat.id), message.message_thread_id);
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
// --- working directory (one typed answer) ------------------------------------
|
|
71
|
-
async promptCwd(key, state) {
|
|
72
|
-
const sent = await this.deps.api.sendMessage({
|
|
73
|
-
chat_id: state.chatId,
|
|
74
|
-
message_thread_id: state.topicId,
|
|
75
|
-
text: `Reply with an absolute path. ${CWD_TAIL}`,
|
|
76
|
-
reply_markup: { force_reply: true, input_field_placeholder: CWD_PLACEHOLDER },
|
|
77
|
-
});
|
|
78
|
-
this.cwdPrompts.set(key.conversationId, sent.message_id);
|
|
79
|
-
}
|
|
80
|
-
/** True when this message was the answer and must not reach the agent. */
|
|
81
|
-
async consumeCwdReply(msg, key) {
|
|
82
|
-
const pending = this.cwdPrompts.get(key.conversationId);
|
|
83
|
-
if (!pending || msg.reply_to_message?.message_id !== pending)
|
|
84
|
-
return false;
|
|
85
|
-
this.cwdPrompts.delete(key.conversationId);
|
|
86
|
-
const path = (msg.text ?? "").trim();
|
|
87
|
-
const started = await this.startSessionIn(key, path);
|
|
88
|
-
// The answer was typed in the chat, so the outcome is said there too.
|
|
89
|
-
await this.deps.api.sendMessage({
|
|
90
|
-
chat_id: msg.chat.id,
|
|
91
|
-
message_thread_id: msg.message_thread_id,
|
|
92
|
-
text: "error" in started ? started.error : `New session in <code>${esc(path)}</code>.`,
|
|
93
|
-
parse_mode: "HTML",
|
|
94
|
-
});
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { chunkText } from "./chunk.js";
|
|
2
|
-
// How a reply looks on Telegram: markdown → Bot API HTML. Telegram's parser
|
|
3
|
-
// rejects the whole message on any tag outside its documented set (b, i, s,
|
|
4
|
-
// code, pre, a), so text is escaped first and only those are reintroduced.
|
|
5
|
-
const MAX_CHARS = 3800; // Telegram's hard limit is 4096; leave room for tags.
|
|
6
|
-
export const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
7
|
-
/** Inline emphasis, applied after escaping so `<b>` can only come from us. */
|
|
8
|
-
function inline(text) {
|
|
9
|
-
return text
|
|
10
|
-
// Links first: their label may itself carry emphasis.
|
|
11
|
-
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => `<a href="${url.replace(/"/g, """)}">${label}</a>`)
|
|
12
|
-
.replace(/\*\*([^\n*]+)\*\*/g, "<b>$1</b>")
|
|
13
|
-
.replace(/~~([^\n~]+)~~/g, "<s>$1</s>")
|
|
14
|
-
.replace(/(^|[\s(])[*_]([^\n*_]+)[*_](?=[\s).,!?:;]|$)/g, "$1<i>$2</i>")
|
|
15
|
-
.replace(/^#{1,6}[ \t]+(.+)$/gm, "<b>$1</b>");
|
|
16
|
-
}
|
|
17
|
-
/** Code is stashed before escaping so emphasis inside it stays literal. */
|
|
18
|
-
export function toTelegramHtml(markdown) {
|
|
19
|
-
const stash = [];
|
|
20
|
-
const keep = (html) => `\uE000${stash.push(html) - 1}\uE001`;
|
|
21
|
-
let out = markdown.replace(/```([\w.+-]*)\n?([\s\S]*?)```/g, (_m, lang, code) => keep(lang
|
|
22
|
-
? `<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(code.replace(/\n+$/, ""))}</code></pre>`
|
|
23
|
-
: `<pre>${escapeHtml(code.replace(/\n+$/, ""))}</pre>`));
|
|
24
|
-
out = out.replace(/`([^`\n]+)`/g, (_m, code) => keep(`<code>${escapeHtml(code)}</code>`));
|
|
25
|
-
out = inline(escapeHtml(out));
|
|
26
|
-
return out.replace(/\uE000(\d+)\uE001/g, (_m, i) => stash[Number(i)] ?? "");
|
|
27
|
-
}
|
|
28
|
-
/** A cut inside a <pre> block is fine: Telegram closes the tag on its own. */
|
|
29
|
-
export const chunk = (html) => chunkText(html, MAX_CHARS);
|
|
30
|
-
// --- next-step buttons -------------------------------------------------------
|
|
31
|
-
/** `callback_data` caps at 64 bytes (~21 CJK characters), so buttons send an
|
|
32
|
-
* index and the label is read back off the message's own keyboard. */
|
|
33
|
-
export const OFFER_PREFIX = "sg:";
|
|
34
|
-
// Buttons in one row split its width evenly, so packing is budgeted by display
|
|
35
|
-
// width, not count.
|
|
36
|
-
const ROW_WIDTH = 26;
|
|
37
|
-
const ROW_BUTTONS = 3;
|
|
38
|
-
/** CJK, fullwidth punctuation and emoji take two cells. */
|
|
39
|
-
const displayWidth = (label) => [...label].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1), 0);
|
|
40
|
-
/** A row that is already wide stops accepting: a long label squeezed in
|
|
41
|
-
* truncates everything beside it. */
|
|
42
|
-
export function keyboard(labels) {
|
|
43
|
-
if (!labels.length)
|
|
44
|
-
return undefined;
|
|
45
|
-
const rows = [];
|
|
46
|
-
let used = 0;
|
|
47
|
-
labels.forEach((label, index) => {
|
|
48
|
-
const width = displayWidth(label);
|
|
49
|
-
const row = rows.at(-1);
|
|
50
|
-
const button = { text: label, callback_data: `${OFFER_PREFIX}${index}` };
|
|
51
|
-
if (!row || row.length >= ROW_BUTTONS || used + width > ROW_WIDTH) {
|
|
52
|
-
rows.push([button]);
|
|
53
|
-
used = width;
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
row.push(button);
|
|
57
|
-
used += width;
|
|
58
|
-
});
|
|
59
|
-
return { inline_keyboard: rows };
|
|
60
|
-
}
|
|
61
|
-
/** A payload we never wrote (an older label-as-payload button) passes through. */
|
|
62
|
-
export function offeredLabel(msg, data) {
|
|
63
|
-
if (!data.startsWith(OFFER_PREFIX))
|
|
64
|
-
return data;
|
|
65
|
-
return msg.reply_markup?.inline_keyboard
|
|
66
|
-
.flat()
|
|
67
|
-
.find((button) => button.callback_data === data)
|
|
68
|
-
?.text;
|
|
69
|
-
}
|