@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,104 @@
|
|
|
1
|
+
// Reaction receipts: the whole lifecycle, storage included.
|
|
2
|
+
//
|
|
3
|
+
// A 👀 goes on an inbound message and comes off when its turn settles. Both
|
|
4
|
+
// halves live in Telegram, not in Pier, so anything ending the process between
|
|
5
|
+
// them leaves the emoji on a user's message with nobody left to clear it —
|
|
6
|
+
// and so does a message whose session never started a turn at all. Making the
|
|
7
|
+
// pending set durable is what closes the loop: an adapter clears every receipt
|
|
8
|
+
// it finds at startup (nothing in memory can be its own yet), and sweeps its
|
|
9
|
+
// own stragglers on a timer.
|
|
10
|
+
import { pierDb } from "../db.js";
|
|
11
|
+
const toReceipt = (row) => ({
|
|
12
|
+
conversationId: row.conversation_id,
|
|
13
|
+
chatId: row.chat_id,
|
|
14
|
+
// SQLite hands back whatever affinity it stored; the column is TEXT, but
|
|
15
|
+
// coercing keeps a numeric-looking id from arriving as a number.
|
|
16
|
+
messageId: String(row.message_id),
|
|
17
|
+
});
|
|
18
|
+
export class ReceiptLedger {
|
|
19
|
+
platform;
|
|
20
|
+
db;
|
|
21
|
+
constructor(platform, db = pierDb()) {
|
|
22
|
+
this.platform = platform;
|
|
23
|
+
this.db = db;
|
|
24
|
+
}
|
|
25
|
+
/** Re-marking the same message replaces the row rather than duplicating it. */
|
|
26
|
+
add(receipt) {
|
|
27
|
+
this.db.prepare(`
|
|
28
|
+
INSERT INTO receipts(platform, conversation_id, chat_id, message_id, created_at)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?)
|
|
30
|
+
ON CONFLICT(platform, chat_id, message_id) DO UPDATE SET
|
|
31
|
+
conversation_id = excluded.conversation_id, created_at = excluded.created_at
|
|
32
|
+
`).run(this.platform, receipt.conversationId, receipt.chatId, receipt.messageId, Date.now());
|
|
33
|
+
}
|
|
34
|
+
/** Claim a conversation's receipts: returned once, then gone. */
|
|
35
|
+
take(conversationId) {
|
|
36
|
+
const rows = this.db.prepare(`
|
|
37
|
+
SELECT conversation_id, chat_id, message_id FROM receipts
|
|
38
|
+
WHERE platform = ? AND conversation_id = ?
|
|
39
|
+
`).all(this.platform, conversationId);
|
|
40
|
+
this.db.prepare("DELETE FROM receipts WHERE platform = ? AND conversation_id = ?")
|
|
41
|
+
.run(this.platform, conversationId);
|
|
42
|
+
return rows.map(toReceipt);
|
|
43
|
+
}
|
|
44
|
+
/** Claim receipts older than `ageMs`; `0` claims everything (startup sweep). */
|
|
45
|
+
takeStale(ageMs, now = Date.now()) {
|
|
46
|
+
const cutoff = now - ageMs;
|
|
47
|
+
const rows = this.db.prepare(`
|
|
48
|
+
SELECT conversation_id, chat_id, message_id FROM receipts
|
|
49
|
+
WHERE platform = ? AND created_at <= ?
|
|
50
|
+
`).all(this.platform, cutoff);
|
|
51
|
+
this.db.prepare("DELETE FROM receipts WHERE platform = ? AND created_at <= ?")
|
|
52
|
+
.run(this.platform, cutoff);
|
|
53
|
+
return rows.map(toReceipt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Marks messages as being worked on and unmarks them when their turn settles.
|
|
58
|
+
* Lives next to the ledger because the ordering rule spans both: a receipt is
|
|
59
|
+
* booked synchronously (so an instant turn cannot clear an unbooked one) while
|
|
60
|
+
* the platform call is in flight, and the clear must wait for that call to land
|
|
61
|
+
* or the reaction stays up forever.
|
|
62
|
+
*/
|
|
63
|
+
export class Receipts {
|
|
64
|
+
api;
|
|
65
|
+
ledger;
|
|
66
|
+
log;
|
|
67
|
+
emoji;
|
|
68
|
+
staleMs;
|
|
69
|
+
/** In-flight `setReaction` per marked message. Only this process's own. */
|
|
70
|
+
applying = new Map();
|
|
71
|
+
constructor(api, ledger, log, emoji,
|
|
72
|
+
/** After this, a receipt's turn is assumed never to settle. */
|
|
73
|
+
staleMs) {
|
|
74
|
+
this.api = api;
|
|
75
|
+
this.ledger = ledger;
|
|
76
|
+
this.log = log;
|
|
77
|
+
this.emoji = emoji;
|
|
78
|
+
this.staleMs = staleMs;
|
|
79
|
+
}
|
|
80
|
+
mark(conversationId, chatId, messageId) {
|
|
81
|
+
this.applying.set(`${chatId}:${messageId}`, this.api.setReaction(chatId, messageId, this.emoji)
|
|
82
|
+
.catch((err) => this.log(`reaction failed: ${String(err)}`)));
|
|
83
|
+
this.ledger.add({ conversationId, chatId, messageId });
|
|
84
|
+
}
|
|
85
|
+
/** The turn this conversation was running has ended. */
|
|
86
|
+
settle(conversationId) {
|
|
87
|
+
return this.clear(this.ledger.take(conversationId));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Everything on the books at startup is orphaned — nothing in memory can be
|
|
91
|
+
* ours yet — and past `staleMs` a receipt's turn is never going to settle.
|
|
92
|
+
*/
|
|
93
|
+
sweep(all = false) {
|
|
94
|
+
return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs));
|
|
95
|
+
}
|
|
96
|
+
async clear(receipts) {
|
|
97
|
+
for (const { chatId, messageId } of receipts) {
|
|
98
|
+
const key = `${chatId}:${messageId}`;
|
|
99
|
+
await this.applying.get(key);
|
|
100
|
+
this.applying.delete(key);
|
|
101
|
+
await this.api.setReaction(chatId, messageId, null).catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Console → Channels HTTP surface. One document per platform: credentials,
|
|
2
|
+
// global defaults, bound users and discovered chats travel together, so the
|
|
3
|
+
// UI never has to stitch two half-configs.
|
|
4
|
+
import { isThinkingLevel } from "../core/types.js";
|
|
5
|
+
import { defaultChannelConfig, isChannelPlatform, } from "./types.js";
|
|
6
|
+
/** Stable mask: recomputable at write time, so "unchanged" is detectable. */
|
|
7
|
+
const maskToken = (token) => token ? `${"•".repeat(8)}${token.slice(-4)}` : "";
|
|
8
|
+
const asBool = (v) => v === true;
|
|
9
|
+
const asString = (v) => (typeof v === "string" ? v.trim() : "");
|
|
10
|
+
const asThinking = (v) => (isThinkingLevel(v) ? v : null);
|
|
11
|
+
/** A model is a provider/id pair or nothing; a half-filled one is nothing. */
|
|
12
|
+
function asModel(v) {
|
|
13
|
+
const ref = v;
|
|
14
|
+
if (!ref || typeof ref !== "object")
|
|
15
|
+
return null;
|
|
16
|
+
const provider = asString(ref.provider);
|
|
17
|
+
const id = asString(ref.id);
|
|
18
|
+
return provider && id ? { provider, id } : null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Apply the client's edits on top of what the store knows. Iterating the
|
|
22
|
+
* stored list, not the payload, is what makes the save non-destructive: chats
|
|
23
|
+
* are discovered, so one that appeared while the operator had the page open
|
|
24
|
+
* must survive their save instead of being deleted by a stale client list.
|
|
25
|
+
*/
|
|
26
|
+
function parseChats(raw, known) {
|
|
27
|
+
if (!Array.isArray(raw))
|
|
28
|
+
return known;
|
|
29
|
+
const edits = new Map();
|
|
30
|
+
for (const item of raw) {
|
|
31
|
+
const id = asString(item?.id);
|
|
32
|
+
if (id)
|
|
33
|
+
edits.set(id, item);
|
|
34
|
+
}
|
|
35
|
+
return known.map((base) => {
|
|
36
|
+
const edit = edits.get(base.id);
|
|
37
|
+
if (!edit)
|
|
38
|
+
return base;
|
|
39
|
+
return {
|
|
40
|
+
...base,
|
|
41
|
+
enabled: asBool(edit.enabled),
|
|
42
|
+
requireMention: asBool(edit.requireMention),
|
|
43
|
+
requireBind: asBool(edit.requireBind),
|
|
44
|
+
topicMode: asBool(edit.topicMode),
|
|
45
|
+
cwd: asString(edit.cwd),
|
|
46
|
+
model: asModel(edit.model),
|
|
47
|
+
thinking: asThinking(edit.thinking),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function registerChannelRoutes(app, store, runtime) {
|
|
52
|
+
app.get("/api/channels/:platform", (c) => {
|
|
53
|
+
const platform = c.req.param("platform");
|
|
54
|
+
if (!isChannelPlatform(platform))
|
|
55
|
+
return c.json({ error: "unknown platform" }, 404);
|
|
56
|
+
const config = store.get(platform);
|
|
57
|
+
// Never hand a token back: the client only needs to know one is set.
|
|
58
|
+
return c.json({
|
|
59
|
+
...config,
|
|
60
|
+
token: maskToken(config.token),
|
|
61
|
+
appToken: maskToken(config.appToken),
|
|
62
|
+
supported: platform === "telegram" || platform === "slack",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
app.put("/api/channels/:platform", async (c) => {
|
|
66
|
+
const platform = c.req.param("platform");
|
|
67
|
+
if (!isChannelPlatform(platform))
|
|
68
|
+
return c.json({ error: "unknown platform" }, 404);
|
|
69
|
+
const body = (await c.req.json().catch(() => null));
|
|
70
|
+
if (!body || typeof body !== "object")
|
|
71
|
+
return c.json({ error: "invalid body" }, 400);
|
|
72
|
+
const current = store.get(platform);
|
|
73
|
+
// A token that comes back masked is the stored one, untouched.
|
|
74
|
+
const kept = (incoming, stored) => !incoming || incoming === maskToken(stored) ? stored : incoming;
|
|
75
|
+
const next = {
|
|
76
|
+
...defaultChannelConfig(),
|
|
77
|
+
enabled: asBool(body.enabled),
|
|
78
|
+
token: kept(asString(body.token), current.token),
|
|
79
|
+
appToken: kept(asString(body.appToken), current.appToken),
|
|
80
|
+
// Absent means "on": a client that predates the field must not silently
|
|
81
|
+
// switch off a capability the operator never touched.
|
|
82
|
+
agentTool: body.agentTool === undefined ? current.agentTool : asBool(body.agentTool),
|
|
83
|
+
requireMention: asBool(body.requireMention),
|
|
84
|
+
requireBind: asBool(body.requireBind),
|
|
85
|
+
topicMode: asBool(body.topicMode),
|
|
86
|
+
cwd: asString(body.cwd),
|
|
87
|
+
model: asModel(body.model),
|
|
88
|
+
thinking: asThinking(body.thinking),
|
|
89
|
+
users: current.users,
|
|
90
|
+
chats: parseChats(body.chats, current.chats),
|
|
91
|
+
bindCode: current.bindCode,
|
|
92
|
+
};
|
|
93
|
+
store.save(platform, next);
|
|
94
|
+
await runtime.reload();
|
|
95
|
+
return c.json({ ok: true });
|
|
96
|
+
});
|
|
97
|
+
app.post("/api/channels/:platform/bind-code", (c) => {
|
|
98
|
+
const platform = c.req.param("platform");
|
|
99
|
+
if (!isChannelPlatform(platform))
|
|
100
|
+
return c.json({ error: "unknown platform" }, 404);
|
|
101
|
+
return c.json(store.issueBindCode(platform));
|
|
102
|
+
});
|
|
103
|
+
app.delete("/api/channels/:platform/users/:id", (c) => {
|
|
104
|
+
const platform = c.req.param("platform");
|
|
105
|
+
if (!isChannelPlatform(platform))
|
|
106
|
+
return c.json({ error: "unknown platform" }, 404);
|
|
107
|
+
store.unbind(platform, c.req.param("id"));
|
|
108
|
+
return c.json({ ok: true });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Channel lifecycle: which adapters are running, and where their sessions
|
|
2
|
+
// live. Keeps main.ts wiring-only and gives the Console one call to apply a
|
|
3
|
+
// config change. Lark is configurable but has no adapter yet.
|
|
4
|
+
import { logger } from "../log.js";
|
|
5
|
+
import { SlackChannel } from "./slack.js";
|
|
6
|
+
import { TelegramChannel } from "./telegram.js";
|
|
7
|
+
/** Platforms with an adapter, and what each needs before it can start. */
|
|
8
|
+
const ADAPTERS = [
|
|
9
|
+
{ platform: "telegram", needsAppToken: false, build: (deps) => new TelegramChannel(deps) },
|
|
10
|
+
{ platform: "slack", needsAppToken: true, build: (deps) => new SlackChannel(deps) },
|
|
11
|
+
];
|
|
12
|
+
// Lifecycle news, which is not what the injected sink below is for: that one
|
|
13
|
+
// is a warning sink the adapters share, and "slack started" is not a warning.
|
|
14
|
+
const log = logger("channels");
|
|
15
|
+
export class ChannelRuntime {
|
|
16
|
+
store;
|
|
17
|
+
router;
|
|
18
|
+
control;
|
|
19
|
+
log;
|
|
20
|
+
live = new Map();
|
|
21
|
+
constructor(store, router, control, log = (m) => logger("channels").warn(m)) {
|
|
22
|
+
this.store = store;
|
|
23
|
+
this.router = router;
|
|
24
|
+
this.control = control;
|
|
25
|
+
this.log = log;
|
|
26
|
+
}
|
|
27
|
+
/** (Re)start every platform whose config says it should run. Idempotent. */
|
|
28
|
+
async reload() {
|
|
29
|
+
for (const adapter of ADAPTERS)
|
|
30
|
+
await this.restart(adapter);
|
|
31
|
+
}
|
|
32
|
+
async restart(adapter) {
|
|
33
|
+
const { platform, needsAppToken, build } = adapter;
|
|
34
|
+
const existing = this.live.get(platform);
|
|
35
|
+
if (existing) {
|
|
36
|
+
this.live.delete(platform);
|
|
37
|
+
// Never fatal — the config still has to be applied — but a socket that
|
|
38
|
+
// refuses to close is exactly what makes the next start behave oddly.
|
|
39
|
+
await existing.stop().catch((err) => this.log(`${platform} did not stop cleanly: ${String(err)}`));
|
|
40
|
+
}
|
|
41
|
+
const config = this.store.get(platform);
|
|
42
|
+
if (!config.enabled || !config.token)
|
|
43
|
+
return;
|
|
44
|
+
if (needsAppToken && !config.appToken) {
|
|
45
|
+
// Named, not silent: "enabled but nothing happens" is otherwise
|
|
46
|
+
// indistinguishable from a broken adapter.
|
|
47
|
+
this.log(`${platform}: enabled but no app token, not starting`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const channel = build({
|
|
51
|
+
store: this.store,
|
|
52
|
+
log: (m) => this.log(`${platform}: ${m}`),
|
|
53
|
+
// The runtime owns the router, so channel control (stop, the settings
|
|
54
|
+
// panel) is wired here instead of widening the Channel seam.
|
|
55
|
+
control: this.control,
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
await channel.start((msg) => {
|
|
59
|
+
void this.router.dispatch(msg).catch((err) => this.log(`dispatch failed: ${String(err)}`));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
this.log(`${platform} failed to start: ${String(err)}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.router.registerChannel(channel);
|
|
67
|
+
this.live.set(platform, channel);
|
|
68
|
+
log.info(`${platform} started`);
|
|
69
|
+
}
|
|
70
|
+
async stop() {
|
|
71
|
+
for (const channel of this.live.values()) {
|
|
72
|
+
await channel.stop().catch((err) => this.log(`${channel.id} did not stop cleanly: ${String(err)}`));
|
|
73
|
+
}
|
|
74
|
+
this.live.clear();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Thin Slack client: HTTP shapes and the Socket Mode transport, no policy.
|
|
2
|
+
// The one file in channels/ that talks to slack.com, so the adapter stays
|
|
3
|
+
// testable against `SlackClient`.
|
|
4
|
+
//
|
|
5
|
+
// Socket Mode, not the Events API over HTTP: Pier is one local process and must
|
|
6
|
+
// not require a public inbound URL. It needs two credentials — an app-level
|
|
7
|
+
// token (`xapp-`) opens the socket, the bot token (`xoxb-`) signs every Web API
|
|
8
|
+
// call — which is why ChannelConfig carries `appToken` beside `token`.
|
|
9
|
+
//
|
|
10
|
+
// No SDK: `apps.connections.open` plus Node's built-in WebSocket is the whole
|
|
11
|
+
// protocol, and @slack/socket-mode would pull a dependency tree to wrap it.
|
|
12
|
+
const BASE = "https://slack.com/api";
|
|
13
|
+
/**
|
|
14
|
+
* Did Slack refuse the payload because of the *block* itself? The `markdown`
|
|
15
|
+
* block is recent, so a workspace that predates it answers with one of these —
|
|
16
|
+
* the signal to re-render the turn as legacy mrkdwn rather than to lose it.
|
|
17
|
+
*
|
|
18
|
+
* Slack has no capability API to ask up front, so the only detection is a
|
|
19
|
+
* failed send. That makes the test's *narrowness* the whole safety property:
|
|
20
|
+
* the caller latches the answer for the process, so anything matched here
|
|
21
|
+
* degrades every later message too. `invalid_arguments` is deliberately NOT
|
|
22
|
+
* matched even though avibe lists it — avibe retries per message, where a
|
|
23
|
+
* broad match costs one fallback; latching turns the same breadth into a
|
|
24
|
+
* permanent downgrade triggered by an unrelated bad argument (a malformed
|
|
25
|
+
* `thread_ts` would silently cost the whole process its rendering). A wrong
|
|
26
|
+
* call should surface as an error, not as a quieter renderer.
|
|
27
|
+
*/
|
|
28
|
+
export const isBlockRejection = (err) => /invalid_blocks|unsupported_block_type/.test(String(err));
|
|
29
|
+
/**
|
|
30
|
+
* A connection that dies younger than this was a failed attempt, however it
|
|
31
|
+
* ended: Slack answers "too many connections" by accepting the socket and
|
|
32
|
+
* closing it straight away, which is not an error the loop would otherwise see.
|
|
33
|
+
*/
|
|
34
|
+
const MIN_CONNECTION_MS = 5000;
|
|
35
|
+
const RECONNECT_FLOOR_MS = 1000;
|
|
36
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
37
|
+
export class SlackApi {
|
|
38
|
+
token;
|
|
39
|
+
appToken;
|
|
40
|
+
log;
|
|
41
|
+
openSocket;
|
|
42
|
+
socketRunning = false;
|
|
43
|
+
constructor(token, appToken, log = () => { },
|
|
44
|
+
/** Injected in tests; production opens a real WebSocket. */
|
|
45
|
+
openSocket = (url) => new WebSocket(url)) {
|
|
46
|
+
this.token = token;
|
|
47
|
+
this.appToken = appToken;
|
|
48
|
+
this.log = log;
|
|
49
|
+
this.openSocket = openSocket;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Slack accepts a JSON body only on *write* methods. A read method
|
|
53
|
+
* (`users.info`, `conversations.info|history|replies`) silently ignores it and
|
|
54
|
+
* then reports the missing parameter — `users.info` answers `user_not_found`,
|
|
55
|
+
* which reads like "no such person" rather than "you sent the id in a place I
|
|
56
|
+
* do not look". So reads go form-encoded. This was worth four broken calls.
|
|
57
|
+
*/
|
|
58
|
+
async read(method, params) {
|
|
59
|
+
const form = new URLSearchParams();
|
|
60
|
+
for (const [key, value] of Object.entries(params)) {
|
|
61
|
+
if (value !== undefined)
|
|
62
|
+
form.set(key, String(value));
|
|
63
|
+
}
|
|
64
|
+
return this.call(method, form);
|
|
65
|
+
}
|
|
66
|
+
async call(method, payload, token = this.token, retry = true) {
|
|
67
|
+
const form = payload instanceof URLSearchParams;
|
|
68
|
+
const res = await fetch(`${BASE}/${method}`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
"content-type": form
|
|
72
|
+
? "application/x-www-form-urlencoded; charset=utf-8"
|
|
73
|
+
: "application/json; charset=utf-8",
|
|
74
|
+
authorization: `Bearer ${token}`,
|
|
75
|
+
},
|
|
76
|
+
body: form ? payload.toString() : JSON.stringify(payload),
|
|
77
|
+
signal: AbortSignal.timeout(30_000),
|
|
78
|
+
});
|
|
79
|
+
// Slack answers a flood (a long turn split into chunks hits ~1 msg/s per
|
|
80
|
+
// channel) with the exact wait in a header. Obeying it once turns a dropped
|
|
81
|
+
// reply into a late one; a second 429 is a real problem and throws.
|
|
82
|
+
if (res.status === 429 && retry) {
|
|
83
|
+
const after = Number(res.headers.get("retry-after") ?? "1");
|
|
84
|
+
if (Number.isFinite(after) && after <= 60) {
|
|
85
|
+
await new Promise((r) => setTimeout(r, (after + 1) * 1000));
|
|
86
|
+
return this.call(method, payload, token, false);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const body = (await res.json().catch(() => null));
|
|
90
|
+
if (!body)
|
|
91
|
+
throw new Error(`slack ${method}: ${res.status} with no JSON body`);
|
|
92
|
+
if (!body.ok)
|
|
93
|
+
throw new Error(`slack ${method}: ${body.error ?? res.status}`);
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
96
|
+
async authTest() {
|
|
97
|
+
const body = await this.call("auth.test", {});
|
|
98
|
+
return { userId: body.user_id ?? "" };
|
|
99
|
+
}
|
|
100
|
+
// --- Socket Mode -----------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* Reconnecting is part of the protocol, not the adapter's problem: Slack
|
|
103
|
+
* cycles a connection every few hours with `disconnect: refresh_requested`,
|
|
104
|
+
* so the loop reopens until `close()` clears the flag.
|
|
105
|
+
*/
|
|
106
|
+
async connect(onEnvelope) {
|
|
107
|
+
this.socketRunning = true;
|
|
108
|
+
let socket;
|
|
109
|
+
const run = async () => {
|
|
110
|
+
let backoff = RECONNECT_FLOOR_MS;
|
|
111
|
+
while (this.socketRunning) {
|
|
112
|
+
// Set once the socket exists, so a slow `apps.connections.open` cannot
|
|
113
|
+
// make a connection that died instantly look like a healthy one.
|
|
114
|
+
let connectedAt = 0;
|
|
115
|
+
try {
|
|
116
|
+
const open = await this.call("apps.connections.open", {}, this.appToken);
|
|
117
|
+
if (!open.url)
|
|
118
|
+
throw new Error("apps.connections.open returned no url");
|
|
119
|
+
// stop() may have landed while that call was in flight. Opening now
|
|
120
|
+
// would leave a live socket nobody holds a reference to.
|
|
121
|
+
if (!this.socketRunning)
|
|
122
|
+
return;
|
|
123
|
+
socket = this.openSocket(open.url);
|
|
124
|
+
connectedAt = Date.now();
|
|
125
|
+
// Resolves on close, never rejects: a dropped socket is normal and
|
|
126
|
+
// the loop's job is to reopen it, not to treat it as an error.
|
|
127
|
+
await new Promise((resolve) => {
|
|
128
|
+
const ws = socket;
|
|
129
|
+
ws.onmessage = (ev) => {
|
|
130
|
+
let env;
|
|
131
|
+
try {
|
|
132
|
+
env = JSON.parse(String(ev.data));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Validate at the boundary: log and drop, never half-handle.
|
|
136
|
+
this.log(`unparseable socket frame dropped`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Ack first and always. Handling happens after, because a turn
|
|
140
|
+
// outlives the deadline and Slack redelivers what it never saw
|
|
141
|
+
// acknowledged.
|
|
142
|
+
if (env.envelope_id) {
|
|
143
|
+
try {
|
|
144
|
+
ws.send(JSON.stringify({ envelope_id: env.envelope_id }));
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
this.log(`ack failed: ${String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (env.type === "hello")
|
|
151
|
+
return;
|
|
152
|
+
if (env.type === "disconnect") {
|
|
153
|
+
// Expected: Slack recycles connections. Closing resolves the
|
|
154
|
+
// promise below and the loop reopens.
|
|
155
|
+
this.log(`socket disconnect (${env.reason ?? "no reason"}), reconnecting`);
|
|
156
|
+
ws.close();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
onEnvelope(env);
|
|
160
|
+
};
|
|
161
|
+
ws.onerror = () => {
|
|
162
|
+
// `onclose` always follows, and carries the useful detail.
|
|
163
|
+
};
|
|
164
|
+
ws.onclose = () => resolve();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
this.log(`socket connect failed: ${String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
if (!this.socketRunning)
|
|
171
|
+
return;
|
|
172
|
+
// The anti-spin floor. A socket that lived a while was healthy, so the
|
|
173
|
+
// next attempt starts from the floor again; one that died young — or
|
|
174
|
+
// threw — backs off, because reopening instantly would hammer
|
|
175
|
+
// apps.connections.open in a tight loop.
|
|
176
|
+
if (connectedAt && Date.now() - connectedAt >= MIN_CONNECTION_MS) {
|
|
177
|
+
backoff = RECONNECT_FLOOR_MS;
|
|
178
|
+
}
|
|
179
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
180
|
+
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
void run();
|
|
184
|
+
return {
|
|
185
|
+
close: async () => {
|
|
186
|
+
this.socketRunning = false;
|
|
187
|
+
try {
|
|
188
|
+
socket?.close();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Already closing; nothing to recover.
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// --- messages --------------------------------------------------------------
|
|
197
|
+
async postMessage(payload) {
|
|
198
|
+
const body = await this.call("chat.postMessage", {
|
|
199
|
+
unfurl_links: false,
|
|
200
|
+
unfurl_media: false,
|
|
201
|
+
...payload,
|
|
202
|
+
});
|
|
203
|
+
return { ts: body.ts ?? "" };
|
|
204
|
+
}
|
|
205
|
+
async updateMessage(payload) {
|
|
206
|
+
// chat.update takes no thread_ts; sending it is an invalid_arguments error.
|
|
207
|
+
const { thread_ts: _thread, ...rest } = payload;
|
|
208
|
+
await this.call("chat.update", rest);
|
|
209
|
+
}
|
|
210
|
+
async deleteMessage(channel, ts) {
|
|
211
|
+
await this.call("chat.delete", { channel, ts });
|
|
212
|
+
}
|
|
213
|
+
async setBlocks(channel, ts, text, blocks) {
|
|
214
|
+
await this.call("chat.update", { channel, ts, text, blocks });
|
|
215
|
+
}
|
|
216
|
+
async addReaction(channel, ts, name) {
|
|
217
|
+
try {
|
|
218
|
+
await this.call("reactions.add", { channel, timestamp: ts, name });
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
// The reaction is already where we want it; that is a success.
|
|
222
|
+
if (!String(err).includes("already_reacted"))
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async removeReaction(channel, ts, name) {
|
|
227
|
+
try {
|
|
228
|
+
await this.call("reactions.remove", { channel, timestamp: ts, name });
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
if (!String(err).includes("no_reaction"))
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async openView(triggerId, view) {
|
|
236
|
+
await this.call("views.open", { trigger_id: triggerId, view });
|
|
237
|
+
}
|
|
238
|
+
async channelInfo(channel) {
|
|
239
|
+
const body = await this.read("conversations.info", { channel });
|
|
240
|
+
return {
|
|
241
|
+
name: body.channel?.name,
|
|
242
|
+
isIm: !!(body.channel?.is_im || body.channel?.is_mpim),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async userName(userId) {
|
|
246
|
+
const body = await this.read("users.info", { user: userId });
|
|
247
|
+
return body.user?.real_name || body.user?.name || userId;
|
|
248
|
+
}
|
|
249
|
+
async page(method, params) {
|
|
250
|
+
const body = await this.read(method, params);
|
|
251
|
+
// An empty cursor means "no more"; Slack sends `""` rather than omitting it.
|
|
252
|
+
const next = body.response_metadata?.next_cursor;
|
|
253
|
+
return { messages: body.messages ?? [], nextCursor: next || undefined };
|
|
254
|
+
}
|
|
255
|
+
history(channel, query) {
|
|
256
|
+
return this.page("conversations.history", {
|
|
257
|
+
channel,
|
|
258
|
+
oldest: query.oldest,
|
|
259
|
+
latest: query.latest,
|
|
260
|
+
limit: query.limit ?? 200,
|
|
261
|
+
cursor: query.cursor,
|
|
262
|
+
inclusive: true,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
replies(channel, ts, query) {
|
|
266
|
+
return this.page("conversations.replies", {
|
|
267
|
+
channel,
|
|
268
|
+
ts,
|
|
269
|
+
// Slack drops the boundary message unless asked; the caller wants it and
|
|
270
|
+
// filters for itself, the same as `history` above.
|
|
271
|
+
oldest: query.oldest,
|
|
272
|
+
inclusive: true,
|
|
273
|
+
limit: query.limit ?? 200,
|
|
274
|
+
cursor: query.cursor,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Slack file URLs are private: they need the bot token as a bearer header and
|
|
279
|
+
* answer HTML (a login page) rather than an error when it is missing.
|
|
280
|
+
*/
|
|
281
|
+
async downloadFile(file) {
|
|
282
|
+
const url = file.url_private_download ?? file.url_private;
|
|
283
|
+
if (!url)
|
|
284
|
+
throw new Error("slack file has no private url");
|
|
285
|
+
const res = await fetch(url, {
|
|
286
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
287
|
+
signal: AbortSignal.timeout(60_000),
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok)
|
|
290
|
+
throw new Error(`slack file download: ${res.status}`);
|
|
291
|
+
const mimeType = res.headers.get("content-type")?.split(";")[0] ?? file.mimetype ?? "image/png";
|
|
292
|
+
if (!mimeType.startsWith("image/"))
|
|
293
|
+
throw new Error(`slack file is ${mimeType}, not an image`);
|
|
294
|
+
return { data: Buffer.from(await res.arrayBuffer()).toString("base64"), mimeType };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Who and where, cached: user display names and channel kind/name.
|
|
2
|
+
//
|
|
3
|
+
// Every inbound message needs the sender's name, and every transcript the tool
|
|
4
|
+
// returns needs one per speaker — so without a cache this is one `users.info`
|
|
5
|
+
// per message and per read. Neither answer changes in practice, so the cache is
|
|
6
|
+
// process-lifetime and shared: the adapter and the agent-facing tool ask the
|
|
7
|
+
// same instance, which is why a repeated `read_thread` costs no lookups at all.
|
|
8
|
+
//
|
|
9
|
+
// Failures are logged and fall back to the id, never swallowed: without
|
|
10
|
+
// `users:read` this fails for every message, and the only symptom used to be an
|
|
11
|
+
// agent telling the human "Slack does not expose your display name" — a scope
|
|
12
|
+
// problem wearing a product problem's clothes.
|
|
13
|
+
export class SlackDirectory {
|
|
14
|
+
log;
|
|
15
|
+
channels = new Map();
|
|
16
|
+
users = new Map();
|
|
17
|
+
constructor(log) {
|
|
18
|
+
this.log = log;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Kind and display name together, because one `conversations.info` answers
|
|
22
|
+
* both. The event usually settles the kind for free (`channel_type`), and a
|
|
23
|
+
* `D`-prefixed id is always a DM — but only the lookup knows the name.
|
|
24
|
+
*/
|
|
25
|
+
async channel(api, channel, event) {
|
|
26
|
+
const cached = this.channels.get(channel);
|
|
27
|
+
if (cached)
|
|
28
|
+
return cached;
|
|
29
|
+
const fromEvent = event?.channel_type
|
|
30
|
+
? event.channel_type === "im" || event.channel_type === "mpim" ? "dm" : "group"
|
|
31
|
+
: channel.startsWith("D")
|
|
32
|
+
? "dm"
|
|
33
|
+
: undefined;
|
|
34
|
+
// A DM's name comes from its member, not from the channel, so a DM settled
|
|
35
|
+
// by the event needs no lookup at all.
|
|
36
|
+
if (fromEvent === "dm") {
|
|
37
|
+
const facts = { kind: fromEvent };
|
|
38
|
+
this.channels.set(channel, facts);
|
|
39
|
+
return facts;
|
|
40
|
+
}
|
|
41
|
+
const info = await api.channelInfo(channel).catch((err) => {
|
|
42
|
+
this.log(`conversations.info failed for ${channel}: ${String(err)}`);
|
|
43
|
+
return undefined;
|
|
44
|
+
});
|
|
45
|
+
// Uncached on failure, so the next message retries rather than pinning a
|
|
46
|
+
// guess for the life of the process.
|
|
47
|
+
if (!info)
|
|
48
|
+
return { kind: fromEvent ?? "group" };
|
|
49
|
+
const facts = {
|
|
50
|
+
kind: info.isIm ? "dm" : "group",
|
|
51
|
+
name: info.name ? `#${info.name}` : undefined,
|
|
52
|
+
};
|
|
53
|
+
this.channels.set(channel, facts);
|
|
54
|
+
return facts;
|
|
55
|
+
}
|
|
56
|
+
/** Display name for a user id; the id itself when Slack will not say. */
|
|
57
|
+
async user(api, userId) {
|
|
58
|
+
const hit = this.users.get(userId);
|
|
59
|
+
if (hit !== undefined)
|
|
60
|
+
return hit;
|
|
61
|
+
const name = await api.userName(userId).catch((err) => {
|
|
62
|
+
this.log(`users.info failed for ${userId} (is users:read granted?): ${String(err)}`);
|
|
63
|
+
return userId;
|
|
64
|
+
});
|
|
65
|
+
this.users.set(userId, name);
|
|
66
|
+
return name;
|
|
67
|
+
}
|
|
68
|
+
/** Names for every speaker in a transcript, in one pass. */
|
|
69
|
+
async names(api, userIds) {
|
|
70
|
+
const out = new Map();
|
|
71
|
+
for (const id of userIds) {
|
|
72
|
+
if (!out.has(id))
|
|
73
|
+
out.set(id, await this.user(api, id));
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
}
|