@pinet/slack-bridge 0.1.0 → 0.2.0
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 +86 -34
- package/dist/broker/adapters/slack.d.ts +19 -1
- package/dist/broker/adapters/slack.js +111 -22
- package/dist/broker/client.d.ts +2 -1
- package/dist/broker/client.js +1 -0
- package/dist/broker/socket-server.js +18 -0
- package/dist/deploy-manifest.d.ts +5 -0
- package/dist/deploy-manifest.js +30 -1
- package/dist/follower-runtime.js +5 -1
- package/dist/helpers.d.ts +14 -0
- package/dist/helpers.js +45 -21
- package/dist/index.js +60 -0
- package/dist/pinet-commands.d.ts +6 -1
- package/dist/pinet-commands.js +166 -1
- package/dist/pinet-mesh-ops.d.ts +11 -0
- package/dist/pinet-mesh-ops.js +17 -0
- package/dist/pinet-tools.d.ts +47 -0
- package/dist/pinet-tools.js +496 -36
- package/dist/prompts/broker/tmux.md +2 -2
- package/dist/reaction-triggers.d.ts +1 -0
- package/dist/reaction-triggers.js +26 -15
- package/dist/runtime-agent-context.js +19 -0
- package/dist/runtime-mode.js +7 -1
- package/dist/single-player-runtime.js +22 -26
- package/dist/slack-access.d.ts +11 -0
- package/dist/slack-access.js +30 -0
- package/dist/slack-agents-command.d.ts +19 -0
- package/dist/slack-agents-command.js +90 -0
- package/dist/slack-export.d.ts +1 -1
- package/dist/slack-export.js +6 -4
- package/dist/slack-file-access.d.ts +34 -0
- package/dist/slack-file-access.js +209 -0
- package/dist/slack-message-context.d.ts +0 -1
- package/dist/slack-message-context.js +1 -6
- package/dist/slack-pinet-runtime-adapter.d.ts +4 -2
- package/dist/slack-pinet-runtime-adapter.js +12 -0
- package/dist/slack-tools.d.ts +6 -0
- package/dist/slack-tools.js +290 -36
- package/dist/slack-upload.d.ts +13 -1
- package/dist/slack-upload.js +29 -2
- package/dist/stale-slack-messages.d.ts +12 -0
- package/dist/stale-slack-messages.js +29 -0
- package/dist/subtree-broker-runtime.d.ts +109 -0
- package/dist/subtree-broker-runtime.js +558 -0
- package/manifest.yaml +9 -0
- package/package.json +16 -9
- package/skills/slack-bridge/SKILL.md +60 -1
package/README.md
CHANGED
|
@@ -4,17 +4,42 @@ Slack assistant integration for [pi](https://github.com/badlogic/pi-mono) — mu
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
+
Install the latest Pinet Slack bridge pi package:
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
10
|
pi install npm:@pinet/slack-bridge
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
Or
|
|
13
|
+
Or pin an exact published version for reproducible installs:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:@pinet/slack-bridge@0.1.0
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For package managers or local inspection, the npm package is also installable directly:
|
|
12
20
|
|
|
13
21
|
```bash
|
|
14
22
|
npm install @pinet/slack-bridge
|
|
15
23
|
```
|
|
16
24
|
|
|
17
|
-
##
|
|
25
|
+
## Package metadata and publishing
|
|
26
|
+
|
|
27
|
+
This package declares pi package/gallery metadata in [`package.json`](./package.json):
|
|
28
|
+
|
|
29
|
+
- `keywords` includes `pi-package` for gallery discovery.
|
|
30
|
+
- `pi.extensions` points at the built extension entrypoint, `./dist/index.js`.
|
|
31
|
+
- `pi.skills` points at the bundled skill directory, `./skills`.
|
|
32
|
+
- No `pi.image` or `pi.video` preview is declared yet because this package does
|
|
33
|
+
not currently include a reviewed gallery image/video asset.
|
|
34
|
+
|
|
35
|
+
The published tarball is expected to include the package metadata, README,
|
|
36
|
+
Slack app manifest, built `dist/` files, bundled `skills/`, and LICENSE. Verify
|
|
37
|
+
that locally with:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cd slack-bridge
|
|
41
|
+
npm pack --dry-run
|
|
42
|
+
```
|
|
18
43
|
|
|
19
44
|
This package is included in the full npm publish set tracked in
|
|
20
45
|
[`../plans/npm-publish.md`](../plans/npm-publish.md). Use the GitHub Actions
|
|
@@ -35,9 +60,10 @@ bump versions without explicit maintainer release approval.
|
|
|
35
60
|
2. Choose **From a manifest**
|
|
36
61
|
3. Select your workspace
|
|
37
62
|
4. Paste the contents of [`manifest.yaml`](./manifest.yaml) from this directory
|
|
38
|
-
5.
|
|
63
|
+
5. If the Slack app is not named Pinet, change `features.slash_commands[0].command` before creating the app (for example, Oathgate uses `/oathgate` instead of the packaged `/pinet` default)
|
|
64
|
+
6. Click **Create**
|
|
39
65
|
|
|
40
|
-
The manifest configures Socket Mode, the assistant view, all required bot scopes,
|
|
66
|
+
The manifest configures Socket Mode, the assistant view, all required bot scopes, event subscriptions, and the packaged Pinet slash-command default automatically.
|
|
41
67
|
|
|
42
68
|
### 2. Generate tokens
|
|
43
69
|
|
|
@@ -56,13 +82,13 @@ These are included in the manifest, but for reference:
|
|
|
56
82
|
app_mentions:read assistant:write bookmarks:read
|
|
57
83
|
bookmarks:write canvases:read canvases:write
|
|
58
84
|
channels:history channels:read chat:write
|
|
59
|
-
files:read files:write groups:history
|
|
85
|
+
commands files:read files:write groups:history
|
|
60
86
|
groups:read im:history im:read
|
|
61
87
|
im:write pins:read pins:write
|
|
62
88
|
reactions:read reactions:write users:read
|
|
63
89
|
```
|
|
64
90
|
|
|
65
|
-
`files:read` is required because Slack exposes canvas comment pagination through `files.info`, even when the target is first validated via canvas-specific APIs.
|
|
91
|
+
`commands` is required for the Slack slash-command surface (`/<app> agents list [all]`). `files:read` is required because Slack exposes canvas comment pagination through `files.info`, even when the target is first validated via canvas-specific APIs.
|
|
66
92
|
|
|
67
93
|
Slack thread shimmer/status updates use `assistant.threads.setStatus`; Slack's 2026 scope update allows this method with the existing `chat:write` bot scope, so no new `assistant:write` scope is needed for status-only support.
|
|
68
94
|
|
|
@@ -173,6 +199,8 @@ Slack access is now **default-deny** unless you configure one of these explicitl
|
|
|
173
199
|
| `ralphSnoozeAfterEmptyCycles` | no | Broker RALPH auto-snooze trigger after N empty cycles; defaults to `0` (disabled), valid range `0`-`100` |
|
|
174
200
|
| `ralphSnoozeDurationMs` | no | Broker RALPH auto-snooze duration in milliseconds; defaults to `1800000` (30 minutes), valid range `60000`-`86400000` |
|
|
175
201
|
| `skinTheme` | no | Pinet presentation skin selected at broker startup/reload (`default`, `foundation`, `cosmere`, or free-form) |
|
|
202
|
+
| `slackCommandName` | no | Slack web app slash command name for `agents list`; defaults to `/pinet`, or `/oathgate` for Oathgate/Cosmere skins |
|
|
203
|
+
| `slackCommandNames` | no | Optional list of accepted/deployed Slack slash command aliases when one app needs multiple command names |
|
|
176
204
|
| `meshSecret` | no | Optional inline Pinet shared secret; overrides `meshSecretPath` and env fallbacks |
|
|
177
205
|
| `meshSecretPath` | no | Optional path to a shared-secret file; broker creates it if missing, followers require an existing file |
|
|
178
206
|
| `suggestedPrompts` | no | Prompts shown when a user opens a new conversation |
|
|
@@ -209,7 +237,7 @@ Messages queue while the agent is busy. When the agent finishes, it automaticall
|
|
|
209
237
|
|
|
210
238
|
### Reaction triggers
|
|
211
239
|
|
|
212
|
-
|
|
240
|
+
Slack emoji reactions are ignored by default: they do not enqueue Pinet work, trigger reviews, steer agents, interrupt owners, or cause broker/worker replies. To opt in deliberately, configure `reactionCommands` for the exact emoji aliases that should become structured Pinet requests from the reacted-to Slack message. Even configured reactions are accepted only inside an already authorized Pinet thread (for example a thread with a current Pinet owner, or persisted Slack assistant-thread context). Reaction authorization is deny-by-default: it requires an explicit broker-backed authorization gate, and a thread the adapter has merely seen or cached never qualifies on its own. Reactions in ordinary, uninvoked Slack channel threads remain no-op — even from authorized users — and they do not enqueue work, persist thread state, claim ownership, or receive a Slack ACK. Messages and interactive events from users outside the allowlist also never mint known-thread state that could later admit reactions or replies. Pinet adds ✅ only when it accepts an opt-in reaction-triggered request. If it cannot process an accepted opted-in reaction, it adds ❌; check broker logs for the underlying Slack/API error. When Slack cannot return the reacted message text, Pinet can still route configured reactions when the message timestamp itself identifies an already authorized thread; otherwise it ignores the reaction safely.
|
|
213
241
|
|
|
214
242
|
### Available tools
|
|
215
243
|
|
|
@@ -229,6 +257,7 @@ Cold Slack actions live behind the `slack` dispatcher:
|
|
|
229
257
|
| `react` | Add an emoji reaction to a message |
|
|
230
258
|
| `read` | Read messages from a thread |
|
|
231
259
|
| `upload` | Upload files, snippets, or diffs into Slack |
|
|
260
|
+
| `file` | Download Slack-hosted files to a controlled local temp cache by file ID |
|
|
232
261
|
| `schedule` | Schedule a message for later delivery |
|
|
233
262
|
| `post_channel` | Post to a channel (by name or ID) |
|
|
234
263
|
| `delete` | Delete a bot-posted message or an entire thread |
|
|
@@ -282,7 +311,21 @@ migration.
|
|
|
282
311
|
- **Uploads are for bulky artifacts.** Use `upload` for logs, screenshots,
|
|
283
312
|
long diffs, and generated files instead of large inline messages. Inline
|
|
284
313
|
uploads require `filename`; path uploads are guarded and must stay within the
|
|
285
|
-
current working directory or system temp directory.
|
|
314
|
+
current working directory or system temp directory. `slack_send` also accepts
|
|
315
|
+
`files: [{ path, filename?, title?, filetype? }]` so one assistant-thread
|
|
316
|
+
reply can contain both text and local binary attachments in the same Slack
|
|
317
|
+
file upload message. Slack external file uploads cannot include Block Kit in
|
|
318
|
+
that same message, so omit `blocks` when sending files or send a separate
|
|
319
|
+
block-only reply.
|
|
320
|
+
- **Inbound Slack files are fetched explicitly.** Incoming file-share messages
|
|
321
|
+
preserve safe `slackFiles` metadata such as file ID, name, type, size, and
|
|
322
|
+
permalink, but private Slack download URLs are not exposed in normal tool
|
|
323
|
+
output. To inspect raw content, call dispatcher action `file` with
|
|
324
|
+
`op: "download"`, `file_id`, and optionally `thread_ts`, `message_ts`, and
|
|
325
|
+
`channel`. The bot fetches the file with Slack bot auth, stores it under the
|
|
326
|
+
system temp `pi-slack-files` cache with best-effort TTL cleanup, and returns a
|
|
327
|
+
descriptor containing the local path, filename, type, size, SHA-256, expiry,
|
|
328
|
+
and residual privacy risks.
|
|
286
329
|
- **Upload host egress note.** The second upload leg goes to Slack file upload
|
|
287
330
|
hosts (`files.slack.com`/`uploads.slack.com`) for the raw payload. In
|
|
288
331
|
environments with restricted egress this can fail with `403` (proxy
|
|
@@ -317,10 +360,13 @@ migration.
|
|
|
317
360
|
...` string from the error, request confirmation, wait for the user's approval
|
|
318
361
|
via `slack_inbox`, then retry the guarded call unchanged. Batched
|
|
319
362
|
multi-thread Slack turns cannot satisfy a single-thread confirmation.
|
|
320
|
-
- **
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
363
|
+
- **Plain emoji reactions are not tasks.** Slack emoji reactions are ignored
|
|
364
|
+
unless `reactionCommands` explicitly opts that emoji into structured
|
|
365
|
+
reaction-trigger handling and the reacted message belongs to an already
|
|
366
|
+
authorized Pinet thread. If an opt-in reaction-triggered request or a Block
|
|
367
|
+
Kit/modal interaction payload arrives through `slack_inbox` with metadata,
|
|
368
|
+
treat it as a user instruction tied to the referenced Slack thread or
|
|
369
|
+
message.
|
|
324
370
|
|
|
325
371
|
#### Common dispatcher examples
|
|
326
372
|
|
|
@@ -393,12 +439,13 @@ The `canvas_comments_read` dispatcher action is intentionally narrow:
|
|
|
393
439
|
|
|
394
440
|
### Slash commands
|
|
395
441
|
|
|
396
|
-
| Command
|
|
397
|
-
|
|
|
398
|
-
| `/pinet <action>`
|
|
399
|
-
| `/pinet status`
|
|
400
|
-
| `/pinet rename`
|
|
401
|
-
| `/pinet logs`
|
|
442
|
+
| Command | Description |
|
|
443
|
+
| -------------------------- | ---------------------------------------------------------- |
|
|
444
|
+
| `/pinet <action>` | Unified Pinet command surface; run `/pinet help` for usage |
|
|
445
|
+
| `/pinet status` | Show connection status, threads, and agent identity |
|
|
446
|
+
| `/pinet rename` | Change the agent's display name |
|
|
447
|
+
| `/pinet logs` | Show recent broker activity log entries |
|
|
448
|
+
| `/<app> agents list [all]` | Slack-native broker roster, workload, task, and lane view |
|
|
402
449
|
|
|
403
450
|
## Runtime modes
|
|
404
451
|
|
|
@@ -469,13 +516,17 @@ Only broker prompt content is replaceable. Broker runtime/tool restrictions rema
|
|
|
469
516
|
|
|
470
517
|
### Multi-agent tools
|
|
471
518
|
|
|
472
|
-
| Tool | Description
|
|
473
|
-
| ------- |
|
|
474
|
-
| `pinet` | Pinet dispatcher with token-efficient `action`-based routing (`help`, `send`, `read`, `free`, `snooze`, `schedule`, `agents`, `lanes`, `ports`, `reload`, `exit`) |
|
|
519
|
+
| Tool | Description |
|
|
520
|
+
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
521
|
+
| `pinet` | Pinet dispatcher with token-efficient `action`-based routing (`help`, `send`, `read`, `free`, `snooze`, `schedule`, `agents`, `lanes`, `ports`, `spawn`, `reload`, `exit`) |
|
|
522
|
+
|
|
523
|
+
Use the dispatcher for Pinet tool actions: `pinet action=send`, `pinet action=read`, `pinet action=free`, `pinet action=snooze`, `pinet action=schedule`, `pinet action=agents`, `pinet action=lanes`, `pinet action=ports`, `pinet action=spawn`, `pinet action=reload`, and `pinet action=exit`. Use slash commands for UI lifecycle transitions: `/pinet start`, `/pinet follow`, `/pinet unfollow`, and `/pinet subtree start`. Dedicated direct Pinet tools (`pinet_message`, `pinet_read`, `pinet_agents`, `pinet_free`, `pinet_schedule`) are no longer registered. Legacy `pinet_*` guardrail patterns still match dispatcher action names, and legacy send policies such as `pinet_send` or `pinet_message` also cover `pinet action=send`, so existing security configs fail closed during migration.
|
|
524
|
+
|
|
525
|
+
Worker-owned subtree brokers let a follower worker supervise its own child mesh without registering those children in the central broker. Run `/pinet subtree start` (alias: `/pinet subbroker start`) from a follower worker. The worker remains connected to the central broker as a normal worker, and it also starts a separate broker socket/database under `~/.pi/pinet-subtrees/<worker>/`. Child workers launched by this worker receive `PINET_SOCKET_PATH`, `PINET_PARENT_AGENT_ID`, `PINET_ROOT_AGENT_ID`, `PINET_LAUNCH_ID`, `PINET_SUBTREE_ROLE`, and related metadata, so they follow the worker's subtree broker instead of the central Pinet broker.
|
|
475
526
|
|
|
476
|
-
Use
|
|
527
|
+
Use `pinet action=spawn args.repo=<repo> args.task=<task> [args.role=<role>] [args.lane_id=<lane>]` or `/pinet subtree spawn repo=<repo> [role=<role>] [lane=<lane>] <task>` to launch a tmux-backed child worker, wait for it to register in the subtree broker, and deliver the task over private Pinet A2A. Use `pinet action=agents args.scope=subtree args.full=true` from the supervising worker to list subtree children, `pinet action=send args.to=<child> args.message=<message>` to reply/control them, `pinet action=read` to read child reports, and `pinet action=exit args.target=<child>` or `/pinet subtree stop` to clean them up. The central broker sees only the supervising worker; the subtree DB contains the child roster and messages.
|
|
477
528
|
|
|
478
|
-
Dispatcher content defaults to terse CLI-style confirmations/summaries for noisy reads, sends, and agent lists.
|
|
529
|
+
Dispatcher content defaults to terse CLI-style confirmations/summaries for noisy reads, sends, and agent lists. Bulky read/agent payloads are compacted in `data.details` by default, including when `args.format="json"` (or `args.f` / `args["-f"]`) renders the dispatcher envelope in content. Use `args.full=true` / `args["--full"]=true` only when you need verbose text and full structured debug details such as exact message bodies or agent metadata.
|
|
479
530
|
|
|
480
531
|
Durable Pinet inbox notifications are classified as `steering`, `fwup`, or `maintenance/context` from explicit metadata or message cues. Follower prompts receive compact pointers such as `pinet action=read args.thread_id=...` instead of the full durable message body; agents use `pinet action=read` to retrieve the actual context. Delivery, read/ack state, and mail classification remain separate.
|
|
481
532
|
|
|
@@ -491,17 +542,18 @@ RALPH snooze quiets non-urgent empty maintenance cycles without disabling human-
|
|
|
491
542
|
|
|
492
543
|
### Pinet command surface
|
|
493
544
|
|
|
494
|
-
Use `/pinet <action> [args]` for mesh lifecycle and broker operations.
|
|
545
|
+
Use `/pinet <action> [args]` for mesh lifecycle and broker operations. In the Slack web app, use `/<app> agents list [all]` for the Slack-native broker roster/current-work view: `/pinet agents list` for the Pinet app, or `/oathgate agents list` for an Oathgate-named app. Set `slackCommandName` (or `slackCommandNames`) in `slack-bridge` settings before deploying the manifest when the Slack command should match a non-default app name.
|
|
495
546
|
|
|
496
|
-
| Command
|
|
497
|
-
|
|
|
498
|
-
| `/pinet start`
|
|
499
|
-
| `/pinet follow`
|
|
500
|
-
| `/pinet unfollow`
|
|
501
|
-
| `/pinet reload <agent>`
|
|
502
|
-
| `/pinet exit <agent>`
|
|
503
|
-
| `/pinet free`
|
|
504
|
-
| `/pinet snooze [duration/off/status]`
|
|
547
|
+
| Command | Description |
|
|
548
|
+
| ------------------------------------------ | ----------------------------------------------------------------------------- |
|
|
549
|
+
| `/pinet start` | Start as the mesh broker |
|
|
550
|
+
| `/pinet follow` | Connect as a follower worker |
|
|
551
|
+
| `/pinet unfollow` | Disconnect from the broker |
|
|
552
|
+
| `/pinet reload <agent>` | Ask another agent to reload |
|
|
553
|
+
| `/pinet exit <agent>` | Ask another agent to exit |
|
|
554
|
+
| `/pinet free` | Mark this agent as idle |
|
|
555
|
+
| `/pinet snooze [duration/off/status]` | Quiet empty RALPH cycles while preserving human-triggered wake/route behavior |
|
|
556
|
+
| `/pinet subtree [start/status/spawn/stop]` | Run this worker as a local subtree broker for child followers |
|
|
505
557
|
|
|
506
558
|
### Pinet skins
|
|
507
559
|
|
|
@@ -555,7 +607,7 @@ pnpm test
|
|
|
555
607
|
pnpm deploy:slack
|
|
556
608
|
```
|
|
557
609
|
|
|
558
|
-
Requires `appId` and `appConfigToken` in settings (or `SLACK_APP_ID` / `SLACK_APP_CONFIG_TOKEN` env vars).
|
|
610
|
+
Requires `appId` and `appConfigToken` in settings (or `SLACK_APP_ID` / `SLACK_APP_CONFIG_TOKEN` env vars). The deploy path rewrites `features.slash_commands` from `slackCommandName` / `slackCommandNames` (or the configured `skinTheme`) before validating and uploading, so set `slackCommandName: "/oathgate"` for an Oathgate app and leave it unset for the packaged Pinet `/pinet` default.
|
|
559
611
|
|
|
560
612
|
### Architecture
|
|
561
613
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ParsedAppHomeOpened, type ParsedThreadStarted } from "../../slack-access.js";
|
|
1
|
+
import { type ParsedAppHomeOpened, type ParsedSlashCommand, type ParsedThreadStarted } from "../../slack-access.js";
|
|
2
2
|
import { type ReactionCommandSettings } from "../../reaction-triggers.js";
|
|
3
3
|
import type { AdapterCapabilityRequest, AdapterCapabilityResult, InboundMessage, OutboundMessage, MessageAdapter } from "./types.js";
|
|
4
4
|
export { classifyMessage, extractAppHomeOpened, extractThreadStarted, parseMemberJoinedChannel, parseSocketFrame, RECONNECT_DELAY_MS, } from "../../slack-access.js";
|
|
@@ -21,8 +21,22 @@ export interface SlackAdapterConfig {
|
|
|
21
21
|
} | null;
|
|
22
22
|
/** Persist thread metadata in the broker DB without claiming ownership. */
|
|
23
23
|
rememberKnownThread?: (threadTs: string, channelId: string, context?: ParsedThreadStarted["context"] | null) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Gate reaction-trigger handling after the reacted message thread is known.
|
|
26
|
+
* Use this to require an already authorized/Pinet-owned thread before an
|
|
27
|
+
* opt-in reaction command can enqueue work or mutate durable thread state.
|
|
28
|
+
*
|
|
29
|
+
* Reaction triggers are denied by default: when this callback is not
|
|
30
|
+
* configured, opt-in reaction commands never route, even for threads the
|
|
31
|
+
* adapter has merely seen/cached. Authorization must be explicit so that
|
|
32
|
+
* reactions pass the same invoked/owned-thread admission bar as normal
|
|
33
|
+
* Slack messages (#812).
|
|
34
|
+
*/
|
|
35
|
+
isReactionThreadAuthorized?: (threadTs: string, channelId: string) => boolean;
|
|
24
36
|
/** Best-effort callback for Home tab opens. */
|
|
25
37
|
onAppHomeOpened?: (event: ParsedAppHomeOpened) => Promise<void> | void;
|
|
38
|
+
/** Best-effort callback for Slack slash commands handled by the broker process. */
|
|
39
|
+
onSlashCommand?: (event: ParsedSlashCommand) => Promise<string | null> | string | null;
|
|
26
40
|
}
|
|
27
41
|
export declare const SLACK_THREAD_CACHE_MAX_SIZE = 5000;
|
|
28
42
|
export declare const SLACK_THREAD_CACHE_TTL_MS: number;
|
|
@@ -58,9 +72,13 @@ export declare class SlackAdapter implements MessageAdapter {
|
|
|
58
72
|
private resolveScopeForThread;
|
|
59
73
|
private onThreadStarted;
|
|
60
74
|
private onContextChanged;
|
|
75
|
+
private sendSlashCommandResponse;
|
|
76
|
+
private onSlashCommand;
|
|
61
77
|
private onAppHomeOpened;
|
|
62
78
|
private fetchMessageByTs;
|
|
63
79
|
private onReactionAdded;
|
|
80
|
+
private getCachedThread;
|
|
81
|
+
private isReactionThreadAuthorized;
|
|
64
82
|
private onMessage;
|
|
65
83
|
private emitInteractiveInbound;
|
|
66
84
|
private onMemberJoined;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import { addSlackReaction, buildSlackThreadRuntimeScope, classifyMessage, extractAppHomeOpened, extractThreadContextChanged, extractThreadStarted, fetchSlackMessageByTs, isSlackUserAllowed, removeSlackReaction, resolveSlackUserName, setSlackSuggestedPrompts, SlackSocketModeClient, } from "../../slack-access.js";
|
|
2
3
|
import { createAbortableOperationTracker, callSlackAPI, isAbortError, buildAllowlist, buildPinetControlMessage, buildPinetControlMetadata, } from "../../helpers.js";
|
|
3
4
|
import { buildReactionTriggerMessage, normalizeReactionName, resolveReactionCommands, } from "../../reaction-triggers.js";
|
|
4
5
|
import { TtlCache, TtlSet } from "../../ttl-cache.js";
|
|
5
6
|
import { SLACK_SOCKET_DELIVERY_DEDUP_MAX_SIZE, SLACK_SOCKET_DELIVERY_DEDUP_TTL_MS, } from "../../slack-access.js";
|
|
6
7
|
import { DEFAULT_SLACK_THREAD_STATUS, SlackThreadStatusManager, } from "../../slack-thread-status.js";
|
|
8
|
+
import { performSlackUploads, prepareSlackUpload } from "../../slack-upload.js";
|
|
7
9
|
export { classifyMessage, extractAppHomeOpened, extractThreadStarted, parseMemberJoinedChannel, parseSocketFrame, RECONNECT_DELAY_MS, } from "../../slack-access.js";
|
|
8
10
|
export const SLACK_THREAD_CACHE_MAX_SIZE = 5000;
|
|
9
11
|
export const SLACK_THREAD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -72,6 +74,7 @@ export class SlackAdapter {
|
|
|
72
74
|
onMemberJoinedChannel: (event) => this.onMemberJoined(event),
|
|
73
75
|
onAppHomeOpened: (event) => this.onAppHomeOpened(event),
|
|
74
76
|
onInteractive: (event) => this.emitInteractiveInbound(event),
|
|
77
|
+
onSlashCommand: (event) => this.onSlashCommand(event),
|
|
75
78
|
onError: (error) => {
|
|
76
79
|
if (!isAbortError(error)) {
|
|
77
80
|
console.error(`[slack-adapter] Socket Mode: ${errorMsg(error)}`);
|
|
@@ -140,7 +143,28 @@ export class SlackAdapter {
|
|
|
140
143
|
},
|
|
141
144
|
};
|
|
142
145
|
}
|
|
143
|
-
|
|
146
|
+
if (msg.files && msg.files.length > 0) {
|
|
147
|
+
if (slackBlocks && slackBlocks.length > 0) {
|
|
148
|
+
throw new Error("Slack text+file replies use Slack's external upload flow, which does not support Block Kit blocks in the same upload message. Omit blocks or send a separate block-only message.");
|
|
149
|
+
}
|
|
150
|
+
const uploads = await Promise.all(msg.files.map((file) => prepareSlackUpload({
|
|
151
|
+
path: file.path,
|
|
152
|
+
...(file.filename ? { filename: file.filename } : {}),
|
|
153
|
+
...(file.title ? { title: file.title } : {}),
|
|
154
|
+
...(file.filetype ? { filetype: file.filetype } : {}),
|
|
155
|
+
}, process.cwd(), os.tmpdir())));
|
|
156
|
+
await performSlackUploads({
|
|
157
|
+
uploads,
|
|
158
|
+
channelId: msg.channel,
|
|
159
|
+
threadTs: msg.threadId,
|
|
160
|
+
initialComment: msg.content?.text ?? msg.text,
|
|
161
|
+
slack: this.callSlack.bind(this),
|
|
162
|
+
token: this.config.botToken,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
await this.callSlack("chat.postMessage", this.config.botToken, body);
|
|
167
|
+
}
|
|
144
168
|
if (this.shuttingDown)
|
|
145
169
|
return;
|
|
146
170
|
const pending = this.pendingEyes.get(msg.threadId);
|
|
@@ -233,6 +257,50 @@ export class SlackAdapter {
|
|
|
233
257
|
/* best effort — DB cache sync must not break Slack event handling */
|
|
234
258
|
}
|
|
235
259
|
}
|
|
260
|
+
async sendSlashCommandResponse(event, text, options = {}) {
|
|
261
|
+
if (options.useResponseUrl !== false && event.responseUrl) {
|
|
262
|
+
try {
|
|
263
|
+
const response = await this.slackRequests.run((signal) => fetch(event.responseUrl, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
266
|
+
body: JSON.stringify({ response_type: "ephemeral", text }),
|
|
267
|
+
signal,
|
|
268
|
+
}));
|
|
269
|
+
if (response.ok) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
console.error(`[slack-adapter] Slash command response_url failed: HTTP ${response.status}`);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
console.error(`[slack-adapter] Slash command response_url failed: ${errorMsg(error)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
await this.callSlack("chat.postEphemeral", this.config.botToken, {
|
|
279
|
+
channel: event.channelId,
|
|
280
|
+
user: event.userId,
|
|
281
|
+
text,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
async onSlashCommand(event) {
|
|
285
|
+
if (this.shuttingDown || !this.config.onSlashCommand)
|
|
286
|
+
return;
|
|
287
|
+
if (!isSlackUserAllowed(this.allowlist, event.userId)) {
|
|
288
|
+
await this.sendSlashCommandResponse(event, "Sorry, I can only respond to authorized users. Please contact an admin if you need access.");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const responseText = await this.config.onSlashCommand(event);
|
|
293
|
+
if (responseText && !this.shuttingDown) {
|
|
294
|
+
await this.sendSlashCommandResponse(event, responseText);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
console.error(`[slack-adapter] Slash command failed: ${errorMsg(error)}`);
|
|
299
|
+
await this.sendSlashCommandResponse(event, `Slack command failed: ${errorMsg(error)}`, {
|
|
300
|
+
useResponseUrl: false,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
236
304
|
async onAppHomeOpened(event) {
|
|
237
305
|
if (this.shuttingDown)
|
|
238
306
|
return;
|
|
@@ -280,25 +348,17 @@ export class SlackAdapter {
|
|
|
280
348
|
try {
|
|
281
349
|
const isInterruptReaction = command.action === "interrupt";
|
|
282
350
|
const reactedMessage = await this.fetchMessageByTs(item.channel, item.ts);
|
|
283
|
-
if (!reactedMessage && isInterruptReaction) {
|
|
284
|
-
throw new Error(`Unable to identify Slack thread for interrupt reaction ${item.ts} in channel ${item.channel}`);
|
|
285
|
-
}
|
|
286
351
|
const reactedMessageFetchStatus = reactedMessage ? "found" : "unavailable";
|
|
287
352
|
const threadTs = reactedMessage?.thread_ts ??
|
|
288
353
|
reactedMessage?.ts ??
|
|
289
354
|
item.ts;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
this.config.rememberKnownThread?.(threadTs, item.channel, null);
|
|
298
|
-
}
|
|
299
|
-
catch {
|
|
300
|
-
/* best effort — DB cache sync must not break reaction handling */
|
|
301
|
-
}
|
|
355
|
+
const cachedThread = this.getCachedThread(threadTs);
|
|
356
|
+
if (!this.isReactionThreadAuthorized(threadTs, item.channel, cachedThread)) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const existingThread = cachedThread ?? this.getThread(threadTs);
|
|
360
|
+
if (!existingThread || existingThread.channelId !== item.channel) {
|
|
361
|
+
return;
|
|
302
362
|
}
|
|
303
363
|
const reactorName = isInterruptReaction
|
|
304
364
|
? await this.resolveUser(userId).catch(() => userId)
|
|
@@ -323,7 +383,6 @@ export class SlackAdapter {
|
|
|
323
383
|
: reactedMessage
|
|
324
384
|
? "(no text)"
|
|
325
385
|
: "(message text unavailable; Slack did not return the reacted message, so use the channel/thread/message ids for context)";
|
|
326
|
-
const threadInfo = this.getThread(threadTs);
|
|
327
386
|
const reactionEventTs = evt.event_ts ?? item.ts;
|
|
328
387
|
this.inboundHandler?.({
|
|
329
388
|
source: "slack",
|
|
@@ -370,7 +429,7 @@ export class SlackAdapter {
|
|
|
370
429
|
},
|
|
371
430
|
scope: buildSlackThreadRuntimeScope({
|
|
372
431
|
channelId: item.channel,
|
|
373
|
-
context:
|
|
432
|
+
context: existingThread.context,
|
|
374
433
|
}),
|
|
375
434
|
});
|
|
376
435
|
await this.addReaction(item.channel, item.ts, "white_check_mark");
|
|
@@ -380,6 +439,31 @@ export class SlackAdapter {
|
|
|
380
439
|
await this.addReaction(item.channel, item.ts, "x");
|
|
381
440
|
}
|
|
382
441
|
}
|
|
442
|
+
getCachedThread(threadTs) {
|
|
443
|
+
const cached = this.threads.get(threadTs);
|
|
444
|
+
if (cached) {
|
|
445
|
+
this.threads.set(threadTs, cached);
|
|
446
|
+
}
|
|
447
|
+
return cached;
|
|
448
|
+
}
|
|
449
|
+
isReactionThreadAuthorized(threadTs, channelId, cachedThread) {
|
|
450
|
+
if (cachedThread && cachedThread.channelId !== channelId)
|
|
451
|
+
return false;
|
|
452
|
+
// Deny by default (#812): without an explicit authorization gate, a
|
|
453
|
+
// merely cached/known thread must never authorize reaction routing.
|
|
454
|
+
const authorize = this.config.isReactionThreadAuthorized;
|
|
455
|
+
if (!authorize)
|
|
456
|
+
return false;
|
|
457
|
+
try {
|
|
458
|
+
return authorize(threadTs, channelId);
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
// Authorization failures must fail closed without posting visible
|
|
462
|
+
// error reactions into threads Pinet does not own.
|
|
463
|
+
console.error(`[slack-adapter] reaction thread authorization failed: ${errorMsg(error)}`);
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
383
467
|
async onMessage(evt) {
|
|
384
468
|
if (this.shuttingDown)
|
|
385
469
|
return;
|
|
@@ -392,6 +476,10 @@ export class SlackAdapter {
|
|
|
392
476
|
this.shouldSuppressLegacyThreadedDm(threadTs)) {
|
|
393
477
|
return;
|
|
394
478
|
}
|
|
479
|
+
// Check the allowlist before recording any thread state so unauthorized
|
|
480
|
+
// traffic can never mint known-thread/affinity side effects (#812).
|
|
481
|
+
if (!isSlackUserAllowed(this.allowlist, userId))
|
|
482
|
+
return;
|
|
395
483
|
if (!this.getThread(threadTs)) {
|
|
396
484
|
this.threads.set(threadTs, {
|
|
397
485
|
channelId: channel,
|
|
@@ -399,8 +487,6 @@ export class SlackAdapter {
|
|
|
399
487
|
userId,
|
|
400
488
|
});
|
|
401
489
|
}
|
|
402
|
-
if (!isSlackUserAllowed(this.allowlist, userId))
|
|
403
|
-
return;
|
|
404
490
|
void this.threadStatuses.begin(channel, threadTs, DEFAULT_SLACK_THREAD_STATUS);
|
|
405
491
|
void this.addReaction(channel, messageTs, "eyes");
|
|
406
492
|
const pending = this.pendingEyes.get(threadTs) ?? [];
|
|
@@ -430,6 +516,11 @@ export class SlackAdapter {
|
|
|
430
516
|
});
|
|
431
517
|
}
|
|
432
518
|
async emitInteractiveInbound(normalized) {
|
|
519
|
+
// Check the allowlist before recording in-memory or durable thread state
|
|
520
|
+
// so unauthorized interactive events cannot mint known-thread/affinity
|
|
521
|
+
// side effects (#812).
|
|
522
|
+
if (!isSlackUserAllowed(this.allowlist, normalized.userId))
|
|
523
|
+
return;
|
|
433
524
|
if (!this.getThread(normalized.threadTs)) {
|
|
434
525
|
this.threads.set(normalized.threadTs, {
|
|
435
526
|
channelId: normalized.channel,
|
|
@@ -443,8 +534,6 @@ export class SlackAdapter {
|
|
|
443
534
|
catch {
|
|
444
535
|
/* best effort — DB cache sync must not break Slack event handling */
|
|
445
536
|
}
|
|
446
|
-
if (!isSlackUserAllowed(this.allowlist, normalized.userId))
|
|
447
|
-
return;
|
|
448
537
|
const userName = await this.resolveUser(normalized.userId);
|
|
449
538
|
if (this.shuttingDown)
|
|
450
539
|
return;
|
package/dist/broker/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PinetReadOptions, PinetReadResult } from "@pinet/pinet-core/pinet-read-formatting";
|
|
2
|
-
import type { ClientAgentInfo, NormalizedMessageContent, PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput } from "./types.js";
|
|
2
|
+
import type { ClientAgentInfo, NormalizedMessageContent, OutboundAttachmentFile, PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput } from "./types.js";
|
|
3
3
|
export interface InboxItem {
|
|
4
4
|
inboxId: number;
|
|
5
5
|
message: {
|
|
@@ -105,6 +105,7 @@ export declare class BrokerClient {
|
|
|
105
105
|
channel?: string;
|
|
106
106
|
content?: NormalizedMessageContent;
|
|
107
107
|
blocks?: ReadonlyArray<Record<string, unknown>>;
|
|
108
|
+
files?: ReadonlyArray<OutboundAttachmentFile>;
|
|
108
109
|
agentName?: string;
|
|
109
110
|
agentEmoji?: string;
|
|
110
111
|
agentOwnerToken?: string;
|
package/dist/broker/client.js
CHANGED
|
@@ -269,6 +269,7 @@ export class BrokerClient {
|
|
|
269
269
|
...(input.channel ? { channel: input.channel } : {}),
|
|
270
270
|
...(input.content ? { content: input.content } : {}),
|
|
271
271
|
...(input.blocks && input.blocks.length > 0 ? { blocks: input.blocks } : {}),
|
|
272
|
+
...(input.files && input.files.length > 0 ? { files: input.files } : {}),
|
|
272
273
|
...(input.agentName ? { agentName: input.agentName } : {}),
|
|
273
274
|
...(input.agentEmoji ? { agentEmoji: input.agentEmoji } : {}),
|
|
274
275
|
...(input.agentOwnerToken ? { agentOwnerToken: input.agentOwnerToken } : {}),
|
|
@@ -601,6 +601,23 @@ export class BrokerSocketServer {
|
|
|
601
601
|
const blocks = Array.isArray(params.blocks)
|
|
602
602
|
? params.blocks.filter((entry) => !!entry && typeof entry === "object")
|
|
603
603
|
: undefined;
|
|
604
|
+
const files = Array.isArray(params.files)
|
|
605
|
+
? params.files.flatMap((entry) => {
|
|
606
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
607
|
+
return [];
|
|
608
|
+
const file = entry;
|
|
609
|
+
if (typeof file.path !== "string" || file.path.trim().length === 0)
|
|
610
|
+
return [];
|
|
611
|
+
return [
|
|
612
|
+
{
|
|
613
|
+
path: file.path,
|
|
614
|
+
...(typeof file.filename === "string" ? { filename: file.filename } : {}),
|
|
615
|
+
...(typeof file.title === "string" ? { title: file.title } : {}),
|
|
616
|
+
...(typeof file.filetype === "string" ? { filetype: file.filetype } : {}),
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
})
|
|
620
|
+
: undefined;
|
|
604
621
|
let content;
|
|
605
622
|
if (params.content !== undefined) {
|
|
606
623
|
if (!params.content || typeof params.content !== "object" || Array.isArray(params.content)) {
|
|
@@ -638,6 +655,7 @@ export class BrokerSocketServer {
|
|
|
638
655
|
...(channel ? { channel } : {}),
|
|
639
656
|
...(content ? { content } : {}),
|
|
640
657
|
...(blocks && blocks.length > 0 ? { blocks } : {}),
|
|
658
|
+
...(files && files.length > 0 ? { files } : {}),
|
|
641
659
|
...(agentName ? { agentName } : {}),
|
|
642
660
|
...(agentEmoji ? { agentEmoji } : {}),
|
|
643
661
|
...(agentOwnerToken ? { agentOwnerToken } : {}),
|
|
@@ -2,6 +2,9 @@ export interface SlackBridgeSettings {
|
|
|
2
2
|
appId?: string;
|
|
3
3
|
appToken?: string;
|
|
4
4
|
appConfigToken?: string;
|
|
5
|
+
skinTheme?: string;
|
|
6
|
+
slackCommandName?: string;
|
|
7
|
+
slackCommandNames?: string[];
|
|
5
8
|
}
|
|
6
9
|
export interface ManifestScopeChanges {
|
|
7
10
|
addedBotScopes: string[];
|
|
@@ -14,6 +17,7 @@ export interface ResolvedDeployConfig {
|
|
|
14
17
|
appId?: string;
|
|
15
18
|
appConfigToken?: string;
|
|
16
19
|
appToken?: string;
|
|
20
|
+
settings: SlackBridgeSettings;
|
|
17
21
|
}
|
|
18
22
|
export interface DeployResult {
|
|
19
23
|
appId: string;
|
|
@@ -23,5 +27,6 @@ export declare function diffManifestScopes(beforeManifest: Record<string, unknow
|
|
|
23
27
|
export declare function formatScopeChangeSummary(changes: ManifestScopeChanges): string[];
|
|
24
28
|
export declare function resolveDeployConfig(settings: SlackBridgeSettings, env: NodeJS.ProcessEnv, cwd?: string): ResolvedDeployConfig;
|
|
25
29
|
export declare function getDeployConfigError(config: ResolvedDeployConfig): string | null;
|
|
30
|
+
export declare function applyConfiguredSlashCommands(manifest: Record<string, unknown>, settings: SlackBridgeSettings): Record<string, unknown>;
|
|
26
31
|
export declare function deploySlackManifest(config: ResolvedDeployConfig): Promise<DeployResult>;
|
|
27
32
|
export declare function run(): Promise<void>;
|
package/dist/deploy-manifest.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { execFile as execFileCallback } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { resolveSlackAgentCommandNames } from "./slack-agents-command.js";
|
|
6
7
|
const execFile = promisify(execFileCallback);
|
|
7
8
|
class SlackMethodError extends Error {
|
|
8
9
|
method;
|
|
@@ -104,6 +105,7 @@ export function resolveDeployConfig(settings, env, cwd = process.cwd()) {
|
|
|
104
105
|
appId: settings.appId ?? env.SLACK_APP_ID,
|
|
105
106
|
appConfigToken: settings.appConfigToken ?? env.SLACK_APP_CONFIG_TOKEN ?? env.SLACK_CONFIG_TOKEN,
|
|
106
107
|
appToken: settings.appToken ?? env.SLACK_APP_TOKEN,
|
|
108
|
+
settings,
|
|
107
109
|
};
|
|
108
110
|
}
|
|
109
111
|
export function getDeployConfigError(config) {
|
|
@@ -123,6 +125,33 @@ export function getDeployConfigError(config) {
|
|
|
123
125
|
}
|
|
124
126
|
return messages.length > 0 ? messages.join(" ") : null;
|
|
125
127
|
}
|
|
128
|
+
export function applyConfiguredSlashCommands(manifest, settings) {
|
|
129
|
+
const commandNames = resolveSlackAgentCommandNames(settings);
|
|
130
|
+
const features = asRecord(manifest.features) ?? {};
|
|
131
|
+
const oauthConfig = asRecord(manifest.oauth_config) ?? {};
|
|
132
|
+
const scopes = asRecord(oauthConfig.scopes) ?? {};
|
|
133
|
+
const botScopes = readScopeList(manifest, "bot");
|
|
134
|
+
const nextBotScopes = botScopes.includes("commands") ? botScopes : [...botScopes, "commands"];
|
|
135
|
+
return {
|
|
136
|
+
...manifest,
|
|
137
|
+
features: {
|
|
138
|
+
...features,
|
|
139
|
+
slash_commands: commandNames.map((command) => ({
|
|
140
|
+
command,
|
|
141
|
+
description: "Show the Pinet broker roster and current work",
|
|
142
|
+
usage_hint: "agents list [all]",
|
|
143
|
+
should_escape: false,
|
|
144
|
+
})),
|
|
145
|
+
},
|
|
146
|
+
oauth_config: {
|
|
147
|
+
...oauthConfig,
|
|
148
|
+
scopes: {
|
|
149
|
+
...scopes,
|
|
150
|
+
bot: nextBotScopes,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
126
155
|
async function parseManifestYaml(manifestPath) {
|
|
127
156
|
try {
|
|
128
157
|
const program = [
|
|
@@ -169,7 +198,7 @@ export async function deploySlackManifest(config) {
|
|
|
169
198
|
}
|
|
170
199
|
const appId = config.appId;
|
|
171
200
|
const appConfigToken = config.appConfigToken;
|
|
172
|
-
const manifest = await parseManifestYaml(config.manifestPath);
|
|
201
|
+
const manifest = applyConfiguredSlashCommands(await parseManifestYaml(config.manifestPath), config.settings);
|
|
173
202
|
const previousManifest = await exportRemoteManifest(appId, appConfigToken);
|
|
174
203
|
await validateManifest(manifest, appConfigToken);
|
|
175
204
|
await updateManifest(appId, manifest, appConfigToken);
|