This commit is contained in:
Joe Brown 2026-09-13 20:50:17 +09:00 committed by GitHub
commit e1c56cddc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1626 additions and 0 deletions

View File

@ -45,6 +45,7 @@ COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
COPY packages/plugins/plugin-slack-control/package.json packages/plugins/plugin-slack-control/
COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/
COPY patches/ patches/
COPY scripts/link-plugin-dev-sdk.mjs scripts/

View File

@ -0,0 +1,3 @@
.build-deps/
node_modules/
dist/

View File

@ -0,0 +1,93 @@
# Slack Control (optional alpha)
Control configured Paperclip projects through a private Slack conversation. The plugin is disabled by default. It creates no agents, changes no agent instructions or credentials, and installs no Slack app automatically.
| Direct message | Result |
| --- | --- |
| `status` | Up to three task identifiers and states per configured project. No model invocation. |
| `new demo: Review the synthetic onboarding fixture` | Creates a task in the configured `demo` project, assigns its configured existing agent and requests a native Paperclip wake. |
| Reply in that message's Slack thread | Adds a human-attributed comment to the same task; normal Paperclip comment wake rules apply. |
| `status` within a bound task thread | Returns that task's current title and state. |
Commands must be plain text, at most 4,000 characters. `new` and project aliases use the exact lower-case syntax above; `status` is case-insensitive. Unknown commands return help. A `new` command inside an existing task thread is a comment, not another task. Attachments and rich-message content are not imported. Approvals, budget limits, blocked tasks and paused agents remain governed by Paperclip. A successful creation response does not mean an agent has started or completed the work.
## Implementation and boundaries
1. Validate one configured Slack workspace, at most ten explicit Slack-user to Paperclip-user mappings and ten project/agent aliases.
2. Accept only new human `message.im` events. Reject channels, group DMs, bot/app messages, edits, deleted/hidden messages, external-workspace identities and unlisted users. Confirm the conversation is a real one-to-one IM with that user using Slack's API.
3. Resolve both tokens through company-scoped Paperclip secret references. The authenticated bot workspace must match the configured workspace. Each command rechecks active human company membership and a writable role (`owner`, `admin`, `operator`, or legacy `member`). There is no implicit mapping from an email address, display name, local-board identity or Slack text.
4. Insert the event into the plugin's namespaced PostgreSQL inbox before acknowledging it. A worker-lifetime timer processes sequential batches of 25 every ten seconds, so replies can take up to ten seconds before processing starts. The timer is created during setup, outside the short-lived configuration invocation, and uses only the host's authorised proactive company scopes. Socket callbacks persist and acknowledge events; they do not perform company operations under an expired configuration invocation. Only bounded input validation and one database insert precede the acknowledgement; database outages can prevent Slack's acknowledgement deadline from being met, allowing provider retries.
5. Atomically claim each event once before any task/comment mutation. Store thread bindings by company, workspace, IM channel and root message timestamp. Future replies require the same Slack and Paperclip identities. Native human-comment attribution revalidates company membership in the host and records the user as author.
The plugin has one company configuration and one Socket Mode connection per worker. Reconfiguration closes the prior connection before opening the next, and stale connection handlers cannot dispatch new commands. A native request already in flight when configuration is disabled may still complete. Do not run a second instance of this plugin against the same Slack app and company database.
Initial authentication and Socket Mode reconnects share one retry loop. Transient network failures use exponential backoff from one second to a maximum of sixty seconds. A longer provider rate-limit delay takes precedence; if it exceeds Node's safe timer range, the plugin reports a `rate_limited` error requiring operator recovery instead of retrying early. Each attempt has a thirty-second authentication/hello deadline and uses fresh clients; SDK automatic reconnects and HTTP retries are disabled. Shutdown cancels backoff, pending requests and upgraded sockets through public SDK/undici APIs before another attempt can start. If teardown cannot be confirmed within five seconds, the plugin reports `cleanup_failed` and refuses replacement until the worker is restarted. Queued commands wait while offline and recheck the existing company/actor restrictions after recovery; already claimed or uncertain commands retain the delivery contract below.
Saving configuration starts connection work in the background; a successful save does not establish that Slack is connected. The board-only status response includes `connection.state`, `connection.lastFailure` (a fixed category) and `connection.retryAt` (a Unix timestamp in milliseconds or null). Invalid/revoked credentials, missing permissions, a workspace mismatch or an unclassified provider failure stop automatic retries and report an error. Correct the credentials or configuration and save again. Provider error bodies, headers, tokens and socket URLs are never returned or logged. Secret references are resolved only during the company-scoped configuration invocation, not from reconnect callbacks.
The host must replay persisted company configuration on both initial activation and worker crash recovery. This plugin does not include that host change or keep a separate hidden configuration cache. The upstream base used for this contribution replays configuration on initial startup only: after a worker crash, re-save configuration or restart the host to reconnect. Unattended crash recovery requires a separate host fix; related upstream work is tracked in [PR #10100](https://github.com/paperclipai/paperclip/pull/10100). Verify that the installed host includes recovery replay before relying on it.
This is a transport, not an account scheduler or a natural-language planner. It uses existing agent configuration and native limits; it does not add a shared subscription-account concurrency lock. Choose existing agents that already respect your account allocation. It cannot make a local machine's agents run while that machine is off. The Paperclip host and selected execution environment must be available.
## Delivery contract and recovery
Paperclip's current plugin SDK has no atomic idempotency key for creating an issue or comment. This plugin therefore uses an **at-most-once mutation fence**, not an exactly-once delivery claim. Repeated Slack event IDs cannot create repeated issues or comments. A crash after the durable claim but before the native call can leave a command unexecuted; retrying it automatically could duplicate a call whose response was lost.
On worker restart, a `working` event is reconciled by its plugin-owned issue origin or a matching human-authored comment marker. If the outcome cannot be established, it becomes `uncertain` and is not replayed. A caught ambiguous RPC error also becomes `uncertain` immediately. Known task creation followed by a blocked or failed wake remains a recorded task; its response directs the operator to Paperclip. A lost Slack response does not erase a known task result, and the acknowledgement message is not resent automatically.
Instance operators can inspect the board-authenticated endpoint:
```text
GET /api/plugins/<installed-plugin-id>/api/status?companyId=<company-id>
```
It returns connection state and the latest 25 delivery outcomes, including event hashes and known task IDs. It does not return token references or incoming message bodies. Some completed outcome text contains the task title or summary shown in Slack. There is no automatic push alert for `uncertain` events in this increment. If a command gets no response, inspect this endpoint and Paperclip before sending a new command.
While a connection exists, `authenticatedIdentity` exposes only its verified workspace ID, bot ID and bot user ID (or null when unavailable). These are identifiers from the existing `auth.test` response; status inspection makes no additional Slack request and exposes no credentials or response headers. The object is null when the connection is stopped.
The `diagnostics` object counts Events API envelopes received through the SDK's `slack_event` dispatcher, accepted messages, ignored messages and persistence/acknowledgement failures. Its last reason is a fixed category; no event identifiers, message bodies, tokens or provider errors are included. Counters reset on enabled reconfiguration or worker restart. `accepted` means the input passed validation, not that task processing completed; failures can overlap accepted or ignored counts. A connected socket with zero received events after a fresh message means the parser has not received an Events API envelope. Check the Slack app identity and its `message.im` subscription before changing the allowlist. Nonzero ignored counts indicate that the existing message/identity checks rejected an envelope.
For an uncertain creation, search company issues with `originKind=plugin:paperclipai.plugin-slack-control` and `originId=<eventKey>`. For an uncertain reply, inspect the bound issue's comments for `[Slack event <eventKey>]`. Do not blindly resend. After confirming no native mutation committed, an operator may send a new Slack command with a new event ID. There is no automated destructive repair or replay endpoint.
Inbox messages and binding records stay in the Paperclip database. SDK/provider payloads and tokens are not forwarded to logs. The inbox is not automatically pruned: deleting idempotency records could permit old events to execute again. Deleting a company removes its plugin records through foreign keys. Add a reviewed retention strategy before high-volume use.
## Setup (operator actions; not performed by installing this package)
1. Build this package in the Paperclip workspace:
```sh
pnpm --filter @paperclipai/plugin-slack-control typecheck
pnpm --filter @paperclipai/plugin-slack-control test
pnpm --filter @paperclipai/plugin-slack-control build
```
Install it through Paperclip's plugin management using the local package directory. Enable its declared capabilities only after reviewing them. Keep its company configuration disabled until the remaining steps are complete. The workspace registers this package with the Docker dependency stage and the standard test runner. CI resolves its dependencies; do not commit a generated lockfile in a contribution PR.
2. In Slack's app management, create an internal app **from** `slack-app-manifest.json` in your intended workspace. The manifest enables Socket Mode, the Messages tab and only the `message.im` event. Bot scopes are `chat:write`, `im:history`, and `im:read`. Create an app-level token with `connections:write`, then install the app to obtain its bot token. These are separate tokens. No public webhook URL or inbound firewall port is required. See the official [Socket Mode SDK guide](https://docs.slack.dev/tools/node-slack-sdk/socket-mode/) and [app manifest reference](https://docs.slack.dev/reference/app-manifest/).
`pnpm --filter @paperclipai/plugin-slack-control slack:setup-url` prints a ready-to-open app creation link containing this public manifest. It makes no network request and performs no installation. Slack still asks the operator to choose a workspace and review creation, following its [documented manifest sharing flow](https://api.slack.com/reference/manifests).
3. Store the app-level and bot tokens as **company secrets** in Paperclip's secrets UI. Select secret references for this plugin's `appToken` and `botToken`. Never put token strings in plugin configuration, a source-controlled file, a task, a screenshot or a shell command. Optional numeric `version` pins a secret version; omitting it uses the host's current version. Re-save the configuration after rotating a token to reconnect.
4. Copy `config.example.json` to a private location outside the repository. Replace the workspace ID (`T…`), Slack member ID (`U…` or `W…`), active writable Paperclip user ID and existing project/agent IDs. IDs must be explicitly checked in both systems. An instance admin saves company-scoped configuration through Paperclip's UI or its authenticated API:
```text
POST /api/plugins/<installed-plugin-id>/config
{ "companyId": "<company-id>", "configJson": <the configuration object> }
```
Configuration uses secret reference objects only. Saving `enabled: true` authorises the outbound Slack connection and responses to allowed direct messages. The worker checks the bot's authenticated workspace before starting. Never configure this worker for another company; create a separately reviewed deployment for another tenant.
5. Perform the synthetic smoke below before assigning real work. To stop new dispatch, save `enabled: false`. Keep the secret references and mappings for a reversible later restart, or remove them through normal Paperclip administration.
## Synthetic working smoke
After the operator has authorised Slack installation and enabled the configuration:
1. Select a project containing synthetic fixtures and an existing paused agent if a model invocation is undesirable. Set the alias to `demo`.
2. From the one allowed Slack user, DM the app `status`. Confirm its reply and the company-scoped status endpoint. No task or agent invocation should result.
3. Send `new demo: Synthetic Slack control smoke; do not change files or contact anyone`. Confirm exactly one task, its mapped human creator, expected project/agent and a reply on the original Slack thread. A paused agent must remain paused.
4. Reply `Synthetic follow-up; no action needed` within that thread. Confirm one comment by the mapped Paperclip user on that same task. Check normal native wake behaviour separately if the agent is active.
5. Send a message as an unlisted user or in a channel/group DM. Confirm no plugin task, comment or reply. Disable configuration and confirm connection state becomes `disabled`.
The offline suite checks the manifest's required Socket Mode/IM fields against the documented shape, SDK capability and human-attribution contracts, official transport calls with mocked Slack responses, a real local PostgreSQL-compatible database migration, company separation, competing claims, retry and crash fences, persistent close/reopen, thread identity and configuration lifecycle. Recovery tests cover transient and permanent failures, backoff, handshake deadlines, stale events and confirmed shutdown before replacement. Loopback tests use the actual Slack SDK and undici to cancel a pending connection request and close an upgraded WebSocket whose peer ignores close frames. A synthetic child worker also exercises the actual host invocation guard: late configuration callbacks remain rejected, while the setup-created drain uses authorised proactive scope and stops when that scope is revoked. Slack's server-side manifest validation, real app installation, real Slack delivery and a real agent invocation require the operator's credentials and are **not performed by these tests**. PGlite runs only in tests; production storage is the host's namespaced PostgreSQL service.

View File

@ -0,0 +1,8 @@
{
"enabled": false,
"workspaceId": "TEXAMPLE",
"appToken": { "type": "secret_ref", "secretId": "00000000-0000-0000-0000-000000000001" },
"botToken": { "type": "secret_ref", "secretId": "00000000-0000-0000-0000-000000000002" },
"users": [{ "slackUserId": "UEXAMPLE", "boardUserId": "replace-with-an-active-paperclip-user-id" }],
"projects": [{ "alias": "demo", "projectId": "00000000-0000-0000-0000-000000000003", "agentId": "00000000-0000-0000-0000-000000000004" }]
}

View File

@ -0,0 +1,2 @@
import esbuild from "esbuild";
await esbuild.build({ entryPoints: ["src/worker.ts", "src/manifest.ts"], outdir: "dist", bundle: true, platform: "node", format: "esm", target: "node24", packages: "external" });

View File

@ -0,0 +1,21 @@
CREATE TABLE plugin_slack_control_608eeb9089.inbox (
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
event_key text NOT NULL,
config_digest text NOT NULL,
message jsonb NOT NULL,
phase text NOT NULL DEFAULT 'received' CHECK (phase IN ('received', 'working', 'done', 'uncertain')),
issue_id uuid,
outcome text,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (company_id, event_key)
);
CREATE TABLE plugin_slack_control_608eeb9089.threads (
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
workspace_id text NOT NULL,
channel_id text NOT NULL,
thread_ts text NOT NULL,
slack_user_id text NOT NULL,
board_user_id text NOT NULL,
issue_id uuid NOT NULL,
PRIMARY KEY (company_id, workspace_id, channel_id, thread_ts)
);

View File

@ -0,0 +1,23 @@
{
"name": "@paperclipai/plugin-slack-control",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "MIT",
"description": "Explicit Slack direct-message commands for company-scoped Paperclip tasks",
"scripts": {
"build": "node esbuild.config.mjs",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit",
"slack:setup-url": "node scripts/print-slack-app-url.mjs"
},
"paperclipPlugin": { "manifest": "./dist/manifest.js", "worker": "./dist/worker.js" },
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"@slack/socket-mode": "3.0.1",
"@slack/web-api": "8.1.1",
"undici": "7.29.1"
},
"devDependencies": { "@electric-sql/pglite": "0.5.8", "@types/node": "^24.0.0", "esbuild": "^0.28.2", "typescript": "^7.0.2", "vitest": "^4.1.11" },
"engines": { "node": ">=24.11.0" }
}

View File

@ -0,0 +1,6 @@
import { readFile } from "node:fs/promises";
const manifest = JSON.parse(await readFile(new URL("../slack-app-manifest.json", import.meta.url), "utf8"));
const url = new URL("https://api.slack.com/apps");
url.searchParams.set("new_app", "1");
url.searchParams.set("manifest_json", JSON.stringify(manifest));
console.log(url.href);

View File

@ -0,0 +1,9 @@
{
"display_information": { "name": "Paperclip Control", "description": "Control configured Paperclip tasks through private messages." },
"features": {
"bot_user": { "display_name": "Paperclip Control", "always_online": false },
"app_home": { "home_tab_enabled": false, "messages_tab_enabled": true, "messages_tab_read_only_enabled": false }
},
"oauth_config": { "scopes": { "bot": ["chat:write", "im:history", "im:read"] } },
"settings": { "socket_mode_enabled": true, "org_deploy_enabled": false, "token_rotation_enabled": false, "event_subscriptions": { "bot_events": ["message.im"] } }
}

View File

@ -0,0 +1,58 @@
import { createHash } from "node:crypto";
export interface SecretRef { type: "secret_ref"; secretId: string; version?: number }
export interface Config {
enabled: boolean;
workspaceId: string;
appToken: SecretRef;
botToken: SecretRef;
users: { slackUserId: string; boardUserId: string }[];
projects: { alias: string; projectId: string; agentId: string }[];
}
export interface Message { eventId: string; workspaceId: string; userId: string; channelId: string; ts: string; threadTs: string | null; text: string }
export const MAX_TEXT = 4000;
export const uuid = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
function valid(condition: unknown): asserts condition { if (!condition) throw new Error("Invalid Slack control configuration; use the documented IDs and secret references."); }
function secret(value: unknown): SecretRef {
const item = record(value);
valid(item.type === "secret_ref" && uuid(item.secretId) && (item.version === undefined || Number.isSafeInteger(item.version) && Number(item.version) > 0));
valid(Object.keys(item).every((key) => ["type", "secretId", "version"].includes(key)));
return { type: "secret_ref", secretId: item.secretId, ...(item.version === undefined ? {} : { version: Number(item.version) }) };
}
export function parseConfig(value: unknown): Config | null {
const item = record(value);
if (item.enabled !== true) return null;
valid(Object.keys(item).every((key) => ["enabled", "workspaceId", "appToken", "botToken", "users", "projects"].includes(key)));
valid(typeof item.workspaceId === "string" && /^T[A-Z0-9]{2,32}$/.test(item.workspaceId));
valid(Array.isArray(item.users) && item.users.length > 0 && item.users.length <= 10);
valid(Array.isArray(item.projects) && item.projects.length > 0 && item.projects.length <= 10);
const users = item.users.map((entry) => {
const user = record(entry);
valid(Object.keys(user).every((key) => ["slackUserId", "boardUserId"].includes(key)));
valid(typeof user.slackUserId === "string" && /^[UW][A-Z0-9]{2,32}$/.test(user.slackUserId));
valid(typeof user.boardUserId === "string" && user.boardUserId.length > 0 && user.boardUserId.length <= 128);
return { slackUserId: user.slackUserId, boardUserId: user.boardUserId };
});
const projects = item.projects.map((entry) => {
const project = record(entry);
valid(Object.keys(project).every((key) => ["alias", "projectId", "agentId"].includes(key)));
valid(typeof project.alias === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(project.alias) && uuid(project.projectId) && uuid(project.agentId));
return { alias: project.alias, projectId: project.projectId, agentId: project.agentId };
});
valid(new Set(users.map((user) => user.slackUserId)).size === users.length && new Set(projects.map((project) => project.alias)).size === projects.length);
return { enabled: true, workspaceId: item.workspaceId, appToken: secret(item.appToken), botToken: secret(item.botToken), users, projects };
}
export function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
export function parseMessage(body: unknown, config: Config): Message | null {
const envelope = record(body); const event = record(envelope.event);
if (envelope.type !== "event_callback" || envelope.team_id !== config.workspaceId || typeof envelope.event_id !== "string" || !/^Ev[A-Za-z0-9]{1,64}$/.test(envelope.event_id)) return null;
if (event.type !== "message" || event.channel_type !== "im" || event.subtype !== undefined || event.bot_id !== undefined || event.bot_profile !== undefined || event.app_id !== undefined || event.hidden === true) return null;
if (event.user_team !== undefined && event.user_team !== config.workspaceId) return null;
if (typeof event.user !== "string" || !config.users.some((user) => user.slackUserId === event.user)) return null;
if (typeof event.channel !== "string" || !/^D[A-Z0-9]{2,32}$/.test(event.channel)) return null;
if (typeof event.text !== "string" || !event.text.trim() || event.text.length > MAX_TEXT) return null;
if (typeof event.ts !== "string" || !/^\d{10,16}\.\d{6}$/.test(event.ts)) return null;
if (event.thread_ts !== undefined && (typeof event.thread_ts !== "string" || !/^\d{10,16}\.\d{6}$/.test(event.thread_ts))) return null;
return { eventId: envelope.event_id, workspaceId: config.workspaceId, userId: event.user, channelId: event.channel, ts: event.ts, threadTs: typeof event.thread_ts === "string" ? event.thread_ts : null, text: event.text.trim() };
}

View File

@ -0,0 +1,174 @@
import { SocketModeClient, LogLevel, type Logger } from "@slack/socket-mode";
import { WebClient } from "@slack/web-api";
import { Agent, buildConnector, fetch } from "undici";
import type { Socket } from "node:net";
import type { Config } from "./config.js";
import type { Connection, ConnectionStatus } from "./runtime.js";
// SDK logs can contain tokens, URLs and message bodies. Never forward them.
const quiet: Logger = { debug() {}, info() {}, warn() {}, error() {}, setLevel() {}, getLevel: () => LogLevel.ERROR, setName() {} };
type Reason = NonNullable<ConnectionStatus["lastFailure"]>;
type Failure = { reason: Reason; retry: boolean; retryAfterMs?: number };
class ConnectionFailure extends Error {
constructor(readonly reason: Reason) { super(reason); }
}
function classify(error: unknown): Failure {
if (error instanceof ConnectionFailure) return { reason: error.reason, retry: error.reason === "connection_timeout" };
const value = error as { code?: unknown; statusCode?: unknown; retryAfter?: unknown; data?: { error?: unknown } } | null;
if (value?.code === "slack_webapi_request_error") return { reason: "network_error", retry: true };
if (value?.code === "slack_webapi_rate_limited_error" || value?.statusCode === 429) {
const seconds = value.retryAfter;
// Never retry before the provider allows it. Node clamps oversized timers
// to one millisecond, so unsupported waits require operator recovery.
if (typeof seconds === "number" && seconds > 2_147_483_647 / 1000) return { reason: "rate_limited", retry: false };
return { reason: "rate_limited", retry: true, retryAfterMs: typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 60_000 };
}
if (value?.code === "slack_webapi_http_error" && typeof value.statusCode === "number" && value.statusCode >= 500 && value.statusCode < 600) return { reason: "network_error", retry: true };
if (value?.code === "slack_webapi_platform_error") {
if (["not_authed", "invalid_auth", "account_inactive", "user_removed_from_team", "team_disabled", "token_revoked", "token_expired"].includes(String(value.data?.error))) return { reason: "authentication_failed", retry: false };
if (["missing_scope", "not_allowed_token_type"].includes(String(value.data?.error))) return { reason: "permission_denied", retry: false };
if (["service_unavailable", "internal_error", "fatal_error"].includes(String(value.data?.error))) return { reason: "network_error", retry: true };
}
return { reason: "provider_error", retry: false };
}
async function deadline<T>(work: Promise<T>, ms: number, reason: Reason): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
try {
return await Promise.race([work, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new ConnectionFailure(reason)), ms); timer.unref();
})]);
} finally { clearTimeout(timer!); }
}
/** One owner for initial connection and reconnects; all network work is cancellable. */
export function createSlackConnection(config: Config, appToken: string, botToken: string, warn: (message: string) => void): Connection {
let stopped = false;
let task: Promise<void> | undefined;
let finishStop!: () => void;
const stopping = new Promise<void>((resolve) => { finishStop = resolve; });
let status: ConnectionStatus = { state: "connecting", lastFailure: null, retryAt: null };
let identity: Connection["authenticatedIdentity"];
let activeWeb: WebClient | null = null;
let cleanupFailed = false;
async function run(receive: Parameters<Connection["start"]>[0]) {
let failures = 0;
while (!stopped) {
let live = true;
let socket: SocketModeClient | undefined;
let opening: Promise<void> = Promise.resolve();
const sockets = new Set<Socket>();
const connect = buildConnector({});
const dispatcher = new Agent({ connect(options, callback) {
connect(options, (error, raw) => {
if (!raw) { callback(error, null); return; }
if (!live || stopped) { raw.destroy(); callback(new Error("Slack connection stopped"), null); return; }
sockets.add(raw); raw.once("close", () => sockets.delete(raw)); callback(null, raw);
});
} });
let failure: Failure | null = null;
let disconnected!: (failure: Failure) => void;
const ended = new Promise<Failure>((resolve) => { disconnected = resolve; });
status = { state: "connecting", lastFailure: status.lastFailure, retryAt: null };
identity = undefined;
try {
const web = new WebClient(botToken, { logger: quiet, retryConfig: { retries: 0 }, rejectRateLimitedCalls: true, timeout: 10_000,
// WebClient uses DOM FormData types; undici accepts that same runtime body.
fetch: (url, init) => fetch(url, { ...init, dispatcher } as Parameters<typeof fetch>[1]) });
socket = new SocketModeClient({ appToken, logger: quiet, logLevel: LogLevel.ERROR, dispatcher, autoReconnectEnabled: false,
clientOptions: { retryConfig: { retries: 0 }, rejectRateLimitedCalls: true, timeout: 10_000 } });
const current = () => live && !stopped;
socket.on("connected", () => {
if (current()) { activeWeb = web; status = { state: "connected", lastFailure: null, retryAt: null }; }
});
const lost = () => {
if (!current()) return;
activeWeb = null; status = { state: "connecting", lastFailure: "connection_lost", retryAt: null };
disconnected({ reason: "connection_lost", retry: true });
};
socket.on("disconnected", lost);
socket.on("error", lost);
socket.on("slack_event", ({ type, body, ack }: { type: string; body: unknown; ack: () => Promise<void> }) => {
if (!current() || status.state !== "connected") return;
// The SDK emits slack_event, not events_api. Filter its envelope here.
if (type !== "events_api") {
void ack().catch(() => warn("Unsupported Slack event acknowledgement failed."));
} else {
void receive(body, ack).catch(() => warn("Slack event was not acknowledged; a provider retry may follow."));
}
});
opening = (async () => {
const auth = await web.auth.test();
if (!current()) return;
if (auth.team_id !== config.workspaceId || !auth.bot_id) throw new ConnectionFailure("workspace_mismatch");
identity = { workspaceId: auth.team_id, botId: auth.bot_id,
botUserId: typeof auth.user_id === "string" && /^[UW][A-Z0-9]{2,32}$/.test(auth.user_id) ? auth.user_id : null };
await socket!.start();
})();
failure = await Promise.race([deadline(opening, 30_000, "connection_timeout").then(() => null, classify), ended, stopping.then(() => null)]);
if (!failure && !stopped) {
failures = 0;
failure = await Promise.race([ended, stopping.then(() => null)]);
}
} catch (error) { failure = classify(error); }
finally {
live = false; activeWeb = null;
if (!stopped) status = { state: "connecting", lastFailure: failure?.reason ?? null, retryAt: null };
try {
await deadline((async () => {
// disconnect() alone cannot abort start() awaiting apps.connections.open.
// Destroy the public shared dispatcher, then wait for startup to settle
// and close again in case it installed a websocket during cancellation.
const disconnecting = socket?.disconnect();
// Upgraded WebSockets detach from the Agent pool. As in the SDK's
// default connector, own the raw sockets so a stalled peer is closed.
for (const raw of sockets) raw.destroy();
await Promise.all([disconnecting, dispatcher.destroy(), opening.catch(() => {})]);
await socket?.disconnect();
})(), 5_000, "cleanup_failed");
} catch {
cleanupFailed = true; failure = { reason: "cleanup_failed", retry: false };
status = { state: "error", lastFailure: "cleanup_failed", retryAt: null };
}
}
if (stopped) break;
if (!failure?.retry) {
status = { state: "error", lastFailure: failure?.reason ?? "provider_error", retryAt: null };
break;
}
const delay = Math.max(Math.min(1000 * 2 ** Math.min(failures++, 6), 60_000), failure.retryAfterMs ?? 0);
status = { state: "connecting", lastFailure: failure.reason, retryAt: Date.now() + delay };
let timer: ReturnType<typeof setTimeout>;
try { await Promise.race([stopping, new Promise<void>((resolve) => { timer = setTimeout(resolve, delay); timer.unref(); })]); }
finally { clearTimeout(timer!); }
}
}
function readyWeb() {
if (stopped || status.state !== "connected" || !activeWeb) throw new ConnectionFailure("connection_lost");
return activeWeb;
}
return {
get authenticatedIdentity() { return identity; },
connectionStatus: () => ({ ...status }),
isConnected: () => !stopped && status.state === "connected",
async start(receive) {
if (stopped || task) throw new Error("Slack connection already started or stopped");
// Do not hold the host's configuration invocation open during an outage.
task = run(receive).catch(() => { status = { state: "error", lastFailure: "provider_error", retryAt: null }; });
},
async stop() {
stopped = true; finishStop(); await task; identity = undefined;
if (cleanupFailed) throw new ConnectionFailure("cleanup_failed");
},
async verifyDirectMessage(message) {
const result = await readyWeb().conversations.info({ channel: message.channelId });
const channel = result.channel;
return channel?.is_im === true && channel.is_mpim !== true && "user" in channel && channel.user === message.userId;
},
async reply(message, text) {
const plain = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
await readyWeb().chat.postMessage({ channel: message.channelId, thread_ts: message.threadTs ?? message.ts, text: plain,
unfurl_links: false, unfurl_media: false, parse: "none", mrkdwn: false });
},
};
}

View File

@ -0,0 +1,105 @@
import type { PluginContext } from "@paperclipai/plugin-sdk";
import { fingerprint, type Config, type Message } from "./config.js";
import type { Entry, Store } from "./store.js";
export const ORIGIN = "plugin:paperclipai.plugin-slack-control" as const;
export interface Transport {
verifyDirectMessage(message: Message): Promise<boolean>;
reply(message: Message, text: string): Promise<void>;
}
const help = "Use status, or new <project alias>: <brief>. Reply in a task's Slack thread to add a follow-up. Approvals remain in Paperclip.";
export function createControl(ctx: PluginContext, companyId: string, config: Config, store: Store, transport: Transport, current: () => boolean) {
const configDigest = fingerprint(config);
let draining = false;
async function identity(message: Message): Promise<string> {
if (!current() || message.workspaceId !== config.workspaceId) throw new Error("Configuration changed");
const mapping = config.users.find((user) => user.slackUserId === message.userId);
const members = await ctx.access.members.list({ companyId });
if (!mapping || !members.some((member) => member.companyId === companyId && member.principalType === "user" && member.principalId === mapping.boardUserId && member.status === "active" && ["owner", "admin", "operator", "member"].includes(member.membershipRole ?? ""))) throw new Error("Unverified company operator");
if (!await transport.verifyDirectMessage(message)) throw new Error("Not an authorised direct message");
if (!current()) throw new Error("Configuration changed");
return mapping.boardUserId;
}
async function finish(entry: Entry, text: string, issueId?: string) {
// Persist completion before sending: an ambiguous Slack send is never replayed.
await store.finish(entry.eventKey, "done", text.slice(0, 500), issueId);
if (current()) {
try { await transport.reply(entry.message, text.slice(0, 3500)); }
catch { ctx.logger.warn("Slack acknowledgement could not be confirmed; task delivery remains recorded."); }
}
}
async function process(entry: Entry, fresh: boolean) {
const message = entry.message;
if (entry.configDigest !== configDigest) { await store.finish(entry.eventKey, "uncertain", "Configuration changed; operator review required."); return; }
const userId = await identity(message);
const binding = await store.binding(message);
if (binding && (binding.slackUserId !== message.userId || binding.boardUserId !== userId)) throw new Error("Thread identity mismatch");
const isReply = message.threadTs !== null && message.threadTs !== message.ts;
if (binding && isReply) {
const issue = await ctx.issues.get(binding.issueId, companyId);
if (!issue || !config.projects.some((project) => project.projectId === issue.projectId)) throw new Error("Task no longer in configured scope");
if (message.text.toLowerCase() === "status") { await finish(entry, `${issue.identifier ?? issue.id}: ${issue.status}${issue.title}`, issue.id); return; }
const marker = `[Slack event ${entry.eventKey}]`;
if (!fresh) {
const comments = await ctx.issues.listComments(issue.id, companyId);
if (comments.some((comment) => comment.authorUserId === userId && comment.body.endsWith(marker))) await finish(entry, "Follow-up recorded in Paperclip.", issue.id);
else await store.finish(entry.eventKey, "uncertain", "Comment outcome unknown; review before sending a new command.", issue.id);
return;
}
if (!current()) throw new Error("Configuration changed");
await ctx.issues.createComment(issue.id, `${message.text}\n\n${marker}`, companyId, { actorUserId: userId });
await finish(entry, "Follow-up recorded in Paperclip. Its normal assignment and approval rules apply.", issue.id);
return;
}
if (isReply) { await finish(entry, "This thread is not bound to a task. Start a new direct message with new <project alias>: <brief>."); return; }
if (message.text.toLowerCase() === "status") {
const lines: string[] = [];
for (const project of config.projects) {
const issues = await ctx.issues.list({ companyId, projectId: project.projectId, limit: 3 });
lines.push(`${project.alias}: ${issues.length ? issues.map((issue) => `${issue.identifier ?? issue.id} ${issue.status}`).join(", ") : "no tasks"}`);
}
await finish(entry, lines.join("\n")); return;
}
const command = /^new ([a-z][a-z0-9-]{0,31}):\s*([\s\S]+)$/.exec(message.text);
if (!command) { await finish(entry, help); return; }
const project = config.projects.find((item) => item.alias === command[1]);
if (!project) { await finish(entry, `Unknown project alias. Available: ${config.projects.map((item) => item.alias).join(", ")}.`); return; }
const [existingProject, agent] = await Promise.all([ctx.projects.get(project.projectId, companyId), ctx.agents.get(project.agentId, companyId)]);
if (!existingProject || !agent || agent.status === "terminated" || agent.status === "pending_approval") throw new Error("Configured task destination is unavailable");
const existing = await ctx.issues.list({ companyId, originKind: ORIGIN, originId: entry.eventKey, limit: 2 });
if (existing.length > 1) throw new Error("Ambiguous task origin");
let issue = existing[0];
if (issue && (issue.companyId !== companyId || issue.projectId !== project.projectId || issue.createdByUserId !== userId)) throw new Error("Task origin scope mismatch");
if (!issue && !fresh) { await store.finish(entry.eventKey, "uncertain", "Task creation outcome unknown; review the origin before sending a new command."); return; }
if (!issue) {
if (!current()) throw new Error("Configuration changed");
issue = await ctx.issues.create({ companyId, projectId: project.projectId, assigneeAgentId: project.agentId,
title: command[2]!.trim().slice(0, 120), description: command[2]!.trim(), status: "todo", priority: "medium",
originKind: ORIGIN, originId: entry.eventKey, actor: { actorUserId: userId },
});
}
await store.bind(message, { slackUserId: message.userId, boardUserId: userId, issueId: issue.id });
let wakeNotice = "";
if (current()) {
try { await ctx.issues.requestWakeup(issue.id, companyId, { actorUserId: userId, idempotencyKey: entry.eventKey, reason: "slack_control", contextSource: "slack_control" }); }
catch { wakeNotice = "\nThe task was recorded, but its wake was not confirmed. Check assignment, limits and approvals in Paperclip."; }
}
await finish(entry, `${issue.identifier ?? issue.id}: ${issue.title}\nReply in this Slack thread to follow up. Approvals remain in Paperclip.${wakeNotice}`, issue.id);
}
return {
async enqueue(message: Message) { await store.enqueue(message, configDigest); },
async drain() {
if (draining || !current()) return;
draining = true;
try {
for (const entry of await store.pending()) {
if (!current()) break;
const fresh = entry.phase === "received";
if (fresh && !await store.claim(entry.eventKey)) continue;
try { await process(entry, fresh); }
catch { await store.finish(entry.eventKey, "uncertain", "Delivery requires operator review; the command was not replayed."); }
}
} finally { draining = false; }
},
};
}

View File

@ -0,0 +1,36 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const secret = {
type: "object", additionalProperties: false, required: ["type", "secretId"],
properties: { type: { const: "secret_ref" }, secretId: { type: "string", format: "uuid" }, version: { type: "integer", minimum: 1 } },
};
const manifest: PaperclipPluginManifestV1 = {
id: "paperclipai.plugin-slack-control", apiVersion: 1, version: "0.1.0",
displayName: "Slack Control", description: "Explicit private-message commands for configured Paperclip projects.",
author: "Community", categories: ["automation", "connector"],
capabilities: [
"api.routes.register", "database.namespace.migrate", "database.namespace.read", "database.namespace.write",
"access.members.read", "projects.read", "agents.read", "issues.read", "issues.create", "issues.wakeup",
"issue.comments.read", "issue.comments.create", "issue.comments.create_human_attributed", "secrets.read-ref", "http.outbound",
],
entrypoints: { worker: "./dist/worker.js" },
database: { namespaceSlug: "slack_control", migrationsDir: "migrations", coreReadTables: ["companies"] },
apiRoutes: [{ routeKey: "status", method: "GET", path: "/status", auth: "board", capability: "api.routes.register", companyResolution: { from: "query", key: "companyId" } }],
instanceConfigSchema: {
type: "object", additionalProperties: false,
properties: {
enabled: { type: "boolean", default: false },
workspaceId: { type: "string", pattern: "^T[A-Z0-9]{2,32}$" }, appToken: secret, botToken: secret,
users: { type: "array", minItems: 1, maxItems: 10, items: {
type: "object", additionalProperties: false, required: ["slackUserId", "boardUserId"],
properties: { slackUserId: { type: "string", pattern: "^[UW][A-Z0-9]{2,32}$" }, boardUserId: { type: "string", minLength: 1, maxLength: 128 } },
} },
projects: { type: "array", minItems: 1, maxItems: 10, items: {
type: "object", additionalProperties: false, required: ["alias", "projectId", "agentId"],
properties: { alias: { type: "string", pattern: "^[a-z][a-z0-9-]{0,31}$" }, projectId: { type: "string", format: "uuid" }, agentId: { type: "string", format: "uuid" } },
} },
},
allOf: [{ if: { properties: { enabled: { const: true } }, required: ["enabled"] }, then: { required: ["workspaceId", "appToken", "botToken", "users", "projects"] } }],
},
};
export default manifest;

View File

@ -0,0 +1,114 @@
import type { PluginContext } from "@paperclipai/plugin-sdk";
import { parseConfig, parseMessage, uuid, type Config, type Message } from "./config.js";
import { createControl, type Transport } from "./control.js";
import { createStore, type Store } from "./store.js";
export interface Connection extends Transport {
readonly authenticatedIdentity?: { workspaceId: string; botUserId: string | null; botId: string };
connectionStatus?(): ConnectionStatus;
isConnected(): boolean;
start(receive: (body: unknown, ack: () => Promise<void>) => Promise<void>): Promise<void>;
stop(): Promise<void>;
}
export interface ConnectionStatus {
state: "connecting" | "connected" | "error";
lastFailure: "network_error" | "rate_limited" | "authentication_failed" | "permission_denied" | "workspace_mismatch" | "provider_error" | "connection_timeout" | "connection_lost" | "cleanup_failed" | null;
retryAt: number | null;
}
export type Connect = (config: Config, companyId: string) => Promise<Connection>;
export interface TransportDiagnostics {
received: number;
accepted: number;
ignored: number;
failed: number;
lastReason: "accepted" | "unsupported_or_untrusted_event" | "delivery_failed" | null;
}
const emptyDiagnostics = (): TransportDiagnostics => ({ received: 0, accepted: 0, ignored: 0, failed: 0, lastReason: null });
/** Persist before acknowledgement; never hold Slack's acknowledgement open for agent work. */
export async function receiveEvent(body: unknown, ack: () => Promise<void>, config: Config, enqueue: (message: Message) => Promise<void>, diagnostics?: TransportDiagnostics) {
const message = parseMessage(body, config);
if (diagnostics) {
diagnostics.received++;
diagnostics[message ? "accepted" : "ignored"]++;
diagnostics.lastReason = message ? "accepted" : "unsupported_or_untrusted_event";
}
try {
if (message) await enqueue(message);
await ack();
} catch (error) {
if (diagnostics) { diagnostics.failed++; diagnostics.lastReason = "delivery_failed"; }
throw error;
}
}
export function createRuntime(ctx: PluginContext, connect: Connect) {
let generation = 0;
let configuredCompany: string | null = null;
let connection: Connection | null = null;
let store: Store | null = null;
let activeControl: ReturnType<typeof createControl> | null = null;
let state: "disabled" | "connecting" | "connected" | "error" = "disabled";
let diagnostics = emptyDiagnostics();
let changing: Promise<void> = Promise.resolve();
// createRuntime runs synchronously in setup, outside configChanged's company
// invocation. Its timer therefore uses the host-authorised proactive company
// scope instead of inheriting an invocation that expires when config returns.
const timer = setInterval(() => {
if (connection?.isConnected()) void activeControl?.drain().catch(() => ctx.logger.warn("Slack inbox could not be processed; inspect plugin status."));
}, 10_000);
timer.unref();
const health = () => state === "connected" ? connection?.connectionStatus?.().state ?? (connection?.isConnected() ? "connected" : "connecting") : state;
async function stop() {
activeControl = null;
// Keep a failed shutdown attached: replacing an unconfirmed live connection
// could consume another socket's events. Recovery then needs a worker restart.
if (connection) await connection.stop();
connection = null;
}
return {
configure(value: unknown, companyId: string | null) {
const version = ++generation;
changing = changing.catch(() => {}).then(async () => {
await stop();
state = "disabled";
if (version !== generation) return;
const config = parseConfig(value);
if (!config) return;
if (!uuid(companyId) || (configuredCompany && configuredCompany !== companyId)) throw new Error("Slack Control requires one company-scoped configuration.");
configuredCompany = companyId;
diagnostics = emptyDiagnostics();
store = createStore(ctx.db, companyId);
state = "connecting";
const next = await connect(config, companyId);
if (version !== generation) { await next.stop(); return; }
connection = next;
const current = () => generation === version && connection === next;
const control = createControl(ctx, companyId, config, store, next, () => current() && next.isConnected());
await next.start(async (body, ack) => {
if (!current()) return; // Leave old-connection events unacknowledged for redelivery.
await receiveEvent(body, ack, config, control.enqueue, diagnostics);
});
if (!current()) { await next.stop(); return; }
activeControl = control;
state = "connected";
}).catch(async () => {
state = "error";
try { await stop(); } catch { ctx.logger.warn("Slack connection shutdown could not be confirmed."); }
ctx.logger.error("Slack Control configuration or connection failed; inspect secret references and workspace mapping.");
throw new Error("Slack Control could not connect. No credential or provider response is included in diagnostics.");
});
return changing;
},
async status(companyId: string) {
if (!uuid(companyId) || (configuredCompany && configuredCompany !== companyId)) throw new Error("Company scope mismatch");
const identity = connection?.authenticatedIdentity;
return { state: health(), connection: connection?.connectionStatus?.() ?? null, authenticatedIdentity: identity ? {
workspaceId: identity.workspaceId, botUserId: identity.botUserId, botId: identity.botId,
} : null, diagnostics: { ...diagnostics }, recent: store ? await store.recent() : [] };
},
health,
async shutdown() { ++generation; clearInterval(timer); await changing.catch(() => {}); await stop(); state = "disabled"; },
};
}

View File

@ -0,0 +1,42 @@
import type { PluginDatabaseClient } from "@paperclipai/plugin-sdk";
import { fingerprint, uuid, type Message } from "./config.js";
export interface Entry { eventKey: string; configDigest: string; message: Message; phase: "received" | "working" | "done" | "uncertain"; issueId: string | null; outcome: string | null }
export interface Binding { slackUserId: string; boardUserId: string; issueId: string }
export interface Store {
enqueue(message: Message, configDigest: string): Promise<void>;
pending(): Promise<Entry[]>;
recent(): Promise<Pick<Entry, "eventKey" | "phase" | "issueId" | "outcome">[]>;
claim(eventKey: string): Promise<boolean>;
finish(eventKey: string, phase: "done" | "uncertain", outcome: string, issueId?: string): Promise<void>;
binding(message: Message): Promise<Binding | null>;
bind(message: Message, binding: Binding): Promise<void>;
}
export function eventKey(message: Message): string { return fingerprint([message.workspaceId, message.eventId]); }
export function createStore(db: PluginDatabaseClient, companyId: string): Store {
if (!uuid(companyId) || !/^plugin_[a-z0-9_]+$/.test(db.namespace)) throw new Error("Invalid plugin storage scope");
const inbox = `${db.namespace}.inbox`; const threads = `${db.namespace}.threads`;
return {
async enqueue(message, configDigest) {
await db.execute(`INSERT INTO ${inbox} (company_id, event_key, config_digest, message) VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (company_id, event_key) DO NOTHING`, [companyId, eventKey(message), configDigest, JSON.stringify(message)]);
},
pending: () => db.query<Entry>(`SELECT event_key AS "eventKey", config_digest AS "configDigest", message, phase, issue_id AS "issueId", outcome FROM ${inbox} WHERE company_id = $1 AND phase IN ('received', 'working') ORDER BY updated_at LIMIT 25`, [companyId]),
recent: () => db.query(`SELECT event_key AS "eventKey", phase, issue_id AS "issueId", outcome FROM ${inbox} WHERE company_id = $1 ORDER BY updated_at DESC LIMIT 25`, [companyId]),
async claim(key) {
const result = await db.execute(`UPDATE ${inbox} SET phase = 'working', updated_at = now() WHERE company_id = $1 AND event_key = $2 AND phase = 'received'`, [companyId, key]);
return result.rowCount === 1;
},
async finish(key, phase, outcome, issueId) {
await db.execute(`UPDATE ${inbox} SET phase = $3, outcome = $4, issue_id = $5, updated_at = now() WHERE company_id = $1 AND event_key = $2`, [companyId, key, phase, outcome, issueId ?? null]);
},
async binding(message) {
const rows = await db.query<Binding>(`SELECT slack_user_id AS "slackUserId", board_user_id AS "boardUserId", issue_id AS "issueId" FROM ${threads} WHERE company_id = $1 AND workspace_id = $2 AND channel_id = $3 AND thread_ts = $4`, [companyId, message.workspaceId, message.channelId, message.threadTs ?? message.ts]);
return rows[0] ?? null;
},
async bind(message, binding) {
await db.execute(`INSERT INTO ${threads} (company_id, workspace_id, channel_id, thread_ts, slack_user_id, board_user_id, issue_id) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (company_id, workspace_id, channel_id, thread_ts) DO NOTHING`, [companyId, message.workspaceId, message.channelId, message.threadTs ?? message.ts, binding.slackUserId, binding.boardUserId, binding.issueId]);
const current = await this.binding(message);
if (!current || current.issueId !== binding.issueId || current.slackUserId !== binding.slackUserId || current.boardUserId !== binding.boardUserId) throw new Error("Thread binding conflict");
},
};
}

View File

@ -0,0 +1,37 @@
import { definePlugin, runWorker, type PluginContext } from "@paperclipai/plugin-sdk";
import { createSlackConnection } from "./connection.js";
import { parseConfig } from "./config.js";
import { createRuntime, type Connect } from "./runtime.js";
export function slackConnection(ctx: PluginContext): Connect {
return async (config, companyId) => {
const [appToken, botToken] = await Promise.all([
ctx.secrets.resolve(config.appToken, { companyId, configPath: "appToken" }),
ctx.secrets.resolve(config.botToken, { companyId, configPath: "botToken" }),
]);
return createSlackConnection(config, appToken, botToken, (message) => ctx.logger.warn(message));
};
}
let runtime: ReturnType<typeof createRuntime>;
const plugin = definePlugin({
async setup(ctx) { runtime = createRuntime(ctx, slackConnection(ctx)); },
async onConfigChanged(config, scope) { await runtime.configure(config, scope?.companyId ?? null); },
async onValidateConfig(config) {
try { parseConfig(config); return { ok: true }; }
catch { return { ok: false, errors: ["Use the documented workspace, user/project mappings and company secret references."] }; }
},
async onApiRequest(input) {
if (input.routeKey !== "status") return { status: 404, body: { error: "Unknown route" } };
if (input.actor.actorType !== "user" || !input.companyId) return { status: 403, body: { error: "An authenticated company operator is required." } };
try { return { body: await runtime.status(input.companyId) }; }
catch { return { status: 403, body: { error: "Company scope mismatch" } }; }
},
async onHealth() {
const state = runtime.health();
return { status: state === "error" ? "error" : state === "connecting" ? "degraded" : "ok", message: `Slack Control: ${state}` };
},
async onShutdown() { await runtime.shutdown(); },
});
export default plugin;
runWorker(plugin, import.meta.url);

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { parseConfig, parseMessage } from "../src/config.js";
import { config, envelope } from "./helpers.js";
describe("explicit identity and input boundary", () => {
it("starts disabled and accepts secret references only", () => {
expect(parseConfig({})).toBeNull();
expect(parseConfig(config)).toEqual(config);
expect(() => parseConfig({ ...config, appToken: "xapp-private" })).toThrow();
expect(() => parseConfig({ ...config, botToken: { type: "plain", value: "private" } })).toThrow();
expect(() => parseConfig({ ...config, users: [...config.users, ...config.users] })).toThrow();
expect(() => parseConfig({ ...config, projects: [{ ...config.projects[0], alias: "../../shell" }] })).toThrow();
expect(() => parseConfig({ ...config, users: [{ ...config.users[0], actorAgentId: "spoofed" }] })).toThrow();
});
it("accepts a configured human's direct message", () => {
expect(parseMessage(envelope(), config)?.userId).toBe("UTEST");
});
it.each([
{ channel_type: "channel" }, { channel_type: "mpim" }, { channel: "CTEST" },
{ subtype: "message_changed" }, { subtype: "message_deleted" }, { subtype: "bot_message" },
{ bot_id: "BTEST" }, { bot_profile: {} }, { app_id: "ATEST" }, { user: "UOTHER" },
{ user_team: "TOTHER" }, { hidden: true }, { text: "x".repeat(4001) }, { text: " " },
{ ts: "bad" }, { thread_ts: "bad" },
])("rejects unsupported or untrusted event %j", (patch) => {
expect(parseMessage(envelope(patch), config)).toBeNull();
});
it("rejects a different workspace or envelope kind", () => {
expect(parseMessage({ ...envelope(), team_id: "TOTHER" }, config)).toBeNull();
expect(parseMessage({ ...envelope(), type: "url_verification" }, config)).toBeNull();
expect(parseMessage({ ...envelope(), event_id: "invalid" }, config)).toBeNull();
});
});

View File

@ -0,0 +1,92 @@
import { createHash } from "node:crypto";
import { createServer, type Server, type ServerResponse } from "node:http";
import type { Socket } from "node:net";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Connection } from "../src/runtime.js";
import { config } from "./helpers.js";
const endpoint = vi.hoisted(() => ({ url: "" }));
// Use the actual Slack clients and undici transport against a loopback peer.
// Only the API base URL changes; no real credentials or Slack requests are used.
vi.mock("@slack/web-api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@slack/web-api")>();
return { ...actual, WebClient: class extends actual.WebClient {
constructor(token: string, options: import("@slack/web-api").WebClientOptions) { super(token, { ...options, slackApiUrl: endpoint.url }); }
} };
});
vi.mock("@slack/socket-mode", async (importOriginal) => {
const actual = await importOriginal<typeof import("@slack/socket-mode")>();
return { ...actual, SocketModeClient: class extends actual.SocketModeClient {
constructor(options: import("@slack/socket-mode").SocketModeOptions) { super({ ...options, clientOptions: { ...options.clientOptions, slackApiUrl: endpoint.url } }); }
} };
});
import { createSlackConnection } from "../src/connection.js";
let server: Server;
let connection: Connection;
let holdOpen = false;
let pending: ServerResponse | undefined;
let websocket: Socket | undefined;
let hello = true;
let opens = 0;
const sockets = new Set<Socket>();
beforeEach(async () => {
holdOpen = false; pending = undefined; websocket = undefined; hello = true; opens = 0;
server = createServer((request, response) => {
request.resume();
response.setHeader("content-type", "application/json");
if (request.url === "/auth.test") response.end(JSON.stringify({ ok: true, team_id: config.workspaceId, bot_id: "BBOT", user_id: "UBOT" }));
else if (request.url === "/apps.connections.open") {
opens++;
if (holdOpen) pending = response;
else response.end(JSON.stringify({ ok: true, url: endpoint.url.replace("http:", "ws:") + "socket" }));
} else { response.statusCode = 404; response.end("{}"); }
});
server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
server.on("upgrade", (request, socket) => {
websocket = socket as Socket;
const accept = createHash("sha1").update(String(request.headers["sec-websocket-key"]) + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
if (hello) { const body = Buffer.from('{"type":"hello"}'); socket.write(Buffer.concat([Buffer.from([0x81, body.length]), body])); }
// Deliberately ignore ping and close frames: teardown must destroy the socket.
socket.on("data", () => {});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing loopback address");
endpoint.url = `http://127.0.0.1:${address.port}/`;
connection = createSlackConnection(config, "xapp-synthetic", "xoxb-synthetic", vi.fn());
});
afterEach(async () => {
await connection.stop().catch(() => {});
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it("cancels actual apps.connections.open before a late response can open a socket", async () => {
holdOpen = true;
await connection.start(vi.fn());
await vi.waitFor(() => expect(pending).toBeDefined());
await connection.stop();
await vi.waitFor(() => expect(pending!.destroyed).toBe(true));
pending!.end(JSON.stringify({ ok: true, url: endpoint.url.replace("http:", "ws:") + "socket" }));
expect(connection.isConnected()).toBe(false); expect(websocket).toBeUndefined(); expect(opens).toBe(1);
}, 10_000);
it("destroys an upgraded real WebSocket even when the peer ignores its close frame", async () => {
await connection.start(vi.fn());
await vi.waitFor(() => expect(connection.isConnected()).toBe(true));
expect(websocket).toBeDefined();
await connection.stop();
// The HTTP server's upgraded peer is half-open; EOF proves the client's raw
// socket closed even though this deliberately uncooperative peer stays open.
await vi.waitFor(() => expect(websocket!.readableEnded).toBe(true));
expect(connection.isConnected()).toBe(false); expect(opens).toBe(1);
}, 10_000);
it("cancels the real SDK hello wait and closes the upgraded socket on shutdown", async () => {
hello = false;
await connection.start(vi.fn());
await vi.waitFor(() => expect(websocket).toBeDefined());
expect(connection.isConnected()).toBe(false);
await connection.stop();
await vi.waitFor(() => expect(websocket!.readableEnded).toBe(true));
expect(opens).toBe(1);
}, 10_000);

View File

@ -0,0 +1,133 @@
import { PGlite } from "@electric-sql/pglite";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createControl, ORIGIN } from "../src/control.js";
import { fingerprint } from "../src/config.js";
import { createStore, eventKey } from "../src/store.js";
import { company, config, database, host, initialise, issueId, message, namespace, otherCompany } from "./helpers.js";
const pg = new PGlite();
beforeAll(() => initialise(pg));
afterAll(() => pg.close());
beforeEach(() => pg.exec(`TRUNCATE ${namespace}.inbox, ${namespace}.threads`));
function fixture() {
const store = createStore(database(pg), company);
const { ctx, api, issue } = host(database(pg));
const transport = { verifyDirectMessage: vi.fn().mockResolvedValue(true), reply: vi.fn().mockResolvedValue(undefined) };
const current = vi.fn(() => true);
return { store, api, issue, transport, current, control: createControl(ctx, company, config, store, transport, current) };
}
describe("durable task dispatch", () => {
it("creates once across concurrent delivery and retries, attributes the human, binds and wakes natively", async () => {
const f = fixture();
await Promise.all([f.control.enqueue(message), f.control.enqueue(message)]);
await Promise.all([f.control.drain(), f.control.drain()]);
await f.control.enqueue({ ...message, text: "new demo: Changed retry payload" });
await f.control.drain();
expect(f.api.issues.create).toHaveBeenCalledTimes(1);
expect(f.api.issues.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: company, actor: { actorUserId: "human" }, originKind: ORIGIN, originId: eventKey(message), description: "Synthetic task" }));
expect(f.api.issues.requestWakeup).toHaveBeenCalledWith(issueId, company, expect.objectContaining({ actorUserId: "human", idempotencyKey: eventKey(message) }));
expect(await f.store.binding(message)).toEqual({ slackUserId: "UTEST", boardUserId: "human", issueId });
expect((await f.store.recent())[0]?.phase).toBe("done");
});
it("does not retry an ambiguous native creation", async () => {
const f = fixture();
f.api.issues.create.mockRejectedValue(new Error("RPC response lost after commit"));
await f.control.enqueue(message); await f.control.drain(); await f.control.enqueue(message); await f.control.drain();
expect(f.api.issues.create).toHaveBeenCalledTimes(1);
expect((await f.store.recent())[0]?.phase).toBe("uncertain");
expect(f.transport.reply).not.toHaveBeenCalled();
});
it("recovers a crashed working event by its native origin without creating again", async () => {
const f = fixture(); await f.control.enqueue(message); await f.store.claim(eventKey(message));
f.api.issues.list.mockResolvedValue([f.issue]);
await f.control.drain();
expect(f.api.issues.create).not.toHaveBeenCalled();
expect(await f.store.binding(message)).toMatchObject({ issueId });
expect((await f.store.recent())[0]?.phase).toBe("done");
});
it("stops after a crash before creation if the outcome cannot be established", async () => {
const f = fixture(); await f.control.enqueue(message); await f.store.claim(eventKey(message)); await f.control.drain();
expect(f.api.issues.create).not.toHaveBeenCalled();
expect((await f.store.recent())[0]?.phase).toBe("uncertain");
});
it("retains a known completed task when the Slack reply fails", async () => {
const f = fixture(); f.transport.reply.mockRejectedValue(new Error("Slack response lost"));
await f.control.enqueue(message); await f.control.drain(); await f.control.drain();
expect((await f.store.recent())[0]).toMatchObject({ phase: "done", issueId });
expect(f.api.issues.create).toHaveBeenCalledTimes(1);
expect(f.transport.reply).toHaveBeenCalledTimes(1);
});
it("keeps a created task when native wake is blocked, without changing approvals or budgets", async () => {
const f = fixture(); f.api.issues.requestWakeup.mockRejectedValue(new Error("Budget blocks invocation"));
await f.control.enqueue(message); await f.control.drain();
expect((await f.store.recent())[0]).toMatchObject({ phase: "done", issueId });
expect(f.transport.reply).toHaveBeenCalledWith(message, expect.stringContaining("wake was not confirmed"));
});
it("does not dispatch for a read-only viewer", async () => {
const f = fixture(); f.api.access.members.list.mockResolvedValue([{ companyId: company, principalType: "user", principalId: "human", status: "active", membershipRole: "viewer" }]);
await f.control.enqueue(message); await f.control.drain(); expect(f.api.issues.create).not.toHaveBeenCalled();
});
it.each([[], [{ companyId: otherCompany, principalType: "user", principalId: "human", status: "active" }], [{ companyId: company, principalType: "agent", principalId: "human", status: "active" }], [{ companyId: company, principalType: "user", principalId: "human", status: "inactive" }]].map((members) => ({ members })))("rejects missing, cross-company or non-human membership", async ({ members }) => {
const f = fixture(); f.api.access.members.list.mockResolvedValue(members);
await f.control.enqueue(message); await f.control.drain();
expect(f.api.issues.create).not.toHaveBeenCalled(); expect(f.transport.reply).not.toHaveBeenCalled();
expect((await f.store.recent())[0]?.phase).toBe("uncertain");
});
it("revalidates the DM with Slack, failing closed", async () => {
const f = fixture(); f.transport.verifyDirectMessage.mockResolvedValue(false);
await f.control.enqueue(message); await f.control.drain();
expect(f.api.issues.create).not.toHaveBeenCalled(); expect(f.transport.reply).not.toHaveBeenCalled();
});
it("does not execute queued events after configuration changes", async () => {
const f = fixture(); await f.store.enqueue(message, fingerprint({ ...config, enabled: false })); await f.control.drain();
expect(f.api.issues.create).not.toHaveBeenCalled(); expect((await f.store.recent())[0]?.phase).toBe("uncertain");
});
it("status and unknown commands never create or wake an agent", async () => {
const f = fixture(); await f.control.enqueue({ ...message, text: "status" }); await f.control.drain();
await f.control.enqueue({ ...message, eventId: "Ev002", text: "run shell https://example.org" }); await f.control.drain();
expect(f.api.issues.create).not.toHaveBeenCalled(); expect(f.api.issues.requestWakeup).not.toHaveBeenCalled();
expect(f.transport.reply).toHaveBeenCalledTimes(2);
});
it("rejects an origin belonging to another company, project or author", async () => {
const f = fixture(); f.api.issues.list.mockResolvedValue([{ ...f.issue, companyId: otherCompany }]);
await f.control.enqueue(message); await f.control.drain();
expect(f.api.issues.create).not.toHaveBeenCalled(); expect(f.api.issues.requestWakeup).not.toHaveBeenCalled();
});
});
describe("thread follow-ups", () => {
const reply = { ...message, eventId: "EvReply", ts: "1780000000.000002", threadTs: message.ts, text: "Use synthetic data only" };
it("keeps replies on the bound task with native human attribution", async () => {
const f = fixture(); await f.store.bind(message, { slackUserId: message.userId, boardUserId: "human", issueId });
await f.control.enqueue(reply); await f.control.drain(); await f.control.enqueue(reply); await f.control.drain();
expect(f.api.issues.createComment).toHaveBeenCalledTimes(1);
expect(f.api.issues.createComment).toHaveBeenCalledWith(issueId, `Use synthetic data only\n\n[Slack event ${eventKey(reply)}]`, company, { actorUserId: "human" });
expect(f.api.issues.create).not.toHaveBeenCalled();
});
it("recovers a committed comment after restart without adding another", async () => {
const f = fixture(); await f.store.bind(message, { slackUserId: message.userId, boardUserId: "human", issueId });
await f.control.enqueue(reply); await f.store.claim(eventKey(reply));
f.api.issues.listComments.mockResolvedValue([{ authorUserId: "human", body: `text\n[Slack event ${eventKey(reply)}]` }]);
await f.control.drain(); expect(f.api.issues.createComment).not.toHaveBeenCalled(); expect((await f.store.recent())[0]?.phase).toBe("done");
});
it("never retries a comment with an unknown outcome", async () => {
const f = fixture(); await f.store.bind(message, { slackUserId: message.userId, boardUserId: "human", issueId });
await f.control.enqueue(reply); await f.store.claim(eventKey(reply)); await f.control.drain();
expect(f.api.issues.createComment).not.toHaveBeenCalled(); expect((await f.store.recent())[0]?.phase).toBe("uncertain");
});
it("cannot use a different Slack channel or unbound thread to target a task", async () => {
const f = fixture(); await f.store.bind(message, { slackUserId: message.userId, boardUserId: "human", issueId });
await f.control.enqueue({ ...reply, channelId: "DOTHER" }); await f.control.drain();
expect(f.api.issues.createComment).not.toHaveBeenCalled(); expect(f.api.issues.create).not.toHaveBeenCalled();
});
it("rejects a changed board-user mapping", async () => {
const f = fixture(); await f.store.bind(message, { slackUserId: message.userId, boardUserId: "previous-human", issueId });
await f.control.enqueue(reply); await f.control.drain();
expect(f.api.issues.createComment).not.toHaveBeenCalled(); expect(f.transport.reply).not.toHaveBeenCalled();
});
it("rejects a task moved outside the configured project", async () => {
const f = fixture(); await f.store.bind(message, { slackUserId: message.userId, boardUserId: "human", issueId });
f.api.issues.get.mockResolvedValue({ ...f.issue, projectId: otherCompany });
await f.control.enqueue(reply); await f.control.drain();
expect(f.api.issues.createComment).not.toHaveBeenCalled(); expect(f.transport.reply).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,35 @@
import { definePlugin, runWorker, type PluginContext } from "@paperclipai/plugin-sdk";
import { createRuntime } from "../../src/runtime.js";
let runtime: ReturnType<typeof createRuntime>;
let release: (() => void) | undefined;
let body: unknown;
let expiredInvocationCode: unknown = null;
let replies = 0;
const plugin = definePlugin({
async setup(ctx: PluginContext) {
runtime = createRuntime(ctx, async (_config, companyId) => ({
isConnected: () => true,
async start(receive) {
const pending = new Promise<void>((resolve) => { release = resolve; });
// Like Socket Mode, this continuation inherits configChanged's context
// but executes only after that host invocation has returned.
void pending.then(async () => {
try { await ctx.access.members.list({ companyId }); }
catch (error) { expiredInvocationCode = (error as { code?: unknown }).code; }
await receive(body, async () => {});
});
},
async stop() {},
async verifyDirectMessage() { return true; },
async reply() { replies++; },
}));
},
async onConfigChanged(config, scope) { await runtime.configure(config, scope?.companyId ?? null); },
async onApiRequest(input) {
if (input.routeKey === "release") { body = input.body; release?.(); return { body: {} }; }
return { body: { ...await runtime.status(input.companyId!), expiredInvocationCode, replies } };
},
async onShutdown() { await runtime.shutdown(); },
});
runWorker(plugin, import.meta.url);

View File

@ -0,0 +1,40 @@
import { readFile } from "node:fs/promises";
import { PGlite } from "@electric-sql/pglite";
import type { PluginContext, PluginDatabaseClient } from "@paperclipai/plugin-sdk";
import { vi } from "vitest";
import type { Config, Message } from "../src/config.js";
export const company = "00000000-0000-0000-0000-000000000001";
export const otherCompany = "00000000-0000-0000-0000-000000000002";
export const projectId = "00000000-0000-0000-0000-000000000003";
export const agentId = "00000000-0000-0000-0000-000000000004";
export const issueId = "00000000-0000-0000-0000-000000000005";
export const namespace = "plugin_slack_control_608eeb9089";
export const config: Config = { enabled: true, workspaceId: "TTEST", appToken: { type: "secret_ref", secretId: projectId }, botToken: { type: "secret_ref", secretId: agentId }, users: [{ slackUserId: "UTEST", boardUserId: "human" }], projects: [{ alias: "demo", projectId, agentId }] };
export const message: Message = { eventId: "Ev001", workspaceId: config.workspaceId, userId: "UTEST", channelId: "DTEST", ts: "1780000000.000001", threadTs: null, text: "new demo: Synthetic task" };
export function envelope(patch: Record<string, unknown> = {}) {
return { type: "event_callback", event_id: message.eventId, team_id: message.workspaceId,
event: { type: "message", channel_type: "im", user: message.userId, channel: message.channelId, ts: message.ts, text: message.text, ...patch } };
}
export function database(pg: PGlite): PluginDatabaseClient {
return { namespace,
async query<T>(sql: string, params?: unknown[]) { return (await pg.query(sql, params)).rows as T[]; },
async execute(sql, params) { return { rowCount: (await pg.query(sql, params)).affectedRows ?? 0 }; },
};
}
export async function initialise(pg: PGlite) {
await pg.exec(`CREATE TABLE public.companies (id uuid PRIMARY KEY); INSERT INTO public.companies VALUES ('${company}'), ('${otherCompany}'); CREATE SCHEMA ${namespace}`);
await pg.exec(await readFile(new URL("../migrations/001_inbox.sql", import.meta.url), "utf8"));
}
export function host(db: PluginDatabaseClient) {
const issue = { id: issueId, companyId: company, projectId, assigneeAgentId: agentId, createdByUserId: "human", identifier: "DEMO-1", status: "todo", title: "Synthetic task" };
const api = {
db, logger: { warn: vi.fn(), error: vi.fn() },
access: { members: { list: vi.fn().mockResolvedValue([{ companyId: company, principalType: "user", principalId: "human", status: "active", membershipRole: "owner" }]) } },
projects: { get: vi.fn().mockResolvedValue({ id: projectId, companyId: company }) },
agents: { get: vi.fn().mockResolvedValue({ id: agentId, companyId: company, status: "idle" }) },
issues: { list: vi.fn().mockResolvedValue([]), get: vi.fn().mockResolvedValue(issue), create: vi.fn().mockResolvedValue(issue),
createComment: vi.fn().mockResolvedValue({ id: "comment" }), listComments: vi.fn().mockResolvedValue([]), requestWakeup: vi.fn().mockResolvedValue({ status: "queued" }) },
};
return { api, ctx: api as unknown as PluginContext, issue };
}

View File

@ -0,0 +1,88 @@
import { mkdtemp, rm } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { createRequire } from "node:module";
import { PGlite } from "@electric-sql/pglite";
import { build } from "esbuild";
import { createHostClientHandlers, PLUGIN_RPC_ERROR_CODES, type HostServices, type HostToWorkerMethods } from "@paperclipai/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import manifest from "../src/manifest.js";
import { company, config, database, envelope, initialise, namespace } from "./helpers.js";
import { createPluginWorkerHandle } from "../../../../server/src/services/plugin-worker-manager.js";
const tsxLoader = createRequire(new URL("../../../../server/package.json", import.meta.url)).resolve("tsx");
vi.mock("../../../../server/src/middleware/logger.js", () => {
const logger = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn(), child: () => logger };
return { logger, httpLogger: vi.fn() };
});
describe("Slack delivery across the native host invocation lifetime", () => {
it("drains late socket events with proactive scope and respects revocation after reconfiguration", async () => {
const root = await mkdtemp(fileURLToPath(new URL("../.invocation-test-", import.meta.url)));
const pg = new PGlite();
const db = database(pg);
const listMembers = vi.fn(async () => [{ companyId: company, principalType: "user", principalId: "human", status: "active", membershipRole: "owner" }]);
const listIssues = vi.fn(async () => []);
const handlers = createHostClientHandlers({
pluginId: manifest.id, capabilities: manifest.capabilities,
services: {
db: {
query: ({ sql, params }: { sql: string; params?: unknown[] }) => db.query(sql, params),
execute: ({ sql, params }: { sql: string; params?: unknown[] }) => db.execute(sql, params),
},
access: { listMembers }, issues: { list: listIssues }, logger: { log: async () => {} },
} as unknown as HostServices,
});
const entrypointPath = join(root, "worker.mjs");
const handle = createPluginWorkerHandle(manifest.id, {
entrypointPath, manifest, config: {}, databaseNamespace: namespace, apiVersion: 1,
execArgv: ["--import", tsxLoader],
instanceInfo: { instanceId: "synthetic-instance", hostVersion: "1.0.0" },
hostHandlers: handlers, proactiveCompanyScopes: [company],
});
const request = (routeKey: string, body?: unknown) => handle.call("handleApiRequest", {
routeKey, companyId: company, body, actor: { actorType: "user", actorId: "human" },
} as HostToWorkerMethods["handleApiRequest"][0]);
try {
await initialise(pg);
await build({ entryPoints: [fileURLToPath(new URL("./fixtures/invocation-worker.ts", import.meta.url))], outfile: entrypointPath,
bundle: true, platform: "node", format: "esm", target: "node24", packages: "external" });
await handle.start();
await handle.call("configChanged", { config: { ...config }, companyId: company });
await request("release", envelope({ text: "status" }));
await vi.waitFor(async () => {
const result = await request("status");
expect(result.body).toMatchObject({ expiredInvocationCode: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
replies: 1, recent: [{ phase: "done" }], diagnostics: { received: 1, accepted: 1 } });
}, { timeout: 15_000, interval: 100 });
expect(listMembers).toHaveBeenCalledTimes(1);
expect(listIssues).toHaveBeenCalledTimes(1);
// Reconfiguration must not replace the setup-created timer with one
// carrying a new, equally short-lived config invocation.
await handle.call("configChanged", { config: { ...config }, companyId: company });
await request("release", { ...envelope({ text: "status" }), event_id: "EvReconfigured" });
await vi.waitFor(async () => {
const result = await request("status");
expect(result.body).toMatchObject({ replies: 2, recent: [{ phase: "done" }, { phase: "done" }] });
}, { timeout: 15_000, interval: 100 });
expect(listMembers).toHaveBeenCalledTimes(2);
expect(listIssues).toHaveBeenCalledTimes(2);
await handle.call("configChanged", { config: { ...config }, companyId: company });
handle.setProactiveCompanyScopes([]);
await request("release", { ...envelope({ text: "status" }), event_id: "EvRevoked" });
await vi.waitFor(async () => {
const result = await request("status");
expect(result.body).toMatchObject({ replies: 2, recent: [{ phase: "uncertain" }, { phase: "done" }, { phase: "done" }] });
}, { timeout: 15_000, interval: 100 });
expect(listMembers).toHaveBeenCalledTimes(2);
expect(listIssues).toHaveBeenCalledTimes(2);
} finally {
await handle.stop().catch(() => {});
await pg.close();
await rm(root, { recursive: true, force: true });
}
}, 45_000);
});

View File

@ -0,0 +1,27 @@
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import manifest from "../src/manifest.js";
import { namespace } from "./helpers.js";
describe("installable manifest contracts", () => {
it("keeps Slack offline until configured, with IM-only events and minimal scopes", async () => {
const slack = JSON.parse(await readFile(new URL("../slack-app-manifest.json", import.meta.url), "utf8"));
expect(slack.display_information.name.length).toBeLessThanOrEqual(35);
expect(slack.settings.socket_mode_enabled).toBe(true);
expect(slack.settings.event_subscriptions).toEqual({ bot_events: ["message.im"] });
expect(slack.oauth_config.scopes.bot).toEqual(["chat:write", "im:history", "im:read"]);
expect(slack.features.app_home.messages_tab_read_only_enabled).toBe(false);
expect(JSON.stringify(slack)).not.toMatch(/xapp-|xoxb-|https:\/\/|hireable|joe@|support@/i);
});
it("uses a company-scoped board-only diagnostics route and the derived SQL namespace", async () => {
expect(manifest.apiRoutes?.[0]).toMatchObject({ auth: "board", method: "GET", companyResolution: { from: "query", key: "companyId" } });
const derived = `plugin_slack_control_${createHash("sha256").update(manifest.id).digest("hex").slice(0, 10)}`;
expect(derived).toBe(namespace);
expect(manifest.database?.coreReadTables).toEqual(["companies"]);
const migration = await readFile(new URL("../migrations/001_inbox.sql", import.meta.url), "utf8");
expect(migration).toContain(`CREATE TABLE ${derived}.inbox`);
expect(migration).toContain("PRIMARY KEY (company_id, event_key)");
expect(migration).toContain("PRIMARY KEY (company_id, workspace_id, channel_id, thread_ts)");
});
});

View File

@ -0,0 +1,147 @@
import { PGlite } from "@electric-sql/pglite";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntime, receiveEvent, type Connection, type TransportDiagnostics } from "../src/runtime.js";
import { company, config, database, envelope, host, initialise, namespace, otherCompany } from "./helpers.js";
const pg = new PGlite();
beforeAll(() => initialise(pg)); afterAll(() => pg.close());
beforeEach(() => pg.exec(`TRUNCATE ${namespace}.inbox, ${namespace}.threads`));
describe("acknowledgement and connection lifecycle", () => {
it("counts received, accepted, ignored and failed delivery without recording payloads or identities", async () => {
const diagnostics: TransportDiagnostics = { received: 0, accepted: 0, ignored: 0, failed: 0, lastReason: null };
const ack = vi.fn().mockResolvedValue(undefined); const enqueue = vi.fn().mockResolvedValue(undefined);
await receiveEvent(envelope(), ack, config, enqueue, diagnostics);
expect(diagnostics).toEqual({ received: 1, accepted: 1, ignored: 0, failed: 0, lastReason: "accepted" });
await receiveEvent(envelope({ user: "U_UNLISTED_PRIVATE" }), ack, config, enqueue, diagnostics);
expect(diagnostics).toEqual({ received: 2, accepted: 1, ignored: 1, failed: 0, lastReason: "unsupported_or_untrusted_event" });
enqueue.mockRejectedValueOnce(new Error("synthetic-private-provider-payload"));
await expect(receiveEvent(envelope(), ack, config, enqueue, diagnostics)).rejects.toThrow();
expect(diagnostics).toEqual({ received: 3, accepted: 2, ignored: 1, failed: 1, lastReason: "delivery_failed" });
expect(ack).toHaveBeenCalledTimes(2);
expect(JSON.stringify(diagnostics)).not.toMatch(/UTEST|TTEST|DTEST|Ev001|private|Synthetic task/);
});
it("persists before acknowledging and never waits for task work", async () => {
const calls: string[] = [];
await receiveEvent(envelope(), async () => { calls.push("ack"); }, config, async () => { calls.push("persist"); });
expect(calls).toEqual(["persist", "ack"]);
});
it("does not acknowledge failed persistence; rejects unsupported events without persisting", async () => {
const ack = vi.fn(); const enqueue = vi.fn().mockRejectedValue(new Error("DB unavailable"));
await expect(receiveEvent(envelope(), ack, config, enqueue)).rejects.toThrow();
expect(ack).not.toHaveBeenCalled();
enqueue.mockClear();
await receiveEvent(envelope({ subtype: "bot_message" }), ack, config, enqueue);
expect(ack).toHaveBeenCalledTimes(1); expect(enqueue).not.toHaveBeenCalled();
});
it("stays disconnected by default, closes before replacing, and prevents another company's configuration", async () => {
const connections: Connection[] = [];
const connect = vi.fn(async () => {
const connection: Connection = { isConnected: () => true, start: vi.fn(), stop: vi.fn(), verifyDirectMessage: vi.fn().mockResolvedValue(true), reply: vi.fn() };
connections.push(connection); return connection;
});
const { ctx } = host(database(pg)); const runtime = createRuntime(ctx, connect);
try {
await runtime.configure({}, company); expect(connect).not.toHaveBeenCalled();
await runtime.configure(config, company); expect(runtime.health()).toBe("connected");
await runtime.configure({ ...config, enabled: false }, company);
expect(connections[0]?.stop).toHaveBeenCalledTimes(1); expect(runtime.health()).toBe("disabled");
await expect(runtime.status(otherCompany)).rejects.toThrow("Company scope mismatch");
await expect(runtime.configure(config, otherCompany)).rejects.toThrow();
expect(connect).toHaveBeenCalledTimes(1);
} finally { await runtime.shutdown(); }
});
it("requires host company scope and reports connection failures without provider secrets", async () => {
const { ctx, api } = host(database(pg));
const connect = vi.fn().mockRejectedValue(new Error("xapp-private-provider-payload"));
const runtime = createRuntime(ctx, connect);
await expect(runtime.configure(config, null)).rejects.toThrow(); expect(connect).not.toHaveBeenCalled();
await expect(runtime.configure(config, company)).rejects.toThrow("No credential or provider response");
expect(runtime.health()).toBe("error");
expect(JSON.stringify(api.logger.error.mock.calls)).not.toContain("xapp-private");
await runtime.shutdown();
});
it("does not dispatch from a replaced connection", async () => {
let receive: ((body: unknown, ack: () => Promise<void>) => Promise<void>) | undefined;
const connection: Connection = { isConnected: () => true, start: vi.fn(async (handler) => { receive = handler; }), stop: vi.fn(), verifyDirectMessage: vi.fn(), reply: vi.fn() };
const { ctx, api } = host(database(pg)); const runtime = createRuntime(ctx, async () => connection);
await runtime.configure(config, company); await runtime.configure({ enabled: false }, company);
const ack = vi.fn(); await receive!(envelope(), ack);
expect(ack).not.toHaveBeenCalled(); expect(api.issues.create).not.toHaveBeenCalled();
await runtime.shutdown();
});
it("exposes isolated counter snapshots and resets them for the next configuration", async () => {
let receive!: (body: unknown, ack: () => Promise<void>) => Promise<void>;
const connection: Connection = { isConnected: () => true, start: vi.fn(async (handler) => { receive = handler; }), stop: vi.fn(), verifyDirectMessage: vi.fn(), reply: vi.fn() };
const { ctx } = host(database(pg)); const runtime = createRuntime(ctx, async () => connection);
try {
await runtime.configure(config, company);
await receive(envelope({ user: "UOTHER" }), vi.fn());
const first = await runtime.status(company);
expect(first.diagnostics).toMatchObject({ received: 1, ignored: 1, lastReason: "unsupported_or_untrusted_event" });
first.diagnostics.received = 999;
expect((await runtime.status(company)).diagnostics.received).toBe(1);
await runtime.configure(config, company);
expect((await runtime.status(company)).diagnostics).toEqual({ received: 0, accepted: 0, ignored: 0, failed: 0, lastReason: null });
} finally { await runtime.shutdown(); }
});
it("returns only authenticated bot metadata within the configured company", async () => {
const authenticatedIdentity = { workspaceId: "TTEST", botUserId: "UBOT", botId: "BBOT", token: "synthetic-private-token" };
const connection: Connection = { authenticatedIdentity, isConnected: () => true, start: vi.fn(), stop: vi.fn(), verifyDirectMessage: vi.fn(), reply: vi.fn() };
const { ctx } = host(database(pg)); const runtime = createRuntime(ctx, async () => connection);
try {
expect((await runtime.status(company)).authenticatedIdentity).toBeNull();
await runtime.configure(config, company);
const status = await runtime.status(company);
expect(status.authenticatedIdentity).toEqual({ workspaceId: "TTEST", botUserId: "UBOT", botId: "BBOT" });
expect(JSON.stringify(status.authenticatedIdentity)).not.toContain("private");
await expect(runtime.status(otherCompany)).rejects.toThrow("Company scope mismatch");
await runtime.configure({ enabled: false }, company);
expect((await runtime.status(company)).authenticatedIdentity).toBeNull();
} finally { await runtime.shutdown(); }
});
it("keeps received commands queued while offline, then rechecks company membership after recovery", async () => {
vi.useFakeTimers();
let connected = false;
let receive!: (body: unknown, ack: () => Promise<void>) => Promise<void>;
const connection: Connection = { isConnected: () => connected, start: vi.fn(async (handler) => { receive = handler; }), stop: vi.fn(), verifyDirectMessage: vi.fn().mockResolvedValue(true), reply: vi.fn() };
const { ctx, api } = host(database(pg)); const runtime = createRuntime(ctx, async () => connection);
try {
await runtime.configure(config, company);
await receive(envelope({ text: "status" }), vi.fn());
await vi.advanceTimersByTimeAsync(20_000);
expect((await runtime.status(company)).recent[0]?.phase).toBe("received");
expect(api.access.members.list).not.toHaveBeenCalled();
// Recovery does not bypass revocation that happened during the outage.
api.access.members.list.mockResolvedValue([]); connected = true;
await vi.advanceTimersByTimeAsync(10_000);
await vi.waitFor(async () => expect((await runtime.status(company)).recent[0]?.phase).toBe("uncertain"));
expect(api.access.members.list).toHaveBeenCalledWith({ companyId: company });
expect(api.issues.list).not.toHaveBeenCalled(); expect(connection.reply).not.toHaveBeenCalled();
} finally { await runtime.shutdown(); vi.useRealTimers(); }
});
it("awaits confirmed shutdown before replacement and refuses replacement if cleanup fails", async () => {
let release!: () => void;
const connection: Connection = { isConnected: () => false, start: vi.fn(), stop: vi.fn(() => new Promise<void>((resolve) => { release = resolve; })), verifyDirectMessage: vi.fn(), reply: vi.fn() };
const connect = vi.fn(async () => connection);
const { ctx } = host(database(pg)); const runtime = createRuntime(ctx, connect);
await runtime.configure(config, company);
const replacing = runtime.configure(config, company);
await vi.waitFor(() => expect(connection.stop).toHaveBeenCalledTimes(1));
expect(connect).toHaveBeenCalledTimes(1);
release(); await replacing; expect(connect).toHaveBeenCalledTimes(2);
vi.mocked(connection.stop).mockRejectedValue(new Error("synthetic-private-cleanup-error"));
await expect(runtime.configure(config, company)).rejects.toThrow("No credential or provider response");
expect(connect).toHaveBeenCalledTimes(2); expect(runtime.health()).toBe("error");
vi.mocked(connection.stop).mockResolvedValue(undefined); await runtime.shutdown();
});
it("reports terminal connection failures to board status and health", async () => {
const connection: Connection = { connectionStatus: () => ({ state: "error", lastFailure: "authentication_failed", retryAt: null }), isConnected: () => false, start: vi.fn(), stop: vi.fn(), verifyDirectMessage: vi.fn(), reply: vi.fn() };
const { ctx } = host(database(pg)); const runtime = createRuntime(ctx, async () => connection);
try {
await runtime.configure(config, company);
expect(runtime.health()).toBe("error");
expect((await runtime.status(company)).connection).toEqual({ state: "error", lastFailure: "authentication_failed", retryAt: null });
await expect(runtime.status(otherCompany)).rejects.toThrow("Company scope mismatch");
} finally { await runtime.shutdown(); }
});
});

View File

@ -0,0 +1,29 @@
import { PGlite } from "@electric-sql/pglite";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import manifest from "../src/manifest.js";
import { createControl } from "../src/control.js";
import { createStore } from "../src/store.js";
import { company, config, database, host, initialise, issueId, message } from "./helpers.js";
const pg = new PGlite();
beforeAll(() => initialise(pg)); afterAll(() => pg.close());
describe("native SDK capability and attribution contract", () => {
it("passes the shipped manifest through native human-comment and company-member gates", async () => {
const harness = createTestHarness({ manifest });
const { issue } = host(database(pg));
harness.seed({ issues: [issue as never], accessMembers: [{ id: "membership", companyId: company, principalType: "user", principalId: "human", status: "active", membershipRole: "operator", grants: [], createdAt: new Date(), updatedAt: new Date() }] });
const store = createStore(database(pg), company);
await store.bind(message, { slackUserId: message.userId, boardUserId: "human", issueId });
const transport = { verifyDirectMessage: vi.fn().mockResolvedValue(true), reply: vi.fn() };
const control = createControl(harness.ctx, company, config, store, transport, () => true);
await control.enqueue({ ...message, eventId: "EvSdk", threadTs: message.ts, ts: "1780000000.000009", text: "Synthetic follow-up" });
await control.drain();
expect((await store.recent())[0]?.phase).toBe("done");
const comments = await harness.ctx.issues.listComments(issueId, company);
expect(comments).toHaveLength(1);
expect(comments[0]).toMatchObject({ authorType: "user", authorUserId: "human", authorAgentId: null });
harness.seed({ accessMembers: [{ id: "membership", companyId: company, principalType: "user", principalId: "human", status: "active", membershipRole: "viewer", grants: [], createdAt: new Date(), updatedAt: new Date() }] });
await expect(harness.ctx.issues.createComment(issueId, "forbidden", company, { actorUserId: "human" })).rejects.toThrow("viewer");
});
});

View File

@ -0,0 +1,41 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PGlite } from "@electric-sql/pglite";
import { describe, expect, it } from "vitest";
import { createStore, eventKey } from "../src/store.js";
import { company, database, initialise, issueId, message, otherCompany } from "./helpers.js";
describe("PostgreSQL persistence", () => {
it("retains the mutation fence and thread binding across database close/reopen, isolating companies", async () => {
const dir = await mkdtemp(join(tmpdir(), "paperclip-slack-control-test-"));
let pg = new PGlite(dir);
try {
await initialise(pg);
const first = createStore(database(pg), company);
await first.enqueue(message, "config");
expect(await Promise.all([first.claim(eventKey(message)), first.claim(eventKey(message))])).toEqual([true, false]);
await first.bind(message, { slackUserId: message.userId, boardUserId: "human", issueId });
await pg.close(); pg = new PGlite(dir);
const reopened = createStore(database(pg), company);
const other = createStore(database(pg), otherCompany);
expect((await reopened.pending())[0]).toMatchObject({ phase: "working", message });
expect(await reopened.claim(eventKey(message))).toBe(false);
expect(await reopened.binding(message)).toMatchObject({ issueId });
expect(await other.pending()).toEqual([]); expect(await other.binding(message)).toBeNull();
await other.enqueue(message, "another company");
await other.finish(eventKey(message), "done", "Unrelated completion");
expect((await reopened.pending())[0]?.phase).toBe("working");
await expect(reopened.bind(message, { slackUserId: message.userId, boardUserId: "another-human", issueId })).rejects.toThrow("Thread binding conflict");
await reopened.enqueue({ ...message, text: "changed retry" }, "changed config");
expect((await reopened.pending())[0]?.message.text).toBe(message.text);
await pg.query("DELETE FROM public.companies WHERE id = $1", [company]);
expect(await reopened.pending()).toEqual([]); expect(await reopened.binding(message)).toBeNull();
expect(await other.recent()).toHaveLength(1);
} finally { await pg.close(); await rm(dir, { recursive: true, force: true }); }
});
it("rejects invalid storage namespaces and company IDs before constructing SQL", () => {
expect(() => createStore({ namespace: "public" } as never, company)).toThrow();
expect(() => createStore({ namespace: "plugin_safe" } as never, "bad")).toThrow();
});
});

View File

@ -0,0 +1,222 @@
import type { PluginContext } from "@paperclipai/plugin-sdk";
import type { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Connection } from "../src/runtime.js";
import { company, config, envelope, message } from "./helpers.js";
type TestSocket = EventEmitter & { options: Record<string, unknown> };
const mocks = vi.hoisted(() => ({
auth: vi.fn(), info: vi.fn(), post: vi.fn(), start: vi.fn(), stop: vi.fn(), destroy: vi.fn(), web: vi.fn(),
sockets: [] as TestSocket[], agents: [] as Array<{ destroy(): Promise<void> }>,
}));
vi.mock("undici", () => ({ fetch: vi.fn(), buildConnector: () => vi.fn(), Agent: class {
constructor() { mocks.agents.push(this); }
destroy() { return mocks.destroy(this); }
} }));
vi.mock("@slack/web-api", () => ({ WebClient: class {
constructor(...args: unknown[]) { mocks.web(...args); }
auth = { test: mocks.auth }; conversations = { info: mocks.info }; chat = { postMessage: mocks.post };
} }));
vi.mock("@slack/socket-mode", async () => {
const { EventEmitter } = await import("node:events");
return { LogLevel: { ERROR: "error" }, SocketModeClient: class extends EventEmitter {
constructor(readonly options: Record<string, unknown>) { super(); mocks.sockets.push(this); }
start() { return mocks.start(this); }
disconnect() { return mocks.stop(this); }
} };
});
import { slackConnection } from "../src/worker.js";
const connections: Connection[] = [];
const networkError = { code: "slack_webapi_request_error", message: "synthetic-private-provider-payload" };
const flush = () => vi.advanceTimersByTimeAsync(0);
beforeEach(() => {
vi.useFakeTimers(); vi.resetAllMocks(); mocks.sockets.length = 0; mocks.agents.length = 0;
mocks.auth.mockResolvedValue({ team_id: config.workspaceId, bot_id: "BBOT" });
mocks.info.mockResolvedValue({ channel: { is_im: true, user: message.userId } });
mocks.start.mockImplementation(async (socket: TestSocket) => { socket.emit("connected"); });
mocks.stop.mockImplementation(async (socket: TestSocket) => { socket.emit("disconnected"); });
mocks.destroy.mockResolvedValue(undefined);
});
afterEach(async () => {
await Promise.all(connections.splice(0).map((connection) => connection.stop().catch(() => {})));
expect(vi.getTimerCount()).toBe(0); vi.useRealTimers();
});
function context() {
const api = { secrets: { resolve: vi.fn().mockResolvedValueOnce("xapp-synthetic").mockResolvedValueOnce("xoxb-synthetic") }, logger: { warn: vi.fn(), error: vi.fn() } };
return { api, ctx: api as unknown as PluginContext };
}
async function create(ctx = context().ctx) {
const connection = await slackConnection(ctx)(config, company); connections.push(connection); return connection;
}
async function start(connection: Connection, receive = vi.fn().mockResolvedValue(undefined)) { await connection.start(receive); await flush(); return receive; }
describe("official Slack transport boundary", () => {
it("receives an Events API envelope through the actual Socket Mode SDK dispatcher", async () => {
const actual = await vi.importActual<typeof import("@slack/socket-mode")>("@slack/socket-mode");
const connection = await create();
const receive = vi.fn(async (_body: unknown, ack: () => Promise<void>) => { await ack(); });
await start(connection, receive);
const send = vi.fn().mockResolvedValue(undefined);
const sdk = Object.assign(Object.create(actual.SocketModeClient.prototype), {
logger: { debug() {}, getLevel: () => actual.LogLevel.ERROR }, send,
emit(name: string, payload: unknown) { mocks.sockets[0]!.emit(name, payload); },
}) as { onWebSocketMessage(data: string, isBinary: boolean): Promise<void> };
await sdk.onWebSocketMessage(JSON.stringify({
type: "events_api", envelope_id: "synthetic-envelope", accepts_response_payload: false,
payload: envelope(), retry_attempt: 0,
}), false);
expect(receive).toHaveBeenCalledExactlyOnceWith(envelope(), expect.any(Function));
expect(send).toHaveBeenCalledExactlyOnceWith("synthetic-envelope", undefined);
});
it("resolves only company-bound references and gives reconnect ownership to one supervisor", async () => {
const { ctx, api } = context(); const connection = await create(ctx);
expect(api.secrets.resolve).toHaveBeenNthCalledWith(1, config.appToken, { companyId: company, configPath: "appToken" });
expect(api.secrets.resolve).toHaveBeenNthCalledWith(2, config.botToken, { companyId: company, configPath: "botToken" });
expect(connection.isConnected()).toBe(false); await start(connection);
expect(mocks.web).toHaveBeenCalledWith("xoxb-synthetic", expect.objectContaining({ retryConfig: { retries: 0 }, rejectRateLimitedCalls: true }));
expect(mocks.sockets[0]!.options).toMatchObject({ autoReconnectEnabled: false, dispatcher: mocks.agents[0], clientOptions: { retryConfig: { retries: 0 } } });
await connection.stop(); expect(mocks.destroy).toHaveBeenCalledTimes(1);
expect(connection.isConnected()).toBe(false);
});
it("exposes only bot identity metadata from the existing authentication check", async () => {
mocks.auth.mockResolvedValue({ team_id: config.workspaceId, bot_id: "BBOT", user_id: "UBOT", token: "synthetic-private-token", response_metadata: { headers: "synthetic-private-headers" } });
const connection = await create(); await start(connection);
expect(connection.authenticatedIdentity).toEqual({ workspaceId: config.workspaceId, botId: "BBOT", botUserId: "UBOT" });
expect(mocks.auth).toHaveBeenCalledTimes(1);
expect(JSON.stringify(connection.authenticatedIdentity)).not.toContain("private");
});
it("acknowledges non-Events-API envelopes without dispatching commands", async () => {
const connection = await create(); const receive = await start(connection); const ack = vi.fn().mockResolvedValue(undefined);
mocks.sockets[0]!.emit("slack_event", { type: "interactive", body: envelope(), ack });
expect(receive).not.toHaveBeenCalled(); expect(ack).toHaveBeenCalledOnce();
});
it.each([{ team_id: "TOTHER", bot_id: "BBOT" }, { team_id: config.workspaceId }])("rejects another workspace or a user token: %j", async (auth) => {
mocks.auth.mockResolvedValue(auth); const connection = await create(); await start(connection);
expect(connection.connectionStatus!()).toEqual({ state: "error", lastFailure: "workspace_mismatch", retryAt: null });
await vi.advanceTimersByTimeAsync(120_000);
expect(mocks.start).not.toHaveBeenCalled(); expect(mocks.auth).toHaveBeenCalledTimes(1);
});
it("requires an actual one-to-one conversation with the mapped user, including after recovery", async () => {
const connection = await create(); await start(connection);
expect(await connection.verifyDirectMessage(message)).toBe(true);
mocks.sockets[0]!.emit("disconnected"); await flush();
await expect(connection.verifyDirectMessage(message)).rejects.toThrow("connection_lost");
await vi.advanceTimersByTimeAsync(1000);
for (const channel of [{ is_im: false, user: message.userId }, { is_im: true, is_mpim: true, user: message.userId }, { is_im: true, user: "UOTHER" }, { is_im: true }]) {
mocks.info.mockResolvedValue({ channel }); expect(await connection.verifyDirectMessage(message)).toBe(false);
}
});
it("replies only to the source IM/thread with mentions and URL unfurling disabled", async () => {
const connection = await create(); await start(connection);
await connection.reply(message, "<@UOTHER> https://example.org");
expect(mocks.post).toHaveBeenCalledWith({ channel: message.channelId, thread_ts: message.ts, text: "&lt;@UOTHER&gt; https://example.org", unfurl_links: false, unfurl_media: false, parse: "none", mrkdwn: false });
const logger = mocks.sockets[0]!.options.logger as { error(value: unknown): void; debug(value: unknown): void };
logger.error("xapp-do-not-log"); logger.debug({ text: "private body" });
});
});
describe("bounded connection recovery", () => {
it("retries initial auth network failures without resolving secrets again or holding configuration open", async () => {
mocks.auth.mockRejectedValueOnce(networkError);
const { ctx, api } = context(); const connection = await create(ctx); await start(connection);
expect(connection.connectionStatus!()).toMatchObject({ state: "connecting", lastFailure: "network_error" });
expect(mocks.start).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(connection.isConnected()).toBe(true); expect(api.secrets.resolve).toHaveBeenCalledTimes(2);
expect(JSON.stringify(connection.connectionStatus!())).not.toContain("private");
});
it("recovers from a failed reconnect request with no parallel clients, then ignores old events", async () => {
const connection = await create(); const receive = await start(connection);
const old = mocks.sockets[0]!;
mocks.start.mockRejectedValueOnce(networkError);
old.emit("disconnected"); await flush();
await vi.advanceTimersByTimeAsync(1000);
expect(connection.connectionStatus!()).toMatchObject({ state: "connecting", lastFailure: "network_error" });
expect(mocks.destroy).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1999); expect(mocks.start).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1); expect(connection.isConnected()).toBe(true);
expect(mocks.sockets).toHaveLength(3); expect(mocks.auth).toHaveBeenCalledTimes(3);
const ack = vi.fn(); old.emit("connected"); old.emit("slack_event", { type: "events_api", body: envelope(), ack });
expect(receive).not.toHaveBeenCalled(); expect(ack).not.toHaveBeenCalled();
mocks.sockets[2]!.emit("slack_event", { type: "events_api", body: envelope(), ack }); expect(receive).toHaveBeenCalledOnce();
});
it.each(["invalid_auth", "token_revoked", "missing_scope"])("stops credential retries for %s and exposes only a fixed category", async (reason) => {
mocks.start.mockRejectedValue({ code: "slack_webapi_platform_error", data: { error: reason, token: "private-token" } });
const connection = await create(); await start(connection); await vi.advanceTimersByTimeAsync(300_000);
expect(connection.connectionStatus!()).toEqual({ state: "error", lastFailure: reason === "missing_scope" ? "permission_denied" : "authentication_failed", retryAt: null });
expect(mocks.start).toHaveBeenCalledTimes(1); expect(mocks.destroy).toHaveBeenCalledTimes(1);
});
it("bounds a missing hello handshake, destroys the old transport and retries", async () => {
mocks.start.mockImplementationOnce((socket: TestSocket) => new Promise((_, reject) => { socket.once("disconnected", () => reject(networkError)); }));
const connection = await create(); await start(connection);
await vi.advanceTimersByTimeAsync(29_999); expect(mocks.destroy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(connection.connectionStatus!()).toMatchObject({ state: "connecting", lastFailure: "connection_timeout" });
expect(mocks.destroy).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000); expect(connection.isConnected()).toBe(true);
});
it("waits for dispatcher destruction before creating another attempt", async () => {
let finish!: () => void;
mocks.destroy.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve; }));
const connection = await create(); await start(connection);
mocks.sockets[0]!.emit("disconnected"); await flush();
await vi.advanceTimersByTimeAsync(2000); expect(mocks.sockets).toHaveLength(1);
finish(); await flush(); await vi.advanceTimersByTimeAsync(1000);
expect(mocks.sockets).toHaveLength(2); expect(connection.isConnected()).toBe(true);
});
it("cancels pending authentication and does not let a late response start a socket", async () => {
let finish!: (auth: unknown) => void;
mocks.auth.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
mocks.destroy.mockImplementationOnce(async () => { finish({ team_id: config.workspaceId, bot_id: "BBOT" }); });
const connection = await create(); await start(connection); await connection.stop();
await vi.advanceTimersByTimeAsync(120_000);
expect(mocks.start).not.toHaveBeenCalled(); expect(mocks.auth).toHaveBeenCalledTimes(1);
expect(connection.authenticatedIdentity).toBeUndefined();
});
it("cancels the handshake on shutdown and ignores late connected/message events", async () => {
mocks.start.mockImplementationOnce((socket: TestSocket) => new Promise((_, reject) => { socket.once("disconnected", () => reject(networkError)); }));
const connection = await create(); const receive = await start(connection); await connection.stop();
const old = mocks.sockets[0]!; old.emit("connected"); old.emit("slack_event", { type: "events_api", body: envelope(), ack: vi.fn() });
await vi.advanceTimersByTimeAsync(120_000);
expect(connection.isConnected()).toBe(false); expect(receive).not.toHaveBeenCalled(); expect(mocks.sockets).toHaveLength(1);
});
it("cancels backoff on shutdown and does not permit a second owner", async () => {
mocks.auth.mockRejectedValue(networkError); const connection = await create(); await start(connection);
await expect(connection.start(vi.fn())).rejects.toThrow("already started");
await connection.stop(); await vi.advanceTimersByTimeAsync(120_000);
expect(mocks.auth).toHaveBeenCalledTimes(1);
});
it("caps transient backoff and honours a longer rate-limit delay", async () => {
mocks.auth.mockRejectedValue(networkError); const connection = await create(); await start(connection);
for (const delay of [1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000]) {
expect(connection.connectionStatus!().retryAt! - Date.now()).toBe(delay);
await vi.advanceTimersByTimeAsync(delay);
}
await connection.stop();
mocks.auth.mockRejectedValue({ code: "slack_webapi_rate_limited_error", retryAfter: 120 });
const limited = await create(); await start(limited);
expect(limited.connectionStatus!().retryAt! - Date.now()).toBe(120_000);
});
it("does not shorten a valid two-hour provider rate limit", async () => {
mocks.auth.mockRejectedValueOnce({ code: "slack_webapi_rate_limited_error", retryAfter: 7200 });
const connection = await create(); await start(connection);
expect(connection.connectionStatus!().retryAt! - Date.now()).toBe(7_200_000);
await vi.advanceTimersByTimeAsync(7_199_999); expect(mocks.auth).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); expect(connection.isConnected()).toBe(true);
});
it.each([2_147_484, Infinity])("halts instead of overflowing an unsupported provider wait: %s", async (retryAfter) => {
mocks.auth.mockRejectedValue({ code: "slack_webapi_rate_limited_error", retryAfter });
const connection = await create(); await start(connection);
expect(connection.connectionStatus!()).toEqual({ state: "error", lastFailure: "rate_limited", retryAt: null });
await vi.advanceTimersByTimeAsync(7_200_000); expect(mocks.auth).toHaveBeenCalledTimes(1);
});
it("halts when cleanup cannot be confirmed instead of opening a competing client", async () => {
mocks.destroy.mockImplementationOnce(() => new Promise(() => {}));
const connection = await create(); await start(connection); mocks.sockets[0]!.emit("disconnected"); await flush();
await vi.advanceTimersByTimeAsync(5000);
expect(connection.connectionStatus!()).toEqual({ state: "error", lastFailure: "cleanup_failed", retryAt: null });
await vi.advanceTimersByTimeAsync(120_000); expect(mocks.sockets).toHaveLength(1);
await expect(connection.stop()).rejects.toThrow("cleanup_failed");
});
});

View File

@ -0,0 +1,4 @@
{
"compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] },
"include": ["src", "tests"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { name: "@paperclipai/plugin-slack-control", include: ["tests/**/*.test.ts"], environment: "node" } });

View File

@ -31,6 +31,7 @@ const nonServerProjects = [
"@paperclipai/adapter-opencode-local",
"@paperclipai/plugin-daytona",
"@paperclipai/plugin-sdk",
"@paperclipai/plugin-slack-control",
"@paperclipai/create-paperclip-plugin",
"@paperclipai/ui",
"paperclipai",

View File

@ -18,6 +18,7 @@ export default defineConfig({
"packages/adapters/opencode-local",
"packages/adapters/pi-local",
"packages/plugins/sdk",
"packages/plugins/plugin-slack-control",
"packages/plugins/create-paperclip-plugin",
"packages/plugins/sandbox-providers/daytona",
"server",