@timqi/pier 0.0.29 → 0.1.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.
- package/README.md +58 -125
- package/dist/agent/config.js +24 -11
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +43 -62
- package/dist/agent/listing.js +113 -68
- package/dist/agent/pi.js +204 -211
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +8 -24
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +13 -35
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -260
- package/dist/core/types.js +17 -1
- package/dist/db.js +98 -252
- package/dist/drain.js +58 -51
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +6 -14
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +87 -179
- package/dist/paths.js +10 -26
- package/dist/secrets.js +19 -46
- package/dist/service.js +33 -75
- package/dist/settings.js +42 -65
- package/dist/tasks/agent.js +129 -114
- package/dist/tasks/callbacks.js +9 -19
- package/dist/tasks/command.js +29 -14
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +46 -42
- package/dist/tasks/groups.js +41 -35
- package/dist/tasks/messages.js +121 -182
- package/dist/tasks/outbox.js +61 -55
- package/dist/tasks/routes.js +5 -11
- package/dist/tasks/runs.js +14 -13
- package/dist/tasks/service.js +53 -54
- package/dist/tasks/store.js +53 -30
- package/dist/tasks/tool.js +132 -61
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +100 -327
- package/dist/update.js +21 -44
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +45 -85
- package/dist/web/providers.js +14 -13
- package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +100 -130
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +2 -2
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +131 -180
- package/dist/web/session-state.js +14 -54
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +153 -160
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
- package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
- package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
- package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
- package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
- package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
- package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
- package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
- package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
// Telegram adapter: normalize inbound updates, render outbound turns.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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.
|
|
2
|
+
// Telegram-specific: topic mode, where a message in a forum group's General
|
|
3
|
+
// opens a topic that becomes the session; 👀 receipts on every message that
|
|
4
|
+
// entered a turn; `/bind <code>` in a DM.
|
|
14
5
|
import { awaitsTurn, formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
15
6
|
import { saveInboundAll } from "../core/inbox.js";
|
|
16
7
|
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
@@ -26,20 +17,13 @@ import { chunk, escapeHtml, keyboard, offeredLabel, toTelegramHtml } from "./tel
|
|
|
26
17
|
import { TelegramApi, } from "./telegram-api.js";
|
|
27
18
|
const WORKING = "👀";
|
|
28
19
|
const POLL_SECONDS = 30;
|
|
29
|
-
//
|
|
30
|
-
// Bounds memory without ever advancing the offset past what we accepted.
|
|
20
|
+
// The poll loop waits here without advancing the offset past what it accepted.
|
|
31
21
|
const MAX_ACTIVE_CHATS = 16;
|
|
32
|
-
//
|
|
33
|
-
// that failed, a session that died). Generous: a real coding turn can be long.
|
|
22
|
+
// Generous: a real coding turn can be long.
|
|
34
23
|
const RECEIPT_STALE_MS = 30 * 60_000;
|
|
35
|
-
// Longest stop() waits for in-flight handlers before letting reload() proceed.
|
|
36
24
|
const DRAIN_TIMEOUT_MS = 5000;
|
|
37
25
|
const TOPIC_TITLE_MAX = 60;
|
|
38
|
-
/**
|
|
39
|
-
* A forum conversation is `<chatId>/<topicId>`; anything else is `<chatId>`.
|
|
40
|
-
* This pair is the only definition of the format — control.ts and the panel
|
|
41
|
-
* decode with it rather than splitting on "/" themselves.
|
|
42
|
-
*/
|
|
26
|
+
/** `<chatId>/<topicId>` or `<chatId>`; the only definition of the format. */
|
|
43
27
|
const conversationId = (chatId, topicId) => topicId ? `${chatId}/${topicId}` : String(chatId);
|
|
44
28
|
export const parseConversation = (id) => {
|
|
45
29
|
const [chatId = "", topic] = id.split("/");
|
|
@@ -59,13 +43,10 @@ export class TelegramChannel {
|
|
|
59
43
|
id = "telegram";
|
|
60
44
|
api;
|
|
61
45
|
log;
|
|
62
|
-
/** 👀 lifecycle, durable; see receipts.ts for why it is not just a Map. */
|
|
63
46
|
receipts;
|
|
64
|
-
/** Ordering per chat, concurrency across them; see chains.ts. */
|
|
65
47
|
chains;
|
|
66
|
-
/** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
|
|
67
48
|
gate;
|
|
68
|
-
/**
|
|
49
|
+
/** Absent when no control was wired (tests). */
|
|
69
50
|
panel;
|
|
70
51
|
me;
|
|
71
52
|
offset;
|
|
@@ -74,14 +55,11 @@ export class TelegramChannel {
|
|
|
74
55
|
this.deps = deps;
|
|
75
56
|
this.api = deps.client ?? new TelegramApi(deps.store.get("telegram").token);
|
|
76
57
|
this.log = deps.log ?? ((m) => logger("telegram").warn(m));
|
|
77
|
-
// No cap
|
|
78
|
-
// the ack cursor past an update it has not accepted.
|
|
58
|
+
// No cap: the poll loop applies backpressure itself.
|
|
79
59
|
this.chains = new Chains(this.log);
|
|
80
60
|
this.gate = new Gatekeeper(deps.store, "telegram", this.log);
|
|
81
61
|
this.receipts = new Receipts(
|
|
82
|
-
// The ledger keeps message ids as opaque strings (a Slack ts is not a
|
|
83
|
-
// number); Telegram's own are numeric, so the cast happens right here at
|
|
84
|
-
// the API boundary rather than leaking a platform's id type into shared code.
|
|
62
|
+
// The ledger keeps message ids as opaque strings (a Slack ts is not a number).
|
|
85
63
|
{ setReaction: (chatId, messageId, emoji) => this.api.setReaction(chatId, Number(messageId), emoji) }, deps.receipts ?? new ReceiptLedger("telegram"), this.log, WORKING, RECEIPT_STALE_MS);
|
|
86
64
|
if (deps.control) {
|
|
87
65
|
this.panel = new TelegramPanel({
|
|
@@ -96,35 +74,30 @@ export class TelegramChannel {
|
|
|
96
74
|
const me = await this.api.getMe();
|
|
97
75
|
this.me = { id: me.id, username: me.username ?? "" };
|
|
98
76
|
if (!this.me.username) {
|
|
99
|
-
//
|
|
100
|
-
// group with require-mention on goes silent. Loud, not a debug line.
|
|
77
|
+
// Every group with require-mention on goes silent; loud, not a debug line.
|
|
101
78
|
this.log("bot has no username: mention detection is disabled");
|
|
102
79
|
}
|
|
103
80
|
this.running = true;
|
|
104
|
-
// Best-effort and off the critical path.
|
|
105
81
|
void this.receipts.sweep(true);
|
|
106
82
|
void this.poll(onMessage);
|
|
107
83
|
}
|
|
108
84
|
async stop() {
|
|
109
85
|
this.running = false;
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// handler must not hold that open.
|
|
86
|
+
// reload() starts a replacement right after; two adapters handling one
|
|
87
|
+
// message would prompt the session twice. Bounded: a stuck handler must not
|
|
88
|
+
// hold the Console's save request open.
|
|
114
89
|
await this.chains.drain(DRAIN_TIMEOUT_MS);
|
|
115
90
|
}
|
|
116
91
|
// --- inbound ---------------------------------------------------------------
|
|
117
92
|
async poll(onMessage) {
|
|
118
93
|
while (this.running) {
|
|
119
94
|
try {
|
|
120
|
-
// Floor on an empty round trip:
|
|
121
|
-
//
|
|
122
|
-
// turn this into a hot loop.
|
|
95
|
+
// Floor on an empty round trip: a proxy that answers instantly would
|
|
96
|
+
// otherwise turn the long poll into a hot loop.
|
|
123
97
|
const startedAt = Date.now();
|
|
124
98
|
const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
|
|
125
99
|
if (!this.running)
|
|
126
100
|
return;
|
|
127
|
-
// Asked on every round trip, throttled inside receipts.ts.
|
|
128
101
|
void this.receipts.sweep();
|
|
129
102
|
if (!updates.length && Date.now() - startedAt < 1000) {
|
|
130
103
|
await new Promise((r) => setTimeout(r, 1000));
|
|
@@ -169,10 +142,11 @@ export class TelegramChannel {
|
|
|
169
142
|
const isDm = msg.chat.type === "private";
|
|
170
143
|
const kind = isDm ? "dm" : msg.chat.is_forum ? "forum" : "group";
|
|
171
144
|
const name = msg.chat.title ?? [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" ");
|
|
172
|
-
this.
|
|
145
|
+
if (this.gate.mayDiscover({ isDm, userId: String(msg.from.id) })) {
|
|
146
|
+
this.deps.store.discoverChat("telegram", { id: chatId, name: name || chatId, kind });
|
|
147
|
+
}
|
|
173
148
|
const text = this.stripMention(raw);
|
|
174
149
|
const command = parseCommand(text);
|
|
175
|
-
// A command aimed at another bot in the same group is not ours to answer.
|
|
176
150
|
const mine = !command?.target || command.target.toLowerCase() === this.me?.username.toLowerCase();
|
|
177
151
|
const bindRequest = mine && command?.name === "bind" && isDm;
|
|
178
152
|
const admitted = this.gate.admit("message", chatId, {
|
|
@@ -194,35 +168,31 @@ export class TelegramChannel {
|
|
|
194
168
|
channelId: this.id,
|
|
195
169
|
conversationId: conversationId(chatId, msg.message_thread_id),
|
|
196
170
|
};
|
|
197
|
-
// A typed answer to the panel's directory prompt, not a prompt for the agent.
|
|
198
171
|
if (await this.panel?.consumeCwdReply(msg, here))
|
|
199
172
|
return;
|
|
200
|
-
// `@bot`
|
|
201
|
-
// `/settings` are the same request: show me this conversation's settings.
|
|
173
|
+
// A bare `@bot` and `/settings` are the same request.
|
|
202
174
|
if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length && !msg.document))) {
|
|
203
175
|
return this.panel.open(here, chatId, msg.message_thread_id);
|
|
204
176
|
}
|
|
205
|
-
// Downloading only past the gate: an unauthorized sender must not
|
|
206
|
-
//
|
|
177
|
+
// Downloading only past the gate: an unauthorized sender must not make the
|
|
178
|
+
// bot pull bytes on their behalf.
|
|
207
179
|
const markers = await this.saveAttachments(msg);
|
|
208
180
|
const topicId = await this.routeTopic(msg, text);
|
|
209
181
|
const key = { channelId: this.id, conversationId: conversationId(chatId, topicId) };
|
|
210
182
|
this.receipts.mark(key.conversationId, chatId, String(msg.message_id));
|
|
211
|
-
//
|
|
212
|
-
// is the wrong default when the human is watching a 👀 in a chat window.
|
|
183
|
+
// Steer: a follow-up is the wrong default when the human is watching a 👀.
|
|
213
184
|
onMessage({
|
|
214
185
|
key,
|
|
215
186
|
senderId: String(msg.from.id),
|
|
216
|
-
// A group is many people talking into one session; the update already
|
|
217
|
-
// carries the name, so no lookup is needed here.
|
|
218
187
|
sender: { id: String(msg.from.id), name: senderName(msg.from) },
|
|
219
188
|
text: [text, ...markers].filter(Boolean).join("\n"),
|
|
220
189
|
mode: "steer",
|
|
221
190
|
});
|
|
222
191
|
}
|
|
223
|
-
/** Quick-reply buttons send their own label back as an ordinary message. */
|
|
224
192
|
async onCallback(query, onMessage) {
|
|
225
|
-
|
|
193
|
+
// Unanswered, the tapped button keeps its spinner until Telegram gives up.
|
|
194
|
+
await this.api.answerCallbackQuery(query.id)
|
|
195
|
+
.catch((err) => this.log(`callback ack failed: ${String(err)}`));
|
|
226
196
|
const msg = query.message;
|
|
227
197
|
if (!msg || !query.data)
|
|
228
198
|
return;
|
|
@@ -238,14 +208,12 @@ export class TelegramChannel {
|
|
|
238
208
|
channelId: this.id,
|
|
239
209
|
conversationId: conversationId(chatId, msg.message_thread_id),
|
|
240
210
|
};
|
|
241
|
-
// Panel taps are namespaced `cfg:` and never reach the agent.
|
|
242
211
|
if (await this.panel?.onCallback(query, key))
|
|
243
212
|
return;
|
|
244
213
|
const text = offeredLabel(msg, query.data);
|
|
245
214
|
if (text === undefined) {
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// against the real API the second answer is silently dropped.
|
|
215
|
+
// A callback query may be answered exactly once, and the ack above was
|
|
216
|
+
// it; a second answer is silently dropped.
|
|
249
217
|
await this.api.sendMessage({
|
|
250
218
|
chat_id: chatId,
|
|
251
219
|
message_thread_id: msg.message_thread_id,
|
|
@@ -253,17 +221,10 @@ export class TelegramChannel {
|
|
|
253
221
|
}).catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
|
|
254
222
|
return;
|
|
255
223
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
// A bot cannot post as the user, so the pick is echoed and marked as one.
|
|
261
|
-
// Without it the chat shows an answer to a request nobody can see being
|
|
262
|
-
// made, and there is no message of the user's to carry the eyes.
|
|
263
|
-
//
|
|
264
|
-
// No reply quote: the marker already says what this is, and quoting a long
|
|
265
|
-
// answer to show which of its buttons was tapped costs more space than it
|
|
266
|
-
// explains.
|
|
224
|
+
await this.api.clearKeyboard(chatId, msg.message_id)
|
|
225
|
+
.catch((err) => this.log(`retiring options failed: ${String(err)}`));
|
|
226
|
+
// A bot cannot post as the user, so the pick is echoed: otherwise the chat
|
|
227
|
+
// shows an answer to a request nobody can see, with nothing to carry the eyes.
|
|
267
228
|
const echo = await this.api.sendMessage({
|
|
268
229
|
chat_id: chatId,
|
|
269
230
|
message_thread_id: msg.message_thread_id,
|
|
@@ -273,17 +234,11 @@ export class TelegramChannel {
|
|
|
273
234
|
this.log(`option echo failed: ${String(err)}`);
|
|
274
235
|
return undefined;
|
|
275
236
|
});
|
|
276
|
-
// The receipt goes on the echo, not on the bot message that held the
|
|
277
|
-
// buttons: the eyes mean "this input is being worked on".
|
|
278
237
|
if (echo)
|
|
279
238
|
this.receipts.mark(key.conversationId, chatId, String(echo.message_id));
|
|
280
239
|
onMessage({ key, senderId: String(query.from.id), text, mode: "steer" });
|
|
281
240
|
}
|
|
282
|
-
/**
|
|
283
|
-
* Stop the turn this conversation is running. The abort makes Pi end the
|
|
284
|
-
* turn, which reaches send() through the normal turn-end path and clears the
|
|
285
|
-
* 👀 receipts — so nothing here touches them.
|
|
286
|
-
*/
|
|
241
|
+
/** The abort ends the turn, which reaches send() and clears the receipts. */
|
|
287
242
|
async abortTurn(msg) {
|
|
288
243
|
const key = {
|
|
289
244
|
channelId: this.id,
|
|
@@ -296,10 +251,8 @@ export class TelegramChannel {
|
|
|
296
251
|
text: STOPPED,
|
|
297
252
|
});
|
|
298
253
|
}
|
|
299
|
-
/**
|
|
300
|
-
*
|
|
301
|
-
* a DM that swallows every message looks broken rather than locked.
|
|
302
|
-
*/
|
|
254
|
+
/** Groups stay silent, but a DM that swallows every message looks broken
|
|
255
|
+
* rather than locked. */
|
|
303
256
|
async hintBind(msg) {
|
|
304
257
|
if (!this.gate.mayHint(String(msg.from?.id ?? "")))
|
|
305
258
|
return;
|
|
@@ -311,23 +264,16 @@ export class TelegramChannel {
|
|
|
311
264
|
async bind(msg, code) {
|
|
312
265
|
const user = msg.from;
|
|
313
266
|
const name = senderName(user);
|
|
314
|
-
const
|
|
267
|
+
const outcome = this.deps.store.redeemBindCode("telegram", code, { id: String(user.id), name });
|
|
315
268
|
await this.api.sendMessage({
|
|
316
269
|
chat_id: msg.chat.id,
|
|
317
|
-
text: bindResult(
|
|
270
|
+
text: bindResult(outcome, name),
|
|
318
271
|
});
|
|
319
272
|
}
|
|
320
|
-
/**
|
|
321
|
-
* Topic mode: a message arriving in a forum group's General starts a new
|
|
322
|
-
* topic, so every request gets its own thread and its own Pi session. A
|
|
323
|
-
* reply or a slash command stays put — it is continuing something, not
|
|
324
|
-
* starting it. Failure falls back to the current thread rather than losing
|
|
325
|
-
* the message.
|
|
326
|
-
*/
|
|
273
|
+
/** A reply or a command stays put; failure falls back to the current thread. */
|
|
327
274
|
async routeTopic(msg, text) {
|
|
328
|
-
// Every reason to decline is
|
|
329
|
-
//
|
|
330
|
-
// makes six invisible conditions indistinguishable from a bug.
|
|
275
|
+
// Every reason to decline is logged: six invisible conditions are
|
|
276
|
+
// indistinguishable from a bug.
|
|
331
277
|
const decline = !this.deps.store.policy("telegram", String(msg.chat.id)).topicMode
|
|
332
278
|
? "topic mode off for this chat"
|
|
333
279
|
: msg.chat.type !== "supergroup"
|
|
@@ -348,15 +294,14 @@ export class TelegramChannel {
|
|
|
348
294
|
const title = topicTitle(text);
|
|
349
295
|
try {
|
|
350
296
|
const topic = await this.api.createForumTopic(msg.chat.id, title);
|
|
351
|
-
// The whole point of the notice is to get out of General, so the title is
|
|
352
|
-
// the link into the new topic rather than decoration.
|
|
353
297
|
await this.api.sendMessage({
|
|
354
298
|
chat_id: msg.chat.id,
|
|
355
299
|
text: `→ <a href="${topicLink(msg.chat, topic.message_thread_id)}">${escapeHtml(title)}</a>`,
|
|
356
300
|
parse_mode: "HTML",
|
|
357
301
|
message_thread_id: msg.message_thread_id,
|
|
358
302
|
reply_to_message_id: msg.message_id,
|
|
359
|
-
|
|
303
|
+
// Without the pointer, General shows a question whose answer went elsewhere.
|
|
304
|
+
}).catch((err) => this.log(`topic pointer failed: ${String(err)}`));
|
|
360
305
|
return topic.message_thread_id;
|
|
361
306
|
}
|
|
362
307
|
catch (err) {
|
|
@@ -364,10 +309,8 @@ export class TelegramChannel {
|
|
|
364
309
|
return msg.message_thread_id;
|
|
365
310
|
}
|
|
366
311
|
}
|
|
367
|
-
/** The message's attachments as the shared save loop wants them (the loop
|
|
368
|
-
* itself, size gate and lost markers included, is core/inbox.ts). */
|
|
369
312
|
saveAttachments(msg) {
|
|
370
|
-
//
|
|
313
|
+
// A photo is a size ladder; the last entry is the largest.
|
|
371
314
|
const photo = msg.photo?.at(-1);
|
|
372
315
|
const doc = msg.document;
|
|
373
316
|
return saveInboundAll(this.id, [
|
|
@@ -375,7 +318,7 @@ export class TelegramChannel {
|
|
|
375
318
|
label: "photo",
|
|
376
319
|
mimeType: "image/jpeg",
|
|
377
320
|
size: photo.file_size,
|
|
378
|
-
//
|
|
321
|
+
// A filename comes only from getFile, so the fetch's wins.
|
|
379
322
|
fetch: async () => this.api.downloadFile(photo.file_id, MAX_INBOUND_BYTES),
|
|
380
323
|
}] : []),
|
|
381
324
|
...(doc ? [{
|
|
@@ -388,7 +331,6 @@ export class TelegramChannel {
|
|
|
388
331
|
], this.log);
|
|
389
332
|
}
|
|
390
333
|
// --- addressing ------------------------------------------------------------
|
|
391
|
-
/** Mentioned, replying to the bot, or a slash command aimed at this bot. */
|
|
392
334
|
addressed(text, msg) {
|
|
393
335
|
if (msg.reply_to_message?.from?.id === this.me?.id)
|
|
394
336
|
return true;
|
|
@@ -399,7 +341,6 @@ export class TelegramChannel {
|
|
|
399
341
|
const handle = `@${this.me?.username.toLowerCase()}`;
|
|
400
342
|
return !!this.me?.username && text.toLowerCase().includes(handle);
|
|
401
343
|
}
|
|
402
|
-
/** A leading @bot is addressing, not content — the agent should not see it. */
|
|
403
344
|
stripMention(text) {
|
|
404
345
|
const handle = `@${this.me?.username ?? ""}`;
|
|
405
346
|
if (!this.me?.username)
|
|
@@ -409,31 +350,20 @@ export class TelegramChannel {
|
|
|
409
350
|
: text;
|
|
410
351
|
}
|
|
411
352
|
// --- outbound --------------------------------------------------------------
|
|
412
|
-
/**
|
|
413
|
-
* Called on every turn-end, empty text included: the turn settled with
|
|
414
|
-
* nothing to say, and the 👀 receipts still have to come off.
|
|
415
|
-
*/
|
|
416
353
|
async send(conversation, reply) {
|
|
417
354
|
const { chatId, topicId } = parseConversation(conversation);
|
|
418
|
-
// A file
|
|
419
|
-
// Telegram: the bytes are uploaded instead and the label stays in the text.
|
|
355
|
+
// A local file link is dead in Telegram: the bytes are uploaded instead.
|
|
420
356
|
const { text: spoken, paths } = splitAttachments(reply.text);
|
|
421
357
|
const text = spoken.trim();
|
|
422
|
-
//
|
|
423
|
-
// of nothing it was: total silence is indistinguishable from a crash, and
|
|
424
|
-
// the person waiting cannot tell. See AGENTS.md — an empty turn is still an
|
|
425
|
-
// event, and an event nobody can see is not observable.
|
|
358
|
+
// An empty turn still posts its footer and says which kind of nothing (§5).
|
|
426
359
|
const buttons = keyboard(reply.suggestions);
|
|
427
360
|
const quiet = isSilentReply(reply)
|
|
428
361
|
? `<i>${quietLabel(reply.silence && escapeHtml(reply.silence))}</i>`
|
|
429
362
|
: "";
|
|
430
|
-
// A
|
|
431
|
-
// no silence marker and no meta the body is empty, and a keyboard cannot
|
|
432
|
-
// ride a message that was never sent — so it gets the smallest one.
|
|
363
|
+
// A keyboard cannot ride a message that was never sent.
|
|
433
364
|
const body = ((text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta)) ||
|
|
434
365
|
(buttons ? "…" : "");
|
|
435
|
-
//
|
|
436
|
-
// the stale sweep, looking like the agent is still working.
|
|
366
|
+
// A 👀 left up by a failed send looks like work until the stale sweep.
|
|
437
367
|
await this.receipts.settleAfter(conversation, async () => {
|
|
438
368
|
if (body.trim()) {
|
|
439
369
|
const parts = chunk(body);
|
|
@@ -443,13 +373,10 @@ export class TelegramChannel {
|
|
|
443
373
|
message_thread_id: topicId,
|
|
444
374
|
text: part,
|
|
445
375
|
parse_mode: "HTML",
|
|
446
|
-
// Next-step buttons ride the last chunk; a click sends the label.
|
|
447
376
|
reply_markup: i === parts.length - 1 ? buttons : undefined,
|
|
448
377
|
});
|
|
449
378
|
}
|
|
450
379
|
}
|
|
451
|
-
// Attachments follow the words, so the message that introduces them is
|
|
452
|
-
// above them; anything that could not be sent says so in the chat.
|
|
453
380
|
const lost = await sendAttachments(paths, (file) => this.api.sendFile({ chat_id: chatId, message_thread_id: topicId, file }), this.log);
|
|
454
381
|
if (lost) {
|
|
455
382
|
await this.api.sendMessage({
|
|
@@ -459,18 +386,10 @@ export class TelegramChannel {
|
|
|
459
386
|
parse_mode: "HTML",
|
|
460
387
|
});
|
|
461
388
|
}
|
|
462
|
-
});
|
|
389
|
+
}, reply.meta);
|
|
463
390
|
}
|
|
464
|
-
/**
|
|
465
|
-
*
|
|
466
|
-
* plain — no buttons and no turn footer, because the turn this input triggers
|
|
467
|
-
* has not ended yet.
|
|
468
|
-
*
|
|
469
|
-
* That turn has no message of the user's to carry the 👀 — nobody typed one —
|
|
470
|
-
* so the note wears them, on its last chunk, until the turn-end `send`
|
|
471
|
-
* clears it. An error note is not marked: no turn follows it (`awaitsTurn`),
|
|
472
|
-
* and the eyes would sit there until the stale sweep.
|
|
473
|
-
*/
|
|
391
|
+
/** No footer: the turn this input triggers has not ended. The 👀 goes on the
|
|
392
|
+
* note itself, since that turn has no message of the user's to carry them. */
|
|
474
393
|
async notify(conversation, note) {
|
|
475
394
|
const { chatId, topicId } = parseConversation(conversation);
|
|
476
395
|
const label = originLabel(note.origin);
|
|
@@ -483,31 +402,20 @@ export class TelegramChannel {
|
|
|
483
402
|
parse_mode: "HTML",
|
|
484
403
|
});
|
|
485
404
|
}
|
|
486
|
-
// A failed reaction is swallowed and logged by receipts.ts, so the note
|
|
487
|
-
// itself is never lost to one.
|
|
488
405
|
if (posted && awaitsTurn(note.origin)) {
|
|
489
406
|
this.receipts.mark(conversation, chatId, String(posted.message_id));
|
|
490
407
|
}
|
|
491
408
|
}
|
|
492
409
|
}
|
|
493
|
-
/** Display name from an update, which always carries enough to build one. */
|
|
494
410
|
const senderName = (user) => [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username || String(user.id);
|
|
495
|
-
/**
|
|
496
|
-
*
|
|
497
|
-
* one uses the `/c/<internal id>` form, which is the chat id with its `-100`
|
|
498
|
-
* supergroup prefix removed. Both only resolve for members — exactly the
|
|
499
|
-
* audience standing in General.
|
|
500
|
-
*/
|
|
411
|
+
/** A private supergroup links as `/c/<internal id>`: the chat id with its
|
|
412
|
+
* `-100` prefix removed. */
|
|
501
413
|
function topicLink(chat, topicId) {
|
|
502
414
|
if (chat.username)
|
|
503
415
|
return `https://t.me/${chat.username}/${topicId}`;
|
|
504
416
|
const internal = String(chat.id).replace(/^-100(?=\d)/, "").replace(/^-/, "");
|
|
505
417
|
return `https://t.me/c/${internal}/${topicId}`;
|
|
506
418
|
}
|
|
507
|
-
/**
|
|
508
|
-
*
|
|
509
|
-
* One newline, not a blank line: Telegram has no small or muted text, so the
|
|
510
|
-
* only way to make it read as a footnote instead of its own paragraph is to
|
|
511
|
-
* keep it tucked against the reply.
|
|
512
|
-
*/
|
|
419
|
+
/** One newline, not a blank line: Telegram has no muted text, so tucking it
|
|
420
|
+
* against the reply is the only way it reads as a footnote. */
|
|
513
421
|
const turnFooter = (meta) => meta ? `\n<i>${formatTurnMeta(meta)}</i>` : "";
|
package/dist/channels/types.js
CHANGED
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
// IM channel configuration types — the wire contract shared by the store, the
|
|
2
|
-
// adapters and the Console view (
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Defaults are least-privilege: mention AND bind are required. The platform
|
|
6
|
-
// values are *seeds*, copied into a chat when the bot first sees it — not a
|
|
7
|
-
// fallback consulted at runtime. So every chat carries its own answer and a
|
|
8
|
-
// switch means what it says, instead of a three-state "inherit" nobody can
|
|
9
|
-
// read off the screen.
|
|
2
|
+
// adapters and the Console view (type-only, so no node builtins here). Defaults
|
|
3
|
+
// are least-privilege, and platform values are seeds copied into a chat on
|
|
4
|
+
// discovery, not a runtime fallback: a switch means what it says.
|
|
10
5
|
const PLATFORMS = ["telegram", "slack", "lark"];
|
|
11
6
|
/** Validate at the boundary: an unknown platform is a 404, not a new row. */
|
|
12
7
|
export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
|
|
13
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
* `<chatId>` or `<chatId>/<thread>` — Telegram's topic, Slack's thread_ts,
|
|
16
|
-
* Lark's root message — so the chat half has one decoder instead of one per
|
|
17
|
-
* platform (control.ts used to import all three adapters for exactly this).
|
|
18
|
-
* The *thread* half stays with each adapter: its type and meaning genuinely
|
|
19
|
-
* differ per platform.
|
|
20
|
-
*/
|
|
8
|
+
/** Every adapter spells its ids `<chatId>` or `<chatId>/<thread>`, so the chat
|
|
9
|
+
* half has one decoder; the thread half genuinely differs per platform. */
|
|
21
10
|
export const chatOf = (conversationId) => conversationId.split("/", 1)[0] ?? "";
|
|
22
11
|
export const defaultChannelConfig = () => ({
|
|
23
12
|
enabled: false,
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// What `pier` does when typed. Dispatch only
|
|
3
|
-
//
|
|
4
|
-
// with no database should not open one.
|
|
5
|
-
//
|
|
6
|
-
// Hand-rolled against node:util's parseArgs rather than a CLI framework: this
|
|
7
|
-
// small command set does not earn a dependency (AGENTS.md 8).
|
|
2
|
+
// What `pier` does when typed. Dispatch only, and no server imports until a
|
|
3
|
+
// command needs them: `pier service install` must not open a database.
|
|
8
4
|
import { execFileSync } from "node:child_process";
|
|
9
5
|
import { accessSync, constants, realpathSync } from "node:fs";
|
|
10
6
|
import { delimiter, join, resolve } from "node:path";
|
|
@@ -77,16 +73,13 @@ else if (values.version || command === "version") {
|
|
|
77
73
|
process.stdout.write(`${version}\n`);
|
|
78
74
|
}
|
|
79
75
|
else if (!command) {
|
|
80
|
-
//
|
|
81
|
-
// that and nothing else: it used to start a server, which is a surprising
|
|
82
|
-
// amount to have done by accident.
|
|
76
|
+
// The bare name must not start a server by accident.
|
|
83
77
|
process.stdout.write(HELP);
|
|
84
78
|
}
|
|
85
79
|
else if (command === "serve") {
|
|
86
80
|
if (subcommand)
|
|
87
81
|
fail(`unexpected argument "${subcommand}"`);
|
|
88
82
|
allowOnly([], "pier serve");
|
|
89
|
-
// The server starts on import; this file stays a dispatcher.
|
|
90
83
|
await import("./main.js");
|
|
91
84
|
}
|
|
92
85
|
else if (command === "service") {
|
|
@@ -117,11 +110,8 @@ else {
|
|
|
117
110
|
process.stderr.write(`pier: unknown command "${command}"\n\n${HELP}`);
|
|
118
111
|
process.exit(2);
|
|
119
112
|
}
|
|
120
|
-
/**
|
|
121
|
-
*
|
|
122
|
-
* handed to a second unit — this process is about to be restarted, and a child
|
|
123
|
-
* of the service being restarted dies with it.
|
|
124
|
-
*/
|
|
113
|
+
/** Under systemd the install is handed to a second unit: a child of the
|
|
114
|
+
* service being restarted dies with it. */
|
|
125
115
|
async function update(checkOnly) {
|
|
126
116
|
const check = new UpdateCheck(version);
|
|
127
117
|
await check.refresh();
|
|
@@ -148,18 +138,13 @@ async function update(checkOnly) {
|
|
|
148
138
|
return;
|
|
149
139
|
}
|
|
150
140
|
}
|
|
151
|
-
// No service manager
|
|
152
|
-
// until the operator is ready to run all three steps.
|
|
141
|
+
// No service manager: leave the rollback point to the operator's own sequence.
|
|
153
142
|
say(`pier backup`);
|
|
154
143
|
say(`npm install -g @timqi/pier@${latest}`);
|
|
155
144
|
say(`then restart Pier.`);
|
|
156
145
|
}
|
|
157
|
-
/**
|
|
158
|
-
*
|
|
159
|
-
* instruction; this converges on it and prints what happened, one line per
|
|
160
|
-
* tool. Non-zero when anything failed — the task run is then a failed run with
|
|
161
|
-
* this text in it, which is the whole tools status surface.
|
|
162
|
-
*/
|
|
146
|
+
/** Non-zero when anything failed: the task run is then a failed run with this
|
|
147
|
+
* text in it, which is the whole tools status surface. */
|
|
163
148
|
async function tools(action = "") {
|
|
164
149
|
if (action !== "sync") {
|
|
165
150
|
process.stderr.write(`pier tools: unknown action "${action}"\n\n${HELP}`);
|
|
@@ -171,8 +156,7 @@ async function tools(action = "") {
|
|
|
171
156
|
import("./settings.js"),
|
|
172
157
|
]);
|
|
173
158
|
try {
|
|
174
|
-
// Read inside the sync's lock
|
|
175
|
-
// one must converge on the set as it is when its turn comes.
|
|
159
|
+
// Read inside the sync's lock: a queued sync converges on the set as it is then.
|
|
176
160
|
const settings = new SettingsStore();
|
|
177
161
|
const report = await new ManagedTools().sync(() => settings.get());
|
|
178
162
|
say(report.summary);
|
|
@@ -184,8 +168,6 @@ async function tools(action = "") {
|
|
|
184
168
|
process.exitCode = 1;
|
|
185
169
|
}
|
|
186
170
|
}
|
|
187
|
-
/** Both are signals to the running unit: SIGUSR2 drains then exits (systemd
|
|
188
|
-
* starts the next process), SIGHUP reloads config in place (main.ts). */
|
|
189
171
|
async function signalService(command) {
|
|
190
172
|
if (process.platform !== "linux") {
|
|
191
173
|
return fail(`only under the systemd service — send ${command === "restart" ? "SIGUSR2" : "SIGHUP"} to the pier process yourself`);
|
|
@@ -193,13 +175,11 @@ async function signalService(command) {
|
|
|
193
175
|
const { UNIT_NAME } = await import("./service.js");
|
|
194
176
|
const signal = command === "restart" ? "SIGUSR2" : "SIGHUP";
|
|
195
177
|
try {
|
|
196
|
-
// `--kill-who`, not
|
|
197
|
-
// every systemd still parses (systemd/systemd#29793).
|
|
178
|
+
// `--kill-who`, not `--kill-whom`: the spelling every systemd parses (systemd/systemd#29793).
|
|
198
179
|
execFileSync("systemctl", ["--user", "kill", "-s", signal, "--kill-who=main", UNIT_NAME], { stdio: "inherit" });
|
|
199
180
|
}
|
|
200
181
|
catch (err) {
|
|
201
|
-
// A failed kill already printed why
|
|
202
|
-
// systemctl printed nothing, so name it (same shape as `service status`).
|
|
182
|
+
// A failed kill already printed why; a missing systemctl printed nothing.
|
|
203
183
|
if (err.code === "ENOENT") {
|
|
204
184
|
process.stderr.write(`pier: systemctl is not on PATH — no systemd here.\n`);
|
|
205
185
|
}
|
|
@@ -212,8 +192,7 @@ async function signalService(command) {
|
|
|
212
192
|
}
|
|
213
193
|
async function backup() {
|
|
214
194
|
const [{ backupDb }, { PIER_DB }] = await Promise.all([import("./db.js"), import("./paths.js")]);
|
|
215
|
-
// This tree's version: the updater runs `backup` before npm replaces it
|
|
216
|
-
// it is the release the copy pairs with.
|
|
195
|
+
// This tree's version: the updater runs `backup` before npm replaces it.
|
|
217
196
|
const path = backupDb(version, PIER_DB);
|
|
218
197
|
process.stdout.write(path ? `backed up ${path}\n` : `no database yet — nothing to back up.\n`);
|
|
219
198
|
}
|
|
@@ -225,7 +204,7 @@ function commandPath(name) {
|
|
|
225
204
|
return realpathSync(path);
|
|
226
205
|
}
|
|
227
206
|
catch {
|
|
228
|
-
// Keep looking: version managers
|
|
207
|
+
// Keep looking: version managers put several prefixes on PATH.
|
|
229
208
|
}
|
|
230
209
|
}
|
|
231
210
|
return fail(`${name} is not executable on PATH`);
|
|
@@ -240,7 +219,7 @@ async function service(action = "status") {
|
|
|
240
219
|
process.exit(2);
|
|
241
220
|
}
|
|
242
221
|
switch (action) {
|
|
243
|
-
case "install": {
|
|
222
|
+
case "install": {
|
|
244
223
|
allowOnly(["force", "port", "host", "pier-home"], "pier service install");
|
|
245
224
|
const port = typeof values.port === "string" ? Number(values.port) : 3141;
|
|
246
225
|
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
@@ -255,8 +234,7 @@ async function service(action = "status") {
|
|
|
255
234
|
if (!install({
|
|
256
235
|
execPath: process.execPath,
|
|
257
236
|
npmPath: commandPath("npm"),
|
|
258
|
-
//
|
|
259
|
-
// they expect a turn's commands to see; the unit records it.
|
|
237
|
+
// Typed in the operator's shell, so this PATH is the one a turn should see.
|
|
260
238
|
shellPath: process.env.PATH,
|
|
261
239
|
entry: fileURLToPath(new URL("./main.js", import.meta.url)),
|
|
262
240
|
host,
|
|
@@ -276,13 +254,11 @@ async function service(action = "status") {
|
|
|
276
254
|
case "status":
|
|
277
255
|
allowOnly([], "pier service status");
|
|
278
256
|
try {
|
|
279
|
-
//
|
|
280
|
-
// its exit code is nonzero for a service that is merely stopped.
|
|
257
|
+
// Exit code is nonzero for a service that is merely stopped.
|
|
281
258
|
execFileSync("systemctl", ["--user", "status", UNIT_NAME], { stdio: "inherit" });
|
|
282
259
|
}
|
|
283
260
|
catch (err) {
|
|
284
|
-
// A stopped service already printed its status; a missing systemctl
|
|
285
|
-
// printed nothing, so name it.
|
|
261
|
+
// A stopped service already printed its status; a missing systemctl printed nothing.
|
|
286
262
|
if (err.code === "ENOENT") {
|
|
287
263
|
process.stderr.write(`pier: systemctl is not on PATH — no systemd here.\n`);
|
|
288
264
|
}
|