paperclip-plugin-slack-socket 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 0xCVH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # paperclip-plugin-slack-socket
2
+
3
+ A Paperclip plugin that connects Slack to Paperclip over [Socket Mode](https://api.slack.com/apis/socket-mode) — Paperclip opens an outbound WebSocket connection to Slack, so no public URL, reverse proxy, or inbound webhook endpoint is required. Install it, paste in two Slack tokens and a handful of IDs, and your Paperclip agents are reachable from Slack.
4
+
5
+ ## What it is
6
+
7
+ This plugin lets people talk to Paperclip agents from Slack and lets Paperclip push updates back into Slack, all over a single Socket Mode connection. Concretely it provides:
8
+
9
+ - **Agent chat** — DM the bot, or `@mention` it in a channel it has been invited to, to start a conversation with your configured default agent. Replies stream into the thread as the agent produces them. Replying inside that thread continues the same agent session without needing to mention the bot again.
10
+ - **Notifications** — Paperclip posts to Slack when an issue is created, when an issue is marked done, and when an agent run fails, each independently toggleable, with an optional per-category channel override (falling back to a default channel).
11
+ - **Approvals with buttons** — when Paperclip requests an approval, the plugin posts a message with Approve/Reject buttons; clicking one calls the Paperclip REST API to decide the approval and updates the Slack message in place to show the outcome.
12
+ - **The `ask_human` tool** — agents can pause and ask a human a question in Slack, either via an emoji reaction or a threaded text reply. The response is recorded as a comment on a Paperclip issue and wakes the issue's agent back up.
13
+ - **`/paperclip issue <title>`** — a slash command that creates a Paperclip issue from Slack and replies with a link, visible only to the person who ran it.
14
+
15
+ ## Install the plugin
16
+
17
+ Paperclip installs plugins instance-wide from an npm package name or a local path, via any of three equivalent routes:
18
+
19
+ - **Paperclip UI** — **Settings → Plugins → Install**, then enter the npm package name.
20
+ - **CLI** — `paperclipai plugin install <npm-package-or-absolute-path>`.
21
+ - **REST API** — `POST /api/plugins/install` with a JSON body of `{"packageName": "...", "isLocalPath": true|false}`.
22
+
23
+ This package is not yet published to npm, so today you install it from a local clone instead of by package name:
24
+
25
+ ```sh
26
+ git clone https://github.com/0xCVH/paperclip-plugin-slack-socket
27
+ cd paperclip-plugin-slack-socket
28
+ npm install
29
+ npm run build
30
+ ```
31
+
32
+ Then install the **absolute path** to that clone via whichever route you prefer — UI (paste the absolute path where it asks for a package name), CLI (`paperclipai plugin install /absolute/path/to/paperclip-plugin-slack-socket`), or REST (`{"packageName": "/absolute/path/to/paperclip-plugin-slack-socket", "isLocalPath": true}`). Once this package is published to npm, `paperclip-plugin-slack-socket` will install by name through any of the three routes instead.
33
+
34
+ Per Paperclip's plugin spec, the host running Paperclip needs a writable filesystem, `npm` available on its `PATH`, and (for npm-name installs) network access to the npm registry.
35
+
36
+ ## Slack setup
37
+
38
+ 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**.
39
+ 2. Choose **From an app manifest**, pick the workspace you want to install into, and paste the contents of [`slack-app-manifest.json`](./slack-app-manifest.json) from this repo when prompted. Review and create the app.
40
+ 3. On the app's **Install App** page, click **Install to Workspace**, approve the requested scopes, and then copy the **Bot User OAuth Token** (starts with `xoxb-`) — you'll need it in Paperclip.
41
+ 4. Go to **Basic Information** → **App-Level Tokens** and click **Generate Token and Scopes**. Give it a name, add the `connections:write` scope, and generate it. Copy the resulting **App-Level Token** (starts with `xapp-`) — this is what lets Socket Mode open its connection.
42
+
43
+ That's it on the Slack side — the manifest already enables Socket Mode, declares the bot events (`app_mention`, `message.channels`, `message.groups`, `message.im`, `reaction_added`), interactivity (for the approval buttons), and the `/paperclip` slash command, and requests the minimum bot scopes the plugin needs (`app_mentions:read`, `chat:write`, `channels:history`, `groups:history`, `im:history`, `im:read`, `im:write`, `reactions:read`, `users:read`, `commands`).
44
+
45
+ ## Paperclip setup
46
+
47
+ 1. In Paperclip, go to **Settings → Secrets** and create two secrets: one holding the Bot User OAuth Token (`xoxb-…`) and one holding the App-Level Token (`xapp-…`). Note the secret reference Paperclip shows for each (the settings form's secret-ref fields store whatever the Secrets page provides — the plugin passes it through opaquely and never sees the raw value).
48
+ 2. Install the plugin into your Paperclip instance (plugin id `cvh.slack-socket`) — see [Install the plugin](#install-the-plugin) above.
49
+ 3. Open the plugin's instance settings and fill in:
50
+ - **Slack Bot Token (secret reference)** — the secret reference for the bot token secret from step 1.
51
+ - **Slack App-Level Token (secret reference)** — the secret reference for the app token secret from step 1.
52
+ - **Company ID** — the Paperclip company UUID used for sessions, issues, and approvals.
53
+ - **Default Agent ID** — the agent that handles DM and @mention conversations.
54
+ - **Default Slack Channel ID** — the fallback channel for notifications (e.g. `C01ABC2DEF3`).
55
+
56
+ These five fields are required. Optionally set per-category notification toggles (`notifyOnIssueCreated`, `notifyOnIssueDone`, `notifyOnAgentRunFailed`, `notifyOnApprovalCreated`, all on by default), per-category channel overrides (`issuesChannelId`, `errorsChannelId`, `approvalsChannelId` — each falls back to the default channel when unset), a `paperclipBaseUrl` (see below), and `sessionIdleHours` (default 24) controlling how long an idle chat session stays open before the cleanup job closes it.
57
+ - **Paperclip Base URL** (`paperclipBaseUrl`) — the base URL of your Paperclip instance. This is load-bearing, not just cosmetic: it's used both to build dashboard links in Slack messages *and* as the target of the approval decision REST calls (`POST {paperclipBaseUrl}/api/approvals/:id/approve|reject`).
58
+ - **Paperclip Board API Key** (`paperclipApiKeyRef`, optional) — a secret reference to a Paperclip API key for a board-role user. Leave empty if your Paperclip instance runs in `local_trusted` deployment mode (every request is implicitly a board actor there). Set it if your instance runs in `authenticated` mode — approval decisions (the Approve/Reject buttons) need to authenticate as a board user, and the plugin sends this key as an `Authorization: Bearer` header on those requests.
59
+ 4. Press **Test Connection**. This calls Slack's `auth.test` with the bot token (verifies it's valid) and `apps.connections.open` with the app token (verifies it has `connections:write` and Socket Mode can be established). Fix any reported error before saving — a missing or malformed token, or a token missing the `connections:write` scope, are the most common failures here.
60
+ 5. Save. The plugin resolves both secrets, opens the Socket Mode connection, and registers the `ask_human` tool and the `*/15 * * * *` cleanup job (which closes agent sessions idle beyond `sessionIdleHours` and expires unanswered `ask_human` questions past their `timeoutMinutes`, default 1440).
61
+
62
+ ## Usage
63
+
64
+ - **DM the bot** from the Apps section of Slack's sidebar to start a private conversation with the default agent.
65
+ - **In a channel**, first `/invite @paperclip` (the bot only sees channel messages after being invited), then `@mention` it to start a thread-scoped conversation.
66
+ - **Reply in the thread** to continue the same agent session — no need to `@mention` the bot again once a thread has an active session.
67
+ - Run **`/paperclip issue <title>`** anywhere to create a Paperclip issue; the confirmation with a link is ephemeral (only you see it), which requires the bot to be a member of the channel the command was run in — `/invite @paperclip` first, or run the command in a DM with the bot. (The issue is still created even if the bot isn't in the channel; only the confirmation message would fail to post.) `/paperclip help` shows usage.
68
+ - Agents can call the **`ask_human`** tool mid-run to ask a person a question in Slack (by channel or by DMing a user), either waiting for an emoji reaction (`mode: "reaction"`) or a threaded text reply (`mode: "answer"`); the response is attached to the issue as a comment and the issue is woken up.
69
+
70
+ **Note:** multi-person group DMs (mpim) are treated the same as channels — the bot only responds in an mpim thread that already has an active session; it does not start new sessions there the way it does in a 1:1 DM. Starting a conversation in an mpim isn't currently supported.
71
+
72
+ ## Manual smoke test checklist
73
+
74
+ After installing and configuring the plugin, walk through this checklist end-to-end in a real Slack workspace:
75
+
76
+ 1. Plugin health shows **OK** after configuring the required fields and pressing Test Connection.
77
+ 2. DM the bot "hello" → a streamed agent reply appears in the thread.
78
+ 3. `@mention` the bot in a channel it has been invited to → it replies in a thread.
79
+ 4. Reply again in that same thread without mentioning the bot → the conversation continues in the same agent session.
80
+ 5. Create an issue in Paperclip → a Slack notification appears in the configured (or default) channel.
81
+ 6. Create an approval in Paperclip → Approve/Reject buttons appear in Slack; clicking **Approve** updates the message in place to show it was approved.
82
+ 7. Have an agent call `ask_human` with `mode: "reaction"` → react to the posted question with an emoji → a comment recording the response lands on the referenced Paperclip issue.
83
+ 8. Run `/paperclip issue Test` → an ephemeral message with a link to the new issue appears.
84
+
85
+ ## Security notes
86
+
87
+ - **Zero inbound HTTP surface.** Socket Mode means Paperclip connects out to Slack; there is no webhook endpoint, no public URL, and nothing for an attacker to send unsolicited requests to.
88
+ - **Tokens live only in Paperclip Secrets.** The plugin config stores secret UUIDs, never raw token values; the bot and app tokens are resolved from Paperclip's secret store at runtime and are never logged.
89
+ - **No allowlisting yet.** Any workspace member who can DM the bot can converse with the configured default agent, and anyone who can `@mention` it in a channel it's been invited to can do the same. There is currently no per-user or per-channel allowlist — restricting who can talk to the bot is a matter of who is in the workspace and which channels it's invited to. Tighter access control (e.g. an allowlist of Slack user or channel IDs) is future work.
@@ -0,0 +1,11 @@
1
+ import type { PluginContext } from "@paperclipai/plugin-sdk";
2
+ import type { InboundAction, SlackGateway, SlackSocketConfig } from "./types.js";
3
+ export interface ApprovalDeps {
4
+ ctx: PluginContext;
5
+ gateway: SlackGateway;
6
+ getConfig: () => Promise<SlackSocketConfig>;
7
+ }
8
+ export interface Approvals {
9
+ handleAction(action: InboundAction): Promise<void>;
10
+ }
11
+ export declare function createApprovals({ ctx, gateway, getConfig }: ApprovalDeps): Approvals;
@@ -0,0 +1,104 @@
1
+ import { ACTION_IDS } from "./constants.js";
2
+ import { formatApprovalCreated, formatApprovalDecided } from "./formatters.js";
3
+ import { errString } from "./redact.js";
4
+ export function createApprovals({ ctx, gateway, getConfig }) {
5
+ ctx.events.on("approval.created", async (event) => {
6
+ const e = event;
7
+ const cfg = await getConfig();
8
+ if (!cfg.notifyOnApprovalCreated || !e.entityId)
9
+ return;
10
+ const channel = cfg.approvalsChannelId || cfg.defaultChannelId;
11
+ if (!channel)
12
+ return;
13
+ try {
14
+ await gateway.postMessage({
15
+ channel,
16
+ ...formatApprovalCreated(e.entityId, e.payload, cfg.paperclipBaseUrl),
17
+ });
18
+ await ctx.metrics.write("slack.notifications.sent", 1, { type: "approval_created" }).catch(() => { });
19
+ }
20
+ catch (err) {
21
+ ctx.logger.warn("Slack approval notification failed", { err: errString(err) });
22
+ await ctx.metrics.write("slack.notifications.failed", 1, { type: "approval_created" }).catch(() => { });
23
+ }
24
+ });
25
+ async function postFailureEphemeral(action, approvalId, decision, hint) {
26
+ await gateway
27
+ .postEphemeral({
28
+ channel: action.channel,
29
+ user: action.user,
30
+ text: `:x: Failed to ${decision} approval \`${approvalId}\`. ${hint}`,
31
+ })
32
+ .catch(() => { });
33
+ }
34
+ return {
35
+ async handleAction(action) {
36
+ const cfg = await getConfig();
37
+ const decision = action.actionId === ACTION_IDS.approvalApprove ? "approve" : "reject";
38
+ const approvalId = action.value;
39
+ if (!approvalId) {
40
+ ctx.logger.warn("Approval action received with an empty value; ignoring", {
41
+ actionId: action.actionId,
42
+ user: action.user,
43
+ });
44
+ await gateway
45
+ .postEphemeral({
46
+ channel: action.channel,
47
+ user: action.user,
48
+ text: ":x: Could not process this action — no approval id was attached to the button.",
49
+ })
50
+ .catch(() => { });
51
+ return;
52
+ }
53
+ // In `local_trusted` deployment mode every request is implicitly a
54
+ // board actor, so no Authorization header is needed. In `authenticated`
55
+ // mode the server requires a board API key to authenticate the
56
+ // decision — resolve it only when the operator configured one.
57
+ let authHeaders = {};
58
+ if (cfg.paperclipApiKeyRef) {
59
+ try {
60
+ const apiKey = await ctx.secrets.resolve(cfg.paperclipApiKeyRef);
61
+ authHeaders = { Authorization: `Bearer ${apiKey}` };
62
+ }
63
+ catch (err) {
64
+ ctx.logger.warn("Approval decision via Slack failed: could not resolve the Paperclip board API key", {
65
+ err: errString(err),
66
+ approvalId,
67
+ });
68
+ await postFailureEphemeral(action, approvalId, decision, "The configured Paperclip board API key could not be resolved — check the plugin settings.");
69
+ return;
70
+ }
71
+ }
72
+ try {
73
+ const response = await ctx.http.fetch(`${cfg.paperclipBaseUrl}/api/approvals/${encodeURIComponent(approvalId)}/${decision}`, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json", ...authHeaders },
76
+ // The server ignores decidedByUserId in the body (it uses the
77
+ // authenticated actor) but does record decisionNote.
78
+ body: JSON.stringify({
79
+ decisionNote: `Decided via Slack by ${action.userName} (slack:${action.user})`,
80
+ }),
81
+ });
82
+ if (response.status < 200 || response.status >= 300) {
83
+ throw new Error(`Approval ${decision} returned HTTP ${response.status}`);
84
+ }
85
+ await gateway.updateMessage({
86
+ channel: action.channel,
87
+ ts: action.messageTs,
88
+ ...formatApprovalDecided(approvalId, decision, action.userName),
89
+ });
90
+ await ctx.activity.log({
91
+ companyId: cfg.companyId,
92
+ message: `Approval ${approvalId} ${decision === "approve" ? "approved" : "rejected"} via Slack by ${action.userName} (slack:${action.user})`,
93
+ entityType: "approval",
94
+ entityId: approvalId,
95
+ });
96
+ await ctx.metrics.write("slack.approvals.decided", 1, { decision });
97
+ }
98
+ catch (err) {
99
+ ctx.logger.warn("Approval decision via Slack failed", { err: errString(err), approvalId });
100
+ await postFailureEphemeral(action, approvalId, decision, "It may already be decided.");
101
+ }
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,13 @@
1
+ import type { PluginContext } from "@paperclipai/plugin-sdk";
2
+ import type { InboundMessage, InboundReaction, SlackGateway } from "./types.js";
3
+ export interface AskHumanDeps {
4
+ ctx: PluginContext;
5
+ gateway: SlackGateway;
6
+ }
7
+ export interface AskHuman {
8
+ registerTool(): void;
9
+ /** Returns true when the message was an answer to a pending question (callers must stop routing it). */
10
+ tryHandleAnswer(msg: InboundMessage): Promise<boolean>;
11
+ handleReaction(reaction: InboundReaction): Promise<void>;
12
+ }
13
+ export declare function createAskHuman({ ctx, gateway }: AskHumanDeps): AskHuman;
@@ -0,0 +1,156 @@
1
+ import { ASK_HUMAN_TOOL_DECLARATION, STATE_KEYS, TOOL_NAMES, stateScope } from "./constants.js";
2
+ import { formatQuestion, formatQuestionResolved } from "./formatters.js";
3
+ import { errString } from "./redact.js";
4
+ import { updateIndex } from "./state-index.js";
5
+ export function createAskHuman({ ctx, gateway }) {
6
+ // Same-process claim guard against the double-resolution race: two
7
+ // near-simultaneous events for the same pending question (e.g. a reaction
8
+ // and a thread reply, or two overlapping reactions) can both pass the
9
+ // "pending exists" read before either has written its resolution. Callers
10
+ // must check-and-add this set synchronously (no await in between) right
11
+ // after confirming the event matches a pending question, so only the
12
+ // first in-flight resolution for a given key proceeds.
13
+ const claimed = new Set();
14
+ async function resolvePending(key, pending, response, responderName) {
15
+ const body = `Slack response from ${responderName} to: "${pending.question}"\n\n${response}`;
16
+ await ctx.issues.createComment(pending.issueId, body, pending.companyId);
17
+ try {
18
+ await ctx.issues.requestWakeup(pending.issueId, pending.companyId, {
19
+ reason: "slack_ask_human_response",
20
+ contextSource: "slack-socket.ask-human",
21
+ });
22
+ }
23
+ catch (err) {
24
+ ctx.logger.warn("Wakeup after Slack answer failed", { err: errString(err), issueId: pending.issueId });
25
+ }
26
+ await gateway.updateMessage({
27
+ channel: pending.channel,
28
+ ts: pending.ts,
29
+ ...formatQuestionResolved(pending.question, response, responderName),
30
+ });
31
+ await ctx.state.delete(stateScope(key));
32
+ await updateIndex(ctx, STATE_KEYS.questionIndex, (current) => current.filter((k) => k !== key));
33
+ await ctx.metrics.write("slack.questions.answered", 1, { mode: pending.mode });
34
+ }
35
+ return {
36
+ registerTool() {
37
+ ctx.tools.register(TOOL_NAMES.askHuman, {
38
+ displayName: ASK_HUMAN_TOOL_DECLARATION.displayName,
39
+ description: ASK_HUMAN_TOOL_DECLARATION.description,
40
+ parametersSchema: ASK_HUMAN_TOOL_DECLARATION.parametersSchema,
41
+ }, async (params, runCtx) => {
42
+ const p = (params ?? {});
43
+ const question = typeof p.question === "string" ? p.question.trim() : "";
44
+ const target = typeof p.target === "string" ? p.target.trim() : "";
45
+ const mode = p.mode === "reaction" || p.mode === "answer" ? p.mode : null;
46
+ const issueId = typeof p.issueId === "string" ? p.issueId : "";
47
+ const timeoutMinutes = typeof p.timeoutMinutes === "number" && p.timeoutMinutes > 0 ? p.timeoutMinutes : 1440;
48
+ if (!question || !target || !mode || !issueId) {
49
+ return { error: "question, target, mode and issueId are required" };
50
+ }
51
+ let posted;
52
+ try {
53
+ // "U" is a regular user id; Enterprise Grid's cross-workspace
54
+ // "connected" users get a "W" id instead. Both DM.
55
+ const channel = target.startsWith("U") || target.startsWith("W") ? await gateway.openDm(target) : target;
56
+ posted = await gateway.postMessage({ channel, ...formatQuestion(question, mode) });
57
+ }
58
+ catch (err) {
59
+ return { error: `Failed to post question to Slack: ${errString(err)}` };
60
+ }
61
+ const key = STATE_KEYS.question(posted.channel, posted.ts);
62
+ try {
63
+ const pending = {
64
+ channel: posted.channel,
65
+ ts: posted.ts,
66
+ issueId,
67
+ companyId: runCtx.companyId,
68
+ mode,
69
+ question,
70
+ askedAt: new Date().toISOString(),
71
+ timeoutMinutes,
72
+ };
73
+ await ctx.state.set(stateScope(key), pending);
74
+ await updateIndex(ctx, STATE_KEYS.questionIndex, (current) => [...current, key]);
75
+ }
76
+ catch (err) {
77
+ // The question is now a live, unanswerable message in Slack: it
78
+ // posted successfully but we failed to record enough state to
79
+ // resolve it later. Log loudly, warn in the thread best-effort,
80
+ // and tell the caller the truth instead of the misleading
81
+ // "failed to post" message.
82
+ ctx.logger.error("Failed to track ask_human question after posting to Slack", {
83
+ err: errString(err),
84
+ channel: posted.channel,
85
+ ts: posted.ts,
86
+ issueId,
87
+ });
88
+ await gateway
89
+ .updateMessage({
90
+ channel: posted.channel,
91
+ ts: posted.ts,
92
+ text: ":warning: This question could not be tracked — please ask again.",
93
+ })
94
+ .catch((updateErr) => {
95
+ ctx.logger.error("Failed to mark untracked ask_human question in Slack", {
96
+ err: String(updateErr),
97
+ channel: posted.channel,
98
+ ts: posted.ts,
99
+ });
100
+ });
101
+ return { error: "Question was posted to Slack but could not be tracked; ask again." };
102
+ }
103
+ try {
104
+ await ctx.metrics.write("slack.questions.asked", 1, { mode });
105
+ }
106
+ catch (err) {
107
+ ctx.logger.warn("Failed to write ask_human metrics", { err: errString(err) });
108
+ }
109
+ return {
110
+ content: `Question posted to Slack channel ${posted.channel}. The response will be recorded as a comment on issue ${issueId}.`,
111
+ data: { channel: posted.channel, ts: posted.ts },
112
+ };
113
+ });
114
+ },
115
+ async tryHandleAnswer(msg) {
116
+ if (!msg.threadTs || msg.threadTs === msg.ts)
117
+ return false;
118
+ const key = STATE_KEYS.question(msg.channel, msg.threadTs);
119
+ const pending = (await ctx.state.get(stateScope(key)));
120
+ if (!pending || pending.mode !== "answer")
121
+ return false;
122
+ // This message IS an answer to a pending question — claim it before
123
+ // any further await so a concurrent resolution for the same key
124
+ // can't also record it. If another in-flight call already holds the
125
+ // claim, this is still an answer (caller must not fall through to
126
+ // chat routing), so return true without re-resolving.
127
+ if (claimed.has(key))
128
+ return true;
129
+ claimed.add(key);
130
+ try {
131
+ const name = await gateway.getUserDisplayName(msg.user);
132
+ await resolvePending(key, pending, msg.text, name);
133
+ }
134
+ finally {
135
+ claimed.delete(key);
136
+ }
137
+ return true;
138
+ },
139
+ async handleReaction(reaction) {
140
+ const key = STATE_KEYS.question(reaction.channel, reaction.messageTs);
141
+ const pending = (await ctx.state.get(stateScope(key)));
142
+ if (!pending || pending.mode !== "reaction")
143
+ return;
144
+ if (claimed.has(key))
145
+ return;
146
+ claimed.add(key);
147
+ try {
148
+ const name = await gateway.getUserDisplayName(reaction.user);
149
+ await resolvePending(key, pending, `:${reaction.reaction}:`, name);
150
+ }
151
+ finally {
152
+ claimed.delete(key);
153
+ }
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,46 @@
1
+ import type { InboundAction, InboundCommand, InboundMessage, InboundReaction, OutboundMessage, SlackGateway } from "./types.js";
2
+ interface GatewayLogger {
3
+ warn(message: string, data?: Record<string, unknown>): void;
4
+ }
5
+ export declare class BoltGateway implements SlackGateway {
6
+ private readonly app;
7
+ private readonly logger;
8
+ private connected;
9
+ private botId;
10
+ private messageHandlers;
11
+ private mentionHandlers;
12
+ private reactionHandlers;
13
+ constructor(opts: {
14
+ botToken: string;
15
+ appToken: string;
16
+ logger: GatewayLogger;
17
+ });
18
+ private dispatch;
19
+ onMessage(handler: (msg: InboundMessage) => Promise<void>): void;
20
+ onMention(handler: (msg: InboundMessage) => Promise<void>): void;
21
+ onReaction(handler: (r: InboundReaction) => Promise<void>): void;
22
+ onAction(pattern: RegExp, handler: (action: InboundAction) => Promise<void>): void;
23
+ onCommand(command: string, handler: (cmd: InboundCommand) => Promise<void>): void;
24
+ start(): Promise<void>;
25
+ stop(): Promise<void>;
26
+ isConnected(): boolean;
27
+ botUserId(): string | undefined;
28
+ postMessage(msg: OutboundMessage): Promise<{
29
+ channel: string;
30
+ ts: string;
31
+ }>;
32
+ updateMessage(msg: {
33
+ channel: string;
34
+ ts: string;
35
+ text: string;
36
+ blocks?: unknown[];
37
+ }): Promise<void>;
38
+ postEphemeral(msg: {
39
+ channel: string;
40
+ user: string;
41
+ text: string;
42
+ }): Promise<void>;
43
+ openDm(userId: string): Promise<string>;
44
+ getUserDisplayName(userId: string): Promise<string>;
45
+ }
46
+ export {};
@@ -0,0 +1,153 @@
1
+ import boltPkg from "@slack/bolt";
2
+ import { errString } from "./redact.js";
3
+ const { App } = boltPkg;
4
+ export class BoltGateway {
5
+ app;
6
+ logger;
7
+ connected = false;
8
+ botId;
9
+ messageHandlers = [];
10
+ mentionHandlers = [];
11
+ reactionHandlers = [];
12
+ constructor(opts) {
13
+ this.logger = opts.logger;
14
+ this.app = new App({ token: opts.botToken, appToken: opts.appToken, socketMode: true });
15
+ this.app.event("app_mention", async ({ event }) => {
16
+ const e = event;
17
+ await this.dispatch(this.mentionHandlers, {
18
+ channel: e.channel,
19
+ channelType: "channel",
20
+ user: e.user ?? "",
21
+ text: e.text ?? "",
22
+ ts: e.ts,
23
+ threadTs: e.thread_ts,
24
+ });
25
+ });
26
+ this.app.message(async ({ message }) => {
27
+ const m = message;
28
+ // thread_broadcast messages ("also send to channel" replies) are
29
+ // genuine human thread replies and must reach chat routing like any
30
+ // other reply; every other subtype (message_changed, message_deleted,
31
+ // bot_message, etc.) is not a live human message and stays filtered.
32
+ if ((m.subtype && m.subtype !== "thread_broadcast") || m.bot_id || !m.user)
33
+ return;
34
+ const channelType = m.channel_type === "im" ? "im" : m.channel_type === "group" ? "group" : "channel";
35
+ await this.dispatch(this.messageHandlers, {
36
+ channel: m.channel,
37
+ channelType,
38
+ user: m.user,
39
+ text: m.text ?? "",
40
+ ts: m.ts,
41
+ threadTs: m.thread_ts,
42
+ });
43
+ });
44
+ this.app.event("reaction_added", async ({ event }) => {
45
+ const e = event;
46
+ if (e.item.type !== "message" || !e.item.channel || !e.item.ts)
47
+ return;
48
+ await this.dispatch(this.reactionHandlers, {
49
+ channel: e.item.channel,
50
+ messageTs: e.item.ts,
51
+ user: e.user,
52
+ reaction: e.reaction,
53
+ });
54
+ });
55
+ }
56
+ async dispatch(handlers, payload) {
57
+ for (const handler of handlers) {
58
+ try {
59
+ await handler(payload);
60
+ }
61
+ catch (err) {
62
+ this.logger.warn("Slack handler failed", { err: errString(err) });
63
+ }
64
+ }
65
+ }
66
+ onMessage(handler) { this.messageHandlers.push(handler); }
67
+ onMention(handler) { this.mentionHandlers.push(handler); }
68
+ onReaction(handler) { this.reactionHandlers.push(handler); }
69
+ onAction(pattern, handler) {
70
+ this.app.action(pattern, async ({ ack, body, action }) => {
71
+ await ack();
72
+ const b = body;
73
+ const a = action;
74
+ try {
75
+ await handler({
76
+ actionId: a.action_id ?? "",
77
+ value: a.value ?? "",
78
+ user: b.user?.id ?? "",
79
+ userName: b.user?.name ?? b.user?.username ?? b.user?.id ?? "unknown",
80
+ channel: b.channel?.id ?? "",
81
+ messageTs: b.message?.ts ?? "",
82
+ });
83
+ }
84
+ catch (err) {
85
+ this.logger.warn("Slack action handler failed", { err: errString(err) });
86
+ }
87
+ });
88
+ }
89
+ onCommand(command, handler) {
90
+ this.app.command(command, async ({ ack, command: cmd }) => {
91
+ await ack();
92
+ try {
93
+ await handler({ command: cmd.command, text: cmd.text ?? "", user: cmd.user_id, channel: cmd.channel_id });
94
+ }
95
+ catch (err) {
96
+ this.logger.warn("Slack command handler failed", { err: errString(err) });
97
+ }
98
+ });
99
+ }
100
+ async start() {
101
+ // Verify the bot token (and capture the bot's user id) before opening
102
+ // the socket at all. auth.test is a plain HTTP call against
103
+ // `this.app.client`, which doesn't require the socket to be started.
104
+ // Doing this first means a bad token fails fast with no socket to unwind.
105
+ const auth = await this.app.client.auth.test();
106
+ this.botId = auth.user_id;
107
+ const receiver = this.app.receiver;
108
+ receiver?.client?.on?.("connected", () => { this.connected = true; });
109
+ receiver?.client?.on?.("disconnected", () => { this.connected = false; });
110
+ await this.app.start();
111
+ this.connected = true;
112
+ }
113
+ async stop() {
114
+ await this.app.stop();
115
+ this.connected = false;
116
+ }
117
+ isConnected() { return this.connected; }
118
+ botUserId() { return this.botId; }
119
+ async postMessage(msg) {
120
+ const res = await this.app.client.chat.postMessage({
121
+ channel: msg.channel,
122
+ text: msg.text,
123
+ blocks: msg.blocks,
124
+ thread_ts: msg.threadTs,
125
+ });
126
+ return { channel: res.channel ?? msg.channel, ts: res.ts ?? "" };
127
+ }
128
+ async updateMessage(msg) {
129
+ await this.app.client.chat.update({
130
+ channel: msg.channel,
131
+ ts: msg.ts,
132
+ text: msg.text,
133
+ blocks: (msg.blocks ?? []),
134
+ });
135
+ }
136
+ async postEphemeral(msg) {
137
+ await this.app.client.chat.postEphemeral({ channel: msg.channel, user: msg.user, text: msg.text });
138
+ }
139
+ async openDm(userId) {
140
+ const res = await this.app.client.conversations.open({ users: userId });
141
+ return res.channel?.id ?? userId;
142
+ }
143
+ async getUserDisplayName(userId) {
144
+ try {
145
+ const res = await this.app.client.users.info({ user: userId });
146
+ const user = res.user;
147
+ return user?.profile?.display_name || user?.profile?.real_name || user?.real_name || userId;
148
+ }
149
+ catch {
150
+ return userId;
151
+ }
152
+ }
153
+ }
package/dist/chat.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginContext } from "@paperclipai/plugin-sdk";
2
+ import type { InboundMessage, SlackGateway, SlackSocketConfig } from "./types.js";
3
+ export interface ChatDeps {
4
+ ctx: PluginContext;
5
+ gateway: SlackGateway;
6
+ getConfig: () => Promise<SlackSocketConfig>;
7
+ /** Minimum ms between streaming chat.update calls. Tests pass 0. */
8
+ updateIntervalMs?: number;
9
+ }
10
+ export interface Chat {
11
+ handleMention(msg: InboundMessage): Promise<void>;
12
+ handleMessage(msg: InboundMessage): Promise<void>;
13
+ }
14
+ export declare function createChat(deps: ChatDeps): Chat;