@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.
- package/LICENSE +661 -0
- package/README.md +97 -0
- package/dist/agent/config.js +133 -0
- package/dist/agent/credentials.js +179 -0
- package/dist/agent/events.js +253 -0
- package/dist/agent/models.js +15 -0
- package/dist/agent/pi.js +296 -0
- package/dist/boards/boards.js +200 -0
- package/dist/boards/pier.css +445 -0
- package/dist/channels/chains.js +67 -0
- package/dist/channels/chunk.js +28 -0
- package/dist/channels/commands.js +28 -0
- package/dist/channels/config.js +172 -0
- package/dist/channels/control.js +71 -0
- package/dist/channels/conversations.js +65 -0
- package/dist/channels/gatekeeper.js +63 -0
- package/dist/channels/panel.js +233 -0
- package/dist/channels/receipts.js +104 -0
- package/dist/channels/routes.js +110 -0
- package/dist/channels/runtime.js +76 -0
- package/dist/channels/slack-api.js +296 -0
- package/dist/channels/slack-directory.js +77 -0
- package/dist/channels/slack-outbound.js +121 -0
- package/dist/channels/slack-panel.js +122 -0
- package/dist/channels/slack-render.js +214 -0
- package/dist/channels/slack-tool.js +334 -0
- package/dist/channels/slack.js +510 -0
- package/dist/channels/telegram-api.js +78 -0
- package/dist/channels/telegram-panel.js +113 -0
- package/dist/channels/telegram-render.js +96 -0
- package/dist/channels/telegram.js +473 -0
- package/dist/channels/types.js +27 -0
- package/dist/cli.js +101 -0
- package/dist/core/hub.js +53 -0
- package/dist/core/identity.js +66 -0
- package/dist/core/queue.js +11 -0
- package/dist/core/reply.js +202 -0
- package/dist/core/router.js +189 -0
- package/dist/core/types.js +7 -0
- package/dist/db.js +268 -0
- package/dist/log.js +55 -0
- package/dist/main.js +183 -0
- package/dist/paths.js +17 -0
- package/dist/secrets.js +191 -0
- package/dist/service.js +134 -0
- package/dist/settings.js +57 -0
- package/dist/tasks/agent.js +197 -0
- package/dist/tasks/callbacks.js +140 -0
- package/dist/tasks/command.js +74 -0
- package/dist/tasks/definitions.js +316 -0
- package/dist/tasks/execution.js +141 -0
- package/dist/tasks/groups.js +187 -0
- package/dist/tasks/messages.js +248 -0
- package/dist/tasks/routes.js +219 -0
- package/dist/tasks/runs.js +104 -0
- package/dist/tasks/service.js +282 -0
- package/dist/tasks/store.js +168 -0
- package/dist/tasks/tool.js +281 -0
- package/dist/tasks/types.js +5 -0
- package/dist/web/auth.js +280 -0
- package/dist/web/files.js +167 -0
- package/dist/web/public/assets/index-8CinH1uR.css +2 -0
- package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +19 -0
- package/dist/web/public/index.html +251 -0
- package/dist/web/public/manifest.webmanifest +16 -0
- package/dist/web/public/sw.js +21 -0
- package/dist/web/server.js +366 -0
- package/dist/web/session-state.js +39 -0
- package/docs/deploy.md +307 -0
- package/package.json +55 -0
- package/skills/pier-boards/SKILL.md +210 -0
- package/skills/pier-slack/SKILL.md +135 -0
- package/skills/pier-tasks/SKILL.md +120 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
// Slack adapter: normalize inbound Socket Mode envelopes, render outbound turns.
|
|
2
|
+
//
|
|
3
|
+
// The anchor is threads. Pier never posts into a channel's main flow: a message
|
|
4
|
+
// in the channel is answered in *its own* thread, a message in a thread is
|
|
5
|
+
// answered in that thread. So a conversation is `<channel>/<threadTs>` and the
|
|
6
|
+
// thread is the session โ which makes Telegram's `topicMode` toggle meaningless
|
|
7
|
+
// here, because there is no other behaviour to switch to. Threads cost no admin
|
|
8
|
+
// right and no group conversion on Slack, so the feature Telegram has to
|
|
9
|
+
// negotiate for is simply how this adapter always works.
|
|
10
|
+
//
|
|
11
|
+
// Three more things are Slack-specific and live only here:
|
|
12
|
+
// - Commands cannot start with `/`. The Slack client intercepts an
|
|
13
|
+
// unregistered slash command and never sends it to an app, so `stop` and
|
|
14
|
+
// `settings` are bare words instead. Registering real slash commands is
|
|
15
|
+
// deliberately not a feature: it needs manifest setup to add a second way to
|
|
16
|
+
// do what a word in the thread already does, and Slack only sends `thread_ts`
|
|
17
|
+
// for a command typed *inside* a thread, so it would be the weaker path too.
|
|
18
|
+
// - Reactions are short names (`eyes`), not codepoints; `reactions.add`
|
|
19
|
+
// rejects ๐ with `invalid_name`.
|
|
20
|
+
// - Slack redelivers anything it did not see acknowledged, so `event_id` is
|
|
21
|
+
// deduplicated.
|
|
22
|
+
//
|
|
23
|
+
// Everything policy-shaped (mention/bind gates, per-chat overrides) is in
|
|
24
|
+
// config.ts, platform-blind and shared with Telegram.
|
|
25
|
+
import { logger } from "../log.js";
|
|
26
|
+
import { Chains } from "./chains.js";
|
|
27
|
+
import { parseCommand } from "./commands.js";
|
|
28
|
+
import { Gatekeeper } from "./gatekeeper.js";
|
|
29
|
+
import { ReceiptLedger, Receipts } from "./receipts.js";
|
|
30
|
+
import { SlackDirectory } from "./slack-directory.js";
|
|
31
|
+
import { SlackApi, } from "./slack-api.js";
|
|
32
|
+
import { SlackOutbound } from "./slack-outbound.js";
|
|
33
|
+
import { SlackPanel } from "./slack-panel.js";
|
|
34
|
+
import { context, escapeMrkdwn, offeredLabel } from "./slack-render.js";
|
|
35
|
+
/** Slack wants a short name here; the raw codepoint is an `invalid_name`. */
|
|
36
|
+
const WORKING = "eyes";
|
|
37
|
+
// Backpressure: how many channels may be handled at once before a new one
|
|
38
|
+
// queues behind an existing chain. Unlike Telegram's poll loop this cannot
|
|
39
|
+
// slow the source down โ the envelope is already acked โ so it bounds
|
|
40
|
+
// concurrency (open sockets, downloads) rather than the backlog itself.
|
|
41
|
+
const MAX_ACTIVE_CHATS = 16;
|
|
42
|
+
const RECEIPT_STALE_MS = 30 * 60_000;
|
|
43
|
+
const DRAIN_TIMEOUT_MS = 5000;
|
|
44
|
+
/**
|
|
45
|
+
* How long a delivered `event_id` is remembered. Slack retries an unacked
|
|
46
|
+
* envelope for a few minutes; we ack immediately, so this only has to cover
|
|
47
|
+
* a redelivery that crossed our ack.
|
|
48
|
+
*/
|
|
49
|
+
const DEDUP_TTL_MS = 5 * 60_000;
|
|
50
|
+
const DEDUP_MAX = 2000;
|
|
51
|
+
/** How often the straggler sweep may run; see onEnvelope. */
|
|
52
|
+
const SWEEP_EVERY_MS = 60_000;
|
|
53
|
+
/**
|
|
54
|
+
* Commands that may appear as a bare word, and exactly how many arguments each
|
|
55
|
+
* takes. Both halves are load-bearing, because Slack gives us no leading `/` to
|
|
56
|
+
* key on: a closed set keeps ordinary prose from being a command, and the
|
|
57
|
+
* exact arity keeps "settings are broken, please help" from opening the panel
|
|
58
|
+
* (or worse, "stop the deploy and tell me why" from aborting the turn that was
|
|
59
|
+
* about to explain). Anything longer is a sentence, and goes to the agent.
|
|
60
|
+
*/
|
|
61
|
+
const BARE_COMMANDS = new Map([["stop", 0], ["settings", 0], ["bind", 1]]);
|
|
62
|
+
/**
|
|
63
|
+
* A Slack conversation is always `<channel>/<threadTs>` โ the thread is the
|
|
64
|
+
* session. This pair is the only definition of the format; control.ts decodes
|
|
65
|
+
* with it rather than splitting on "/" itself.
|
|
66
|
+
*/
|
|
67
|
+
const conversationId = (channel, threadTs) => `${channel}/${threadTs}`;
|
|
68
|
+
export const parseConversation = (id) => {
|
|
69
|
+
const at = id.indexOf("/");
|
|
70
|
+
return at < 0
|
|
71
|
+
? { channel: id, threadTs: "" }
|
|
72
|
+
: { channel: id.slice(0, at), threadTs: id.slice(at + 1) };
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* The thread a message belongs to. A message already in a thread keeps it; one
|
|
76
|
+
* posted in the channel becomes the root of its own โ which is what makes
|
|
77
|
+
* every request its own session without asking Slack for anything.
|
|
78
|
+
*/
|
|
79
|
+
const threadOf = (event) => event.thread_ts ?? event.ts ?? "";
|
|
80
|
+
/**
|
|
81
|
+
* Subtypes worth reading. Everything else (joins, edits, deletions, topic
|
|
82
|
+
* changes) is noise, and `bot_message` is either our own echo or another app's.
|
|
83
|
+
*/
|
|
84
|
+
const READABLE_SUBTYPES = new Set(["file_share", "thread_broadcast"]);
|
|
85
|
+
/**
|
|
86
|
+
* What the user asked for, from text a mention has already been stripped from.
|
|
87
|
+
* `/stop` is accepted for muscle memory even though Slack rarely lets one
|
|
88
|
+
* through; a bare `stop` is the form that actually arrives.
|
|
89
|
+
*/
|
|
90
|
+
function slackCommand(text) {
|
|
91
|
+
const slash = parseCommand(text);
|
|
92
|
+
if (slash)
|
|
93
|
+
return { name: slash.name, args: slash.args };
|
|
94
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
95
|
+
const name = words[0]?.toLowerCase() ?? "";
|
|
96
|
+
const arity = BARE_COMMANDS.get(name);
|
|
97
|
+
if (arity === undefined || words.length - 1 !== arity)
|
|
98
|
+
return undefined;
|
|
99
|
+
return { name, args: words.slice(1).join(" ") };
|
|
100
|
+
}
|
|
101
|
+
export class SlackChannel {
|
|
102
|
+
deps;
|
|
103
|
+
id = "slack";
|
|
104
|
+
api;
|
|
105
|
+
log;
|
|
106
|
+
/** ๐ lifecycle, durable; see receipts.ts for why it is not just a Map. */
|
|
107
|
+
receipts;
|
|
108
|
+
/** Ordering per channel, concurrency across them; see chains.ts. */
|
|
109
|
+
chains;
|
|
110
|
+
/** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
|
|
111
|
+
gate;
|
|
112
|
+
/** `event_id`s already handled, against Slack's at-least-once delivery. */
|
|
113
|
+
seen = new Map();
|
|
114
|
+
/** The in-chat settings panel; absent when no control was wired (tests). */
|
|
115
|
+
panel;
|
|
116
|
+
/** Channel kinds/names and user names, cached; see slack-directory.ts. */
|
|
117
|
+
directory;
|
|
118
|
+
/**
|
|
119
|
+
* Channels already reported to the store this process. Slack's message event
|
|
120
|
+
* carries no channel name, so discovery costs an API call โ once per channel
|
|
121
|
+
* per process rather than once per message. A rename is picked up on restart,
|
|
122
|
+
* which is soon enough for a Console display label.
|
|
123
|
+
*/
|
|
124
|
+
discovered = new Set();
|
|
125
|
+
me = "";
|
|
126
|
+
/** Precompiled from `me`: a leading mention, and any mention. */
|
|
127
|
+
mention;
|
|
128
|
+
out;
|
|
129
|
+
socket;
|
|
130
|
+
running = false;
|
|
131
|
+
sweptAt = 0;
|
|
132
|
+
constructor(deps) {
|
|
133
|
+
this.deps = deps;
|
|
134
|
+
const config = deps.store.get("slack");
|
|
135
|
+
this.log = deps.log ?? ((m) => logger("slack").warn(m));
|
|
136
|
+
this.chains = new Chains(this.log, MAX_ACTIVE_CHATS);
|
|
137
|
+
this.directory = deps.directory ?? new SlackDirectory(this.log);
|
|
138
|
+
this.gate = new Gatekeeper(deps.store, "slack", this.log, "channel");
|
|
139
|
+
this.api = deps.client ?? new SlackApi(config.token, config.appToken, this.log);
|
|
140
|
+
this.out = new SlackOutbound(this.api, this.log);
|
|
141
|
+
this.receipts = new Receipts(
|
|
142
|
+
// Slack names its reactions; the clear needs that name back, and Pier
|
|
143
|
+
// only ever applies the one.
|
|
144
|
+
{
|
|
145
|
+
setReaction: (channel, ts, emoji) => emoji
|
|
146
|
+
? this.api.addReaction(channel, ts, emoji)
|
|
147
|
+
: this.api.removeReaction(channel, ts, WORKING),
|
|
148
|
+
}, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS);
|
|
149
|
+
if (deps.control) {
|
|
150
|
+
this.panel = new SlackPanel({
|
|
151
|
+
api: this.api,
|
|
152
|
+
control: deps.control,
|
|
153
|
+
store: deps.store,
|
|
154
|
+
log: this.log,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async start(onMessage) {
|
|
159
|
+
const auth = await this.api.authTest();
|
|
160
|
+
this.me = auth.userId;
|
|
161
|
+
if (this.me) {
|
|
162
|
+
// A Slack user id is `[A-Z0-9]+`, so it needs no escaping โ but building
|
|
163
|
+
// these once keeps two regex compiles off the per-message path.
|
|
164
|
+
this.mention = {
|
|
165
|
+
leading: new RegExp(`^\\s*<@${this.me}>[\\s,:-]*`),
|
|
166
|
+
any: new RegExp(`<@${this.me}>`, "g"),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Without our own user id, "was I mentioned?" can only answer no, so
|
|
171
|
+
// every channel with require-mention on goes silent. Loud, not a debug line.
|
|
172
|
+
this.log("auth.test returned no user id: mention detection is disabled");
|
|
173
|
+
}
|
|
174
|
+
this.running = true;
|
|
175
|
+
// Best-effort and off the critical path.
|
|
176
|
+
void this.receipts.sweep(true);
|
|
177
|
+
this.socket = await this.api.connect((env) => this.onEnvelope(env, onMessage));
|
|
178
|
+
}
|
|
179
|
+
async stop() {
|
|
180
|
+
this.running = false;
|
|
181
|
+
await this.socket?.close().catch(() => { });
|
|
182
|
+
this.socket = undefined;
|
|
183
|
+
await this.chains.drain(DRAIN_TIMEOUT_MS);
|
|
184
|
+
}
|
|
185
|
+
// --- inbound ---------------------------------------------------------------
|
|
186
|
+
/**
|
|
187
|
+
* Already acknowledged by the transport. Routing is synchronous so ordering
|
|
188
|
+
* is decided here, before any await.
|
|
189
|
+
*/
|
|
190
|
+
onEnvelope(env, onMessage) {
|
|
191
|
+
if (!this.running)
|
|
192
|
+
return;
|
|
193
|
+
// Envelopes arrive pushed, not in polled batches, so the sweep is timed
|
|
194
|
+
// rather than per-envelope: a busy channel would otherwise run this query
|
|
195
|
+
// hundreds of times a minute for something that changes every 30.
|
|
196
|
+
if (Date.now() - this.sweptAt > SWEEP_EVERY_MS) {
|
|
197
|
+
this.sweptAt = Date.now();
|
|
198
|
+
void this.receipts.sweep();
|
|
199
|
+
}
|
|
200
|
+
if (env.type === "events_api") {
|
|
201
|
+
const payload = env.payload;
|
|
202
|
+
const event = payload?.event;
|
|
203
|
+
if (!event)
|
|
204
|
+
return;
|
|
205
|
+
// `app_mention` duplicates a `message.channels` we already get, and has
|
|
206
|
+
// its own event_id, so dedup cannot save us โ it has to be ignored here.
|
|
207
|
+
if (event.type !== "message") {
|
|
208
|
+
this.log(`ignored event type ${event.type}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (this.duplicate(payload?.event_id))
|
|
212
|
+
return;
|
|
213
|
+
const channel = event.channel;
|
|
214
|
+
if (!channel)
|
|
215
|
+
return this.log("message event without a channel, dropped");
|
|
216
|
+
this.chains.run(channel, () => this.onMessage(event, onMessage));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (env.type === "interactive") {
|
|
220
|
+
const interaction = env.payload;
|
|
221
|
+
if (!interaction)
|
|
222
|
+
return;
|
|
223
|
+
// A modal submission carries its conversation in private_metadata, so it
|
|
224
|
+
// is not tied to a channel chain.
|
|
225
|
+
const channel = interaction.channel?.id ?? "modal";
|
|
226
|
+
this.chains.run(channel, () => this.onInteraction(interaction, onMessage));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
this.log(`ignored envelope type ${env.type}`);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Slack redelivers an envelope it did not see acknowledged, so the same
|
|
233
|
+
* `event_id` can arrive twice. Bounded and time-limited: the map is fed by
|
|
234
|
+
* every message in every channel the bot is in.
|
|
235
|
+
*/
|
|
236
|
+
duplicate(eventId) {
|
|
237
|
+
if (!eventId)
|
|
238
|
+
return false;
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
if (this.seen.size > DEDUP_MAX) {
|
|
241
|
+
for (const [id, at] of this.seen) {
|
|
242
|
+
if (now - at > DEDUP_TTL_MS)
|
|
243
|
+
this.seen.delete(id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const at = this.seen.get(eventId);
|
|
247
|
+
if (at !== undefined && now - at <= DEDUP_TTL_MS) {
|
|
248
|
+
this.log(`duplicate event ${eventId} ignored`);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
this.seen.set(eventId, now);
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
async onMessage(event, onMessage) {
|
|
255
|
+
// Our own echo, another app, or a subtype that is not a person talking.
|
|
256
|
+
if (event.bot_id || !event.user || event.user === this.me)
|
|
257
|
+
return;
|
|
258
|
+
if (event.subtype && !READABLE_SUBTYPES.has(event.subtype)) {
|
|
259
|
+
this.log(`ignored message subtype ${event.subtype}`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const channel = event.channel;
|
|
263
|
+
const ts = event.ts;
|
|
264
|
+
if (!ts)
|
|
265
|
+
return this.log("message event without a ts, dropped");
|
|
266
|
+
const raw = (event.text ?? "").trim();
|
|
267
|
+
const files = (event.files ?? []).filter((f) => f.mimetype?.startsWith("image/"));
|
|
268
|
+
if (!raw && !files.length)
|
|
269
|
+
return;
|
|
270
|
+
const { kind } = await this.directory.channel(this.api, channel, event);
|
|
271
|
+
const isDm = kind === "dm";
|
|
272
|
+
if (!this.discovered.has(channel)) {
|
|
273
|
+
this.discovered.add(channel);
|
|
274
|
+
const name = await this.nameOf(channel, event);
|
|
275
|
+
this.deps.store.discoverChat("slack", { id: channel, name, kind });
|
|
276
|
+
}
|
|
277
|
+
const text = this.stripMention(raw);
|
|
278
|
+
const command = slackCommand(text);
|
|
279
|
+
const threadTs = threadOf(event);
|
|
280
|
+
const here = { channelId: this.id, conversationId: conversationId(channel, threadTs) };
|
|
281
|
+
const bindRequest = command?.name === "bind" && isDm;
|
|
282
|
+
const admitted = this.gate.admit("message", channel, {
|
|
283
|
+
isDm,
|
|
284
|
+
addressed: this.addressed(raw, event, here),
|
|
285
|
+
userId: event.user,
|
|
286
|
+
bindRequest,
|
|
287
|
+
});
|
|
288
|
+
if (!admitted) {
|
|
289
|
+
if (isDm)
|
|
290
|
+
await this.hintBind(channel, event.user, threadTs);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (bindRequest)
|
|
294
|
+
return this.bind(channel, event.user, threadTs, command?.args ?? "");
|
|
295
|
+
if (command?.name === "stop")
|
|
296
|
+
return this.abortTurn(here, channel, threadTs);
|
|
297
|
+
// `@bot` on its own (the text is empty once the mention is stripped) and
|
|
298
|
+
// `settings` are the same request: show me this conversation's settings.
|
|
299
|
+
if (this.panel && (command?.name === "settings" || (!text && !files.length))) {
|
|
300
|
+
return this.panel.open(here, channel, threadTs);
|
|
301
|
+
}
|
|
302
|
+
// Downloading only past the gate: an unauthorized sender must not be able
|
|
303
|
+
// to make the bot pull bytes on their behalf.
|
|
304
|
+
const images = await this.images(files);
|
|
305
|
+
this.receipts.mark(here.conversationId, channel, ts);
|
|
306
|
+
// IM messages steer by default: a follow-up that waits for the turn to end
|
|
307
|
+
// is the wrong default when the human is watching a ๐ in a thread.
|
|
308
|
+
onMessage({
|
|
309
|
+
key: here,
|
|
310
|
+
senderId: event.user,
|
|
311
|
+
// A Slack thread is many people talking into one session, so the agent is
|
|
312
|
+
// told who spoke โ and the id, which is what a mention needs.
|
|
313
|
+
sender: { id: event.user, name: await this.directory.user(this.api, event.user) },
|
|
314
|
+
text,
|
|
315
|
+
images,
|
|
316
|
+
mode: "steer",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// --- interactions ----------------------------------------------------------
|
|
320
|
+
async onInteraction(interaction, onMessage) {
|
|
321
|
+
// A modal submission carries the conversation in private_metadata and is
|
|
322
|
+
// answered entirely by the panel.
|
|
323
|
+
if (interaction.type === "view_submission") {
|
|
324
|
+
if (!(await this.panel?.onViewSubmission(interaction))) {
|
|
325
|
+
this.log(`unhandled view submission ${interaction.view?.callback_id ?? "?"}`);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (interaction.type !== "block_actions") {
|
|
330
|
+
this.log(`ignored interaction type ${interaction.type}`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const channel = interaction.channel?.id;
|
|
334
|
+
const message = interaction.message;
|
|
335
|
+
const actionId = interaction.actions?.[0]?.action_id;
|
|
336
|
+
const user = interaction.user?.id;
|
|
337
|
+
if (!channel || !message || !actionId || !user) {
|
|
338
|
+
this.log("incomplete block_actions payload, dropped");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const threadTs = message.thread_ts ?? message.ts;
|
|
342
|
+
const key = {
|
|
343
|
+
channelId: this.id,
|
|
344
|
+
conversationId: conversationId(channel, threadTs),
|
|
345
|
+
};
|
|
346
|
+
const admitted = this.gate.admit("action", channel, {
|
|
347
|
+
isDm: (await this.directory.channel(this.api, channel)).kind === "dm",
|
|
348
|
+
addressed: true, // clicking the bot's own button is addressing it
|
|
349
|
+
userId: user,
|
|
350
|
+
});
|
|
351
|
+
if (!admitted)
|
|
352
|
+
return;
|
|
353
|
+
// Panel clicks are namespaced `cfg:` and never reach the agent.
|
|
354
|
+
if (await this.panel?.onAction(interaction, key, actionId))
|
|
355
|
+
return;
|
|
356
|
+
const text = offeredLabel(message.blocks, actionId);
|
|
357
|
+
if (text === undefined) {
|
|
358
|
+
this.log(`unknown action ${actionId} in channel ${channel}`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// The options belonged to the turn that just ended; once one is taken the
|
|
362
|
+
// rest answer a question the conversation has moved past (the web drops
|
|
363
|
+
// them for the same reason).
|
|
364
|
+
await this.retireOptions(channel, message.ts, message.blocks);
|
|
365
|
+
// A bot cannot post as the user, so the pick is echoed and marked as one.
|
|
366
|
+
// Without it the thread shows an answer to a request nobody can see being
|
|
367
|
+
// made, and there is no message of the user's to carry the eyes.
|
|
368
|
+
const echo = await this.api.postMessage({
|
|
369
|
+
channel,
|
|
370
|
+
thread_ts: threadTs,
|
|
371
|
+
text: `\u25b8 ${escapeMrkdwn(text)}`,
|
|
372
|
+
}).catch((err) => {
|
|
373
|
+
this.log(`option echo failed: ${String(err)}`);
|
|
374
|
+
return undefined;
|
|
375
|
+
});
|
|
376
|
+
// The receipt goes on the echo, not on the bot message that held the
|
|
377
|
+
// buttons: the eyes mean "this input is being worked on".
|
|
378
|
+
if (echo?.ts)
|
|
379
|
+
this.receipts.mark(key.conversationId, channel, echo.ts);
|
|
380
|
+
onMessage({
|
|
381
|
+
key,
|
|
382
|
+
senderId: user,
|
|
383
|
+
sender: { id: user, name: await this.directory.user(this.api, user) },
|
|
384
|
+
text,
|
|
385
|
+
mode: "steer",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Drop the actions row, keeping the reply itself exactly as it was. A turn
|
|
390
|
+
* that was *nothing but* its options leaves nothing to keep, and Slack will
|
|
391
|
+
* not accept a message with neither text nor blocks โ so it becomes a muted
|
|
392
|
+
* line rather than staying clickable forever.
|
|
393
|
+
*/
|
|
394
|
+
async retireOptions(channel, ts, blocks) {
|
|
395
|
+
const kept = (blocks ?? []).filter((b) => b.type !== "actions");
|
|
396
|
+
const fallback = kept.find((b) => b.type === "section")?.text.text;
|
|
397
|
+
await this.api.setBlocks(channel, ts, fallback ?? "Option taken.", kept.length ? kept : [context("_Option taken._")]).catch((err) => this.log(`retiring options failed: ${String(err)}`));
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Stop the turn this conversation is running. The abort makes Pi end the
|
|
401
|
+
* turn, which reaches send() through the normal turn-end path and clears the
|
|
402
|
+
* ๐ receipts โ so nothing here touches them.
|
|
403
|
+
*/
|
|
404
|
+
async abortTurn(key, channel, threadTs) {
|
|
405
|
+
await this.deps.control?.abort(key);
|
|
406
|
+
await this.api.postMessage({ channel, thread_ts: threadTs, text: "โน Stopped." });
|
|
407
|
+
}
|
|
408
|
+
// --- bind ------------------------------------------------------------------
|
|
409
|
+
/**
|
|
410
|
+
* Tell an unbound DM sender what to do. Channels stay silent (see gate()),
|
|
411
|
+
* but a DM that swallows every message looks broken rather than locked.
|
|
412
|
+
*/
|
|
413
|
+
async hintBind(channel, userId, threadTs) {
|
|
414
|
+
if (!this.gate.mayHint(userId))
|
|
415
|
+
return;
|
|
416
|
+
await this.api.postMessage({
|
|
417
|
+
channel,
|
|
418
|
+
thread_ts: threadTs,
|
|
419
|
+
text: "You are not bound yet. Ask the operator for a bind code, then send `bind <code>`.",
|
|
420
|
+
}).catch((err) => this.log(`bind hint failed: ${String(err)}`));
|
|
421
|
+
}
|
|
422
|
+
async bind(channel, userId, threadTs, code) {
|
|
423
|
+
const name = await this.directory.user(this.api, userId);
|
|
424
|
+
const ok = this.deps.store.redeemBindCode("slack", code, { id: userId, name });
|
|
425
|
+
await this.api.postMessage({
|
|
426
|
+
channel,
|
|
427
|
+
thread_ts: threadTs,
|
|
428
|
+
text: ok ? `Bound as ${escapeMrkdwn(name)}.` : "That bind code is invalid or expired.",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
// --- addressing ------------------------------------------------------------
|
|
432
|
+
/**
|
|
433
|
+
* Mentioned, or continuing a thread Pier already owns โ Slack's equivalent
|
|
434
|
+
* of Telegram's "replying to the bot". The thread check is what lets a
|
|
435
|
+
* conversation flow without an `@` on every line, and it is durable so it
|
|
436
|
+
* still holds after a restart.
|
|
437
|
+
*/
|
|
438
|
+
addressed(raw, event, key) {
|
|
439
|
+
if (this.me && raw.includes(`<@${this.me}>`))
|
|
440
|
+
return true;
|
|
441
|
+
return !!event.thread_ts && !!this.deps.control?.knows(key);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* A leading `<@BOT>` is addressing, not content โ the agent should not see
|
|
445
|
+
* it. Slack does not strip it for us, and puts it wherever the user typed it.
|
|
446
|
+
*/
|
|
447
|
+
stripMention(text) {
|
|
448
|
+
if (!this.mention)
|
|
449
|
+
return text;
|
|
450
|
+
return text.replace(this.mention.leading, "").replace(this.mention.any, "").trim();
|
|
451
|
+
}
|
|
452
|
+
// --- lookups ---------------------------------------------------------------
|
|
453
|
+
/** A readable label for the Console's channel list; the id is the fallback. */
|
|
454
|
+
async nameOf(channel, event) {
|
|
455
|
+
const { kind, name } = await this.directory.channel(this.api, channel, event);
|
|
456
|
+
if (kind === "dm") {
|
|
457
|
+
return event.user ? `DM ยท ${await this.directory.user(this.api, event.user)}` : channel;
|
|
458
|
+
}
|
|
459
|
+
return name ?? channel;
|
|
460
|
+
}
|
|
461
|
+
async images(files) {
|
|
462
|
+
const out = [];
|
|
463
|
+
for (const file of files) {
|
|
464
|
+
try {
|
|
465
|
+
out.push(await this.api.downloadFile(file));
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
this.log(`file download failed: ${String(err)}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
// --- outbound --------------------------------------------------------------
|
|
474
|
+
/**
|
|
475
|
+
* Called on every turn-end, empty text included: the turn settled with
|
|
476
|
+
* nothing to say, and the ๐ receipts still have to come off.
|
|
477
|
+
*/
|
|
478
|
+
async send(conversation, reply) {
|
|
479
|
+
const { channel, threadTs } = parseConversation(conversation);
|
|
480
|
+
// Every id this adapter mints carries a thread, so an empty one is a
|
|
481
|
+
// corrupted or foreign conversation id. Posting it would put an agent turn
|
|
482
|
+
// in the channel's main flow โ the one thing this adapter promises never to
|
|
483
|
+
// do โ so it is refused loudly instead, and the receipts still come off so
|
|
484
|
+
// no ๐ is stranded.
|
|
485
|
+
if (!threadTs) {
|
|
486
|
+
this.log(`refusing to answer ${conversation}: no thread in the conversation id`);
|
|
487
|
+
await this.receipts.settle(conversation);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
await this.out.reply(channel, threadTs, reply);
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
// Always: the turn ended either way, and a ๐ left on a user's message
|
|
495
|
+
// because the reply failed to send would sit there until the stale sweep
|
|
496
|
+
// half an hour later, looking like the agent is still working.
|
|
497
|
+
await this.receipts.settle(conversation);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/** A system note, posted without touching the receipts: the turn it triggers
|
|
501
|
+
* has not ended yet. */
|
|
502
|
+
async notify(conversation, note) {
|
|
503
|
+
const { channel, threadTs } = parseConversation(conversation);
|
|
504
|
+
if (!threadTs) {
|
|
505
|
+
this.log(`refusing to post a system note to ${conversation}: no thread in the conversation id`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
await this.out.note(channel, threadTs, note);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Thin Telegram Bot API client: HTTP and payload shapes only, no policy.
|
|
2
|
+
// The one file in channels/ that talks to api.telegram.org, so the adapter
|
|
3
|
+
// stays testable against this interface. Long polling, not webhooks โ Pier is
|
|
4
|
+
// one local process with no inbound HTTP requirement.
|
|
5
|
+
//
|
|
6
|
+
// Behind a proxy, run node with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY set;
|
|
7
|
+
// nothing here needs to know.
|
|
8
|
+
const BASE = "https://api.telegram.org";
|
|
9
|
+
export class TelegramApi {
|
|
10
|
+
token;
|
|
11
|
+
constructor(token) {
|
|
12
|
+
this.token = token;
|
|
13
|
+
}
|
|
14
|
+
async call(method, payload, timeoutMs = 30_000, retry = true) {
|
|
15
|
+
const res = await fetch(`${BASE}/bot${this.token}/${method}`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { "content-type": "application/json" },
|
|
18
|
+
body: JSON.stringify(payload),
|
|
19
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
20
|
+
});
|
|
21
|
+
const body = (await res.json());
|
|
22
|
+
if (body.ok)
|
|
23
|
+
return body.result;
|
|
24
|
+
// Telegram answers a flood (a long turn split into chunks hits ~1 msg/s per
|
|
25
|
+
// chat) with the exact wait. Obeying it once turns a dropped reply into a
|
|
26
|
+
// late one; a second 429 is a real problem and surfaces as an error.
|
|
27
|
+
const after = body.parameters?.retry_after;
|
|
28
|
+
if (retry && after !== undefined && after <= 60) {
|
|
29
|
+
await new Promise((r) => setTimeout(r, (after + 1) * 1000));
|
|
30
|
+
return this.call(method, payload, timeoutMs, false);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`telegram ${method}: ${body.description ?? res.status}`);
|
|
33
|
+
}
|
|
34
|
+
getMe() {
|
|
35
|
+
return this.call("getMe", {});
|
|
36
|
+
}
|
|
37
|
+
getUpdates(offset, timeoutSeconds) {
|
|
38
|
+
return this.call("getUpdates", { offset, timeout: timeoutSeconds, allowed_updates: ["message", "callback_query"] }, (timeoutSeconds + 15) * 1000);
|
|
39
|
+
}
|
|
40
|
+
sendMessage(payload) {
|
|
41
|
+
return this.call("sendMessage", payload);
|
|
42
|
+
}
|
|
43
|
+
async editMessage(payload) {
|
|
44
|
+
await this.call("editMessageText", payload);
|
|
45
|
+
}
|
|
46
|
+
async deleteMessage(chatId, messageId) {
|
|
47
|
+
await this.call("deleteMessage", { chat_id: chatId, message_id: messageId });
|
|
48
|
+
}
|
|
49
|
+
async clearKeyboard(chatId, messageId) {
|
|
50
|
+
await this.call("editMessageReplyMarkup", { chat_id: chatId, message_id: messageId });
|
|
51
|
+
}
|
|
52
|
+
async setReaction(chatId, messageId, emoji) {
|
|
53
|
+
await this.call("setMessageReaction", {
|
|
54
|
+
chat_id: chatId,
|
|
55
|
+
message_id: messageId,
|
|
56
|
+
reaction: emoji ? [{ type: "emoji", emoji }] : [],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
createForumTopic(chatId, name) {
|
|
60
|
+
return this.call("createForumTopic", { chat_id: chatId, name });
|
|
61
|
+
}
|
|
62
|
+
async answerCallbackQuery(id, text) {
|
|
63
|
+
await this.call("answerCallbackQuery", { callback_query_id: id, text });
|
|
64
|
+
}
|
|
65
|
+
async downloadPhoto(fileId) {
|
|
66
|
+
const file = await this.call("getFile", { file_id: fileId });
|
|
67
|
+
if (!file.file_path)
|
|
68
|
+
throw new Error("telegram getFile: no file_path");
|
|
69
|
+
const res = await fetch(`${BASE}/file/bot${this.token}/${file.file_path}`, {
|
|
70
|
+
signal: AbortSignal.timeout(60_000),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
throw new Error(`telegram file download: ${res.status}`);
|
|
74
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
75
|
+
const mimeType = file.file_path.endsWith(".png") ? "image/png" : "image/jpeg";
|
|
76
|
+
return { data: buf.toString("base64"), mimeType };
|
|
77
|
+
}
|
|
78
|
+
}
|