@pinet/slack-bridge 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -32
- 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 +506 -38
- 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 +9 -7
- package/skills/slack-bridge/SKILL.md +60 -1
package/README.md
CHANGED
|
@@ -60,9 +60,10 @@ bump versions without explicit maintainer release approval.
|
|
|
60
60
|
2. Choose **From a manifest**
|
|
61
61
|
3. Select your workspace
|
|
62
62
|
4. Paste the contents of [`manifest.yaml`](./manifest.yaml) from this directory
|
|
63
|
-
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**
|
|
64
65
|
|
|
65
|
-
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.
|
|
66
67
|
|
|
67
68
|
### 2. Generate tokens
|
|
68
69
|
|
|
@@ -81,13 +82,13 @@ These are included in the manifest, but for reference:
|
|
|
81
82
|
app_mentions:read assistant:write bookmarks:read
|
|
82
83
|
bookmarks:write canvases:read canvases:write
|
|
83
84
|
channels:history channels:read chat:write
|
|
84
|
-
files:read files:write groups:history
|
|
85
|
+
commands files:read files:write groups:history
|
|
85
86
|
groups:read im:history im:read
|
|
86
87
|
im:write pins:read pins:write
|
|
87
88
|
reactions:read reactions:write users:read
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
`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.
|
|
91
92
|
|
|
92
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.
|
|
93
94
|
|
|
@@ -198,6 +199,8 @@ Slack access is now **default-deny** unless you configure one of these explicitl
|
|
|
198
199
|
| `ralphSnoozeAfterEmptyCycles` | no | Broker RALPH auto-snooze trigger after N empty cycles; defaults to `0` (disabled), valid range `0`-`100` |
|
|
199
200
|
| `ralphSnoozeDurationMs` | no | Broker RALPH auto-snooze duration in milliseconds; defaults to `1800000` (30 minutes), valid range `60000`-`86400000` |
|
|
200
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 |
|
|
201
204
|
| `meshSecret` | no | Optional inline Pinet shared secret; overrides `meshSecretPath` and env fallbacks |
|
|
202
205
|
| `meshSecretPath` | no | Optional path to a shared-secret file; broker creates it if missing, followers require an existing file |
|
|
203
206
|
| `suggestedPrompts` | no | Prompts shown when a user opens a new conversation |
|
|
@@ -234,7 +237,7 @@ Messages queue while the agent is busy. When the agent finishes, it automaticall
|
|
|
234
237
|
|
|
235
238
|
### Reaction triggers
|
|
236
239
|
|
|
237
|
-
|
|
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.
|
|
238
241
|
|
|
239
242
|
### Available tools
|
|
240
243
|
|
|
@@ -254,6 +257,7 @@ Cold Slack actions live behind the `slack` dispatcher:
|
|
|
254
257
|
| `react` | Add an emoji reaction to a message |
|
|
255
258
|
| `read` | Read messages from a thread |
|
|
256
259
|
| `upload` | Upload files, snippets, or diffs into Slack |
|
|
260
|
+
| `file` | Download Slack-hosted files to a controlled local temp cache by file ID |
|
|
257
261
|
| `schedule` | Schedule a message for later delivery |
|
|
258
262
|
| `post_channel` | Post to a channel (by name or ID) |
|
|
259
263
|
| `delete` | Delete a bot-posted message or an entire thread |
|
|
@@ -307,7 +311,21 @@ migration.
|
|
|
307
311
|
- **Uploads are for bulky artifacts.** Use `upload` for logs, screenshots,
|
|
308
312
|
long diffs, and generated files instead of large inline messages. Inline
|
|
309
313
|
uploads require `filename`; path uploads are guarded and must stay within the
|
|
310
|
-
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.
|
|
311
329
|
- **Upload host egress note.** The second upload leg goes to Slack file upload
|
|
312
330
|
hosts (`files.slack.com`/`uploads.slack.com`) for the raw payload. In
|
|
313
331
|
environments with restricted egress this can fail with `403` (proxy
|
|
@@ -342,10 +360,13 @@ migration.
|
|
|
342
360
|
...` string from the error, request confirmation, wait for the user's approval
|
|
343
361
|
via `slack_inbox`, then retry the guarded call unchanged. Batched
|
|
344
362
|
multi-thread Slack turns cannot satisfy a single-thread confirmation.
|
|
345
|
-
- **
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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.
|
|
349
370
|
|
|
350
371
|
#### Common dispatcher examples
|
|
351
372
|
|
|
@@ -418,12 +439,13 @@ The `canvas_comments_read` dispatcher action is intentionally narrow:
|
|
|
418
439
|
|
|
419
440
|
### Slash commands
|
|
420
441
|
|
|
421
|
-
| Command
|
|
422
|
-
|
|
|
423
|
-
| `/pinet <action>`
|
|
424
|
-
| `/pinet status`
|
|
425
|
-
| `/pinet rename`
|
|
426
|
-
| `/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 |
|
|
427
449
|
|
|
428
450
|
## Runtime modes
|
|
429
451
|
|
|
@@ -494,13 +516,17 @@ Only broker prompt content is replaceable. Broker runtime/tool restrictions rema
|
|
|
494
516
|
|
|
495
517
|
### Multi-agent tools
|
|
496
518
|
|
|
497
|
-
| Tool | Description
|
|
498
|
-
| ------- |
|
|
499
|
-
| `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`) |
|
|
500
522
|
|
|
501
|
-
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=reload`, and `pinet action=exit`. Use slash commands for UI lifecycle transitions: `/pinet start`, `/pinet follow`, and `/pinet
|
|
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.
|
|
502
524
|
|
|
503
|
-
|
|
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.
|
|
526
|
+
|
|
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.
|
|
528
|
+
|
|
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.
|
|
504
530
|
|
|
505
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.
|
|
506
532
|
|
|
@@ -516,17 +542,18 @@ RALPH snooze quiets non-urgent empty maintenance cycles without disabling human-
|
|
|
516
542
|
|
|
517
543
|
### Pinet command surface
|
|
518
544
|
|
|
519
|
-
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.
|
|
520
546
|
|
|
521
|
-
| Command
|
|
522
|
-
|
|
|
523
|
-
| `/pinet start`
|
|
524
|
-
| `/pinet follow`
|
|
525
|
-
| `/pinet unfollow`
|
|
526
|
-
| `/pinet reload <agent>`
|
|
527
|
-
| `/pinet exit <agent>`
|
|
528
|
-
| `/pinet free`
|
|
529
|
-
| `/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 |
|
|
530
557
|
|
|
531
558
|
### Pinet skins
|
|
532
559
|
|
|
@@ -580,7 +607,7 @@ pnpm test
|
|
|
580
607
|
pnpm deploy:slack
|
|
581
608
|
```
|
|
582
609
|
|
|
583
|
-
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.
|
|
584
611
|
|
|
585
612
|
### Architecture
|
|
586
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);
|
package/dist/follower-runtime.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { buildFollowerRuntimeDiagnostic, buildPinetOwnerToken, extractPinetControlCommand, formatPinetInboxMessages, getFollowerOwnedThreadReclaims, getFollowerReconnectUiUpdate, partitionFollowerInboxEntries, resolvePinetMeshAuth, resolveRuntimeAgentIdentity, syncFollowerInboxEntries, syncTransferredSlackThreadContexts, } from "./helpers.js";
|
|
2
2
|
import { drainFollowerAckBatches, hasDeliveredFollowerInboxIds, isFollowerInboxIdTracked, markFollowerInboxIdsDelivered, queueFollowerInboxIds, resetFollowerDeliveryState, } from "./follower-delivery.js";
|
|
3
3
|
import { BrokerClient, DEFAULT_SOCKET_PATH } from "./broker/client.js";
|
|
4
|
+
function resolveBrokerSocketPath() {
|
|
5
|
+
const envPath = process.env.PINET_SOCKET_PATH?.trim();
|
|
6
|
+
return envPath && envPath.length > 0 ? envPath : DEFAULT_SOCKET_PATH;
|
|
7
|
+
}
|
|
4
8
|
function getInboxIds(entries) {
|
|
5
9
|
return entries.flatMap((entry) => (typeof entry.inboxId === "number" ? [entry.inboxId] : []));
|
|
6
10
|
}
|
|
@@ -91,7 +95,7 @@ export function createFollowerRuntime(deps) {
|
|
|
91
95
|
deps.refreshSettings();
|
|
92
96
|
const meshAuth = resolvePinetMeshAuth(deps.getSettings());
|
|
93
97
|
const client = new BrokerClient({
|
|
94
|
-
path:
|
|
98
|
+
path: resolveBrokerSocketPath(),
|
|
95
99
|
...(meshAuth.meshSecret ? { meshSecret: meshAuth.meshSecret } : {}),
|
|
96
100
|
...(meshAuth.meshSecretPath ? { meshSecretPath: meshAuth.meshSecretPath } : {}),
|
|
97
101
|
});
|