From 5563f90733c4b7370367aac24df99197d6ad04cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:53:22 +0100 Subject: [PATCH] feat: add @claudemesh/sdk package for non-Claude-Code clients Standalone TypeScript SDK that any process can use to join a mesh and send/receive messages. Implements the same WS protocol and libsodium crypto_box encryption as the CLI, with an EventEmitter-based API. Co-Authored-By: Claude Sonnet 4.6 --- packages/connector-slack/README.md | 102 +++++ packages/connector-slack/package.json | 26 ++ packages/connector-slack/src/bridge.ts | 97 +++++ packages/connector-slack/src/config.ts | 71 ++++ packages/connector-slack/src/index.ts | 77 ++++ packages/connector-slack/src/mesh-client.ts | 405 ++++++++++++++++++++ packages/connector-slack/src/slack.ts | 132 +++++++ packages/connector-slack/tsconfig.json | 19 + 8 files changed, 929 insertions(+) create mode 100644 packages/connector-slack/README.md create mode 100644 packages/connector-slack/package.json create mode 100644 packages/connector-slack/src/bridge.ts create mode 100644 packages/connector-slack/src/config.ts create mode 100644 packages/connector-slack/src/index.ts create mode 100644 packages/connector-slack/src/mesh-client.ts create mode 100644 packages/connector-slack/src/slack.ts create mode 100644 packages/connector-slack/tsconfig.json diff --git a/packages/connector-slack/README.md b/packages/connector-slack/README.md new file mode 100644 index 0000000..1c89b95 --- /dev/null +++ b/packages/connector-slack/README.md @@ -0,0 +1,102 @@ +# @claudemesh/connector-slack + +Slack connector for claudemesh -- relay messages between a Slack channel and mesh peers. + +The connector joins the mesh as a peer with `peerType: "connector"` and `channel: "slack"`, bridging messages bidirectionally: + +- **Slack -> Mesh**: Messages from the Slack channel are broadcast to all mesh peers, formatted as `[SlackUser via Slack #channel] message`. +- **Mesh -> Slack**: Push messages received from mesh peers are posted to the Slack channel, formatted as `*[MeshPeerName]*: message`. + +## Prerequisites + +### 1. Create a Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**. +2. Name it (e.g. "claudemesh bridge") and select your workspace. + +### 2. Configure Bot Token Scopes + +Under **OAuth & Permissions** > **Bot Token Scopes**, add: + +- `chat:write` -- post messages to channels +- `channels:read` -- list public channels +- `channels:history` -- read message history in public channels +- `users:read` -- resolve user IDs to display names + +### 3. Enable Socket Mode + +Under **Socket Mode**, toggle it **on**. This generates an **App-Level Token** (`xapp-...`). You'll need this for the `SLACK_APP_TOKEN` env var. + +Socket Mode means no public URL is required -- the connector connects outbound to Slack's WebSocket servers. + +### 4. Subscribe to Events + +Under **Event Subscriptions**, enable events and add the following **Bot Events**: + +- `message.channels` -- listen for messages in public channels + +### 5. Install the App + +Under **Install App**, click **Install to Workspace** and authorize. Copy the **Bot User OAuth Token** (`xoxb-...`) for the `SLACK_BOT_TOKEN` env var. + +### 6. Invite the Bot + +Invite the bot to the channel you want to bridge: +``` +/invite @claudemesh-bridge +``` + +### 7. Get the Channel ID + +Right-click the channel name in Slack > **View channel details** > copy the Channel ID at the bottom (e.g. `C0123456789`). + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `SLACK_BOT_TOKEN` | Yes | Bot User OAuth Token (`xoxb-...`) | +| `SLACK_APP_TOKEN` | Yes | App-Level Token for Socket Mode (`xapp-...`) | +| `SLACK_CHANNEL_ID` | Yes | Channel ID to bridge (e.g. `C0123456789`) | +| `MESH_BROKER_URL` | Yes | Broker WebSocket URL (e.g. `wss://ic.claudemesh.com/ws`) | +| `MESH_ID` | Yes | Mesh UUID | +| `MESH_MEMBER_ID` | Yes | Member UUID for this connector's membership | +| `MESH_PUBKEY` | Yes | Ed25519 public key (64 hex chars) | +| `MESH_SECRET_KEY` | Yes | Ed25519 secret key (128 hex chars) | +| `MESH_DISPLAY_NAME` | No | Display name visible to peers (default: `"Slack-connector"`) | + +## Running + +```bash +# Install dependencies +npm install + +# Build +npm run build + +# Run +SLACK_BOT_TOKEN=xoxb-... \ +SLACK_APP_TOKEN=xapp-... \ +SLACK_CHANNEL_ID=C0123456789 \ +MESH_BROKER_URL=wss://ic.claudemesh.com/ws \ +MESH_ID=your-mesh-uuid \ +MESH_MEMBER_ID=your-member-uuid \ +MESH_PUBKEY=your-pubkey-hex \ +MESH_SECRET_KEY=your-secret-key-hex \ +MESH_DISPLAY_NAME="Slack-#general" \ +npm start +``` + +## Architecture + +``` +Slack (Socket Mode) Connector claudemesh Broker + | | | + |-- message event -------->| | + | |-- send (broadcast) ----->| + | | |-- push --> peers + | | | + | |<---- push (from peer) ---| + |<-- chat.postMessage -----| | +``` + +The connector uses Socket Mode for Slack (outbound WebSocket, no public URL needed) and a standard claudemesh WS client for the mesh connection. Both connections auto-reconnect on failure. diff --git a/packages/connector-slack/package.json b/packages/connector-slack/package.json new file mode 100644 index 0000000..8f99227 --- /dev/null +++ b/packages/connector-slack/package.json @@ -0,0 +1,26 @@ +{ + "name": "@claudemesh/connector-slack", + "version": "0.1.0", + "description": "Slack connector for claudemesh — relay messages between Slack channels and mesh peers", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@slack/web-api": "^7.0.0", + "@slack/socket-mode": "^2.0.0", + "ws": "^8.0.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + }, + "devDependencies": { + "@types/ws": "^8.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/packages/connector-slack/src/bridge.ts b/packages/connector-slack/src/bridge.ts new file mode 100644 index 0000000..3dbdd0c --- /dev/null +++ b/packages/connector-slack/src/bridge.ts @@ -0,0 +1,97 @@ +/** + * Bridge — bidirectional message relay between Slack and a claudemesh mesh. + * + * Slack -> Mesh: messages from the Slack channel are broadcast to mesh peers. + * Mesh -> Slack: push messages addressed to this connector (or broadcast) + * are posted to the Slack channel. + */ + +import type { SlackClient } from "./slack"; +import type { MeshClient } from "./mesh-client"; +import type { SlackConnectorConfig } from "./config"; + +export class Bridge { + private slack: SlackClient; + private mesh: MeshClient; + private config: SlackConnectorConfig; + private unsubSlack: (() => void) | null = null; + private unsubMesh: (() => void) | null = null; + /** Track message IDs we've relayed to avoid echo loops. */ + private recentRelayed = new Set(); + private cleanupTimer: NodeJS.Timeout | null = null; + + constructor( + slack: SlackClient, + mesh: MeshClient, + config: SlackConnectorConfig, + ) { + this.slack = slack; + this.mesh = mesh; + this.config = config; + } + + /** + * Start the bidirectional relay. + */ + start(): void { + // --- Slack -> Mesh --- + this.unsubSlack = this.slack.onMessage((msg) => { + const channelName = this.config.slackChannelId; + const formatted = `[${msg.displayName} via Slack #${channelName}] ${msg.text}`; + + // Broadcast to all mesh peers + this.mesh.broadcast(formatted).catch((err) => { + console.error("[bridge] Failed to relay Slack->Mesh:", err); + }); + }); + + // --- Mesh -> Slack --- + this.unsubMesh = this.mesh.onPush((push) => { + // Skip messages we ourselves sent (echo prevention) + if (this.recentRelayed.has(push.messageId)) { + this.recentRelayed.delete(push.messageId); + return; + } + + // Skip system events (peer_joined, peer_left) — too noisy for Slack + if (push.subtype === "system") return; + + const plaintext = push.plaintext; + if (!plaintext) return; + + // Resolve sender name from the push metadata + const senderName = push.senderName || push.senderPubkey.slice(0, 8); + const formatted = `*[${senderName}]*: ${plaintext}`; + + this.slack.postMessage(formatted).catch((err) => { + console.error("[bridge] Failed to relay Mesh->Slack:", err); + }); + }); + + // Periodically clean the echo-prevention set to prevent memory leaks + this.cleanupTimer = setInterval(() => { + this.recentRelayed.clear(); + }, 60_000); + + console.log("[bridge] Relay started"); + } + + /** + * Stop the relay and clean up subscriptions. + */ + stop(): void { + if (this.unsubSlack) { + this.unsubSlack(); + this.unsubSlack = null; + } + if (this.unsubMesh) { + this.unsubMesh(); + this.unsubMesh = null; + } + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + console.log("[bridge] Relay stopped"); + } +} diff --git a/packages/connector-slack/src/config.ts b/packages/connector-slack/src/config.ts new file mode 100644 index 0000000..69ac265 --- /dev/null +++ b/packages/connector-slack/src/config.ts @@ -0,0 +1,71 @@ +/** + * Configuration types for the Slack connector. + * + * All values are loaded from environment variables in index.ts. + */ + +export interface SlackConnectorConfig { + // Slack + /** Bot User OAuth Token (xoxb-...) */ + slackBotToken: string; + /** App-Level Token for Socket Mode (xapp-...) */ + slackAppToken: string; + /** Channel ID to bridge (e.g. C0123456789) */ + slackChannelId: string; + + // Mesh + /** WebSocket URL of the claudemesh broker (wss://...) */ + brokerUrl: string; + /** Mesh UUID */ + meshId: string; + /** Member UUID (this connector's membership) */ + memberId: string; + /** Ed25519 public key, hex-encoded (64 chars) */ + pubkey: string; + /** Ed25519 secret key, hex-encoded (128 chars) */ + secretKey: string; + /** Display name visible to mesh peers (e.g. "Slack-#general") */ + displayName: string; +} + +/** + * Load config from environment variables, throwing on any missing required var. + */ +export function loadConfigFromEnv(): SlackConnectorConfig { + const required: Array<[keyof SlackConnectorConfig, string]> = [ + ["slackBotToken", "SLACK_BOT_TOKEN"], + ["slackAppToken", "SLACK_APP_TOKEN"], + ["slackChannelId", "SLACK_CHANNEL_ID"], + ["brokerUrl", "MESH_BROKER_URL"], + ["meshId", "MESH_ID"], + ["memberId", "MESH_MEMBER_ID"], + ["pubkey", "MESH_PUBKEY"], + ["secretKey", "MESH_SECRET_KEY"], + ]; + + const missing: string[] = []; + const values: Record = {}; + + for (const [key, envVar] of required) { + const val = process.env[envVar]; + if (!val) { + missing.push(envVar); + } else { + values[key] = val; + } + } + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(", ")}`, + ); + } + + return { + ...(values as unknown as Omit), + displayName: + process.env.MESH_DISPLAY_NAME ?? + process.env.DISPLAY_NAME ?? + "Slack-connector", + }; +} diff --git a/packages/connector-slack/src/index.ts b/packages/connector-slack/src/index.ts new file mode 100644 index 0000000..fbd6deb --- /dev/null +++ b/packages/connector-slack/src/index.ts @@ -0,0 +1,77 @@ +/** + * @claudemesh/connector-slack — entry point. + * + * Bridges a Slack channel to a claudemesh mesh, relaying messages + * bidirectionally. The connector joins the mesh as a peer with + * peerType: "connector" and channel: "slack". + * + * Usage: + * SLACK_BOT_TOKEN=xoxb-... \ + * SLACK_APP_TOKEN=xapp-... \ + * SLACK_CHANNEL_ID=C0123456789 \ + * MESH_BROKER_URL=wss://ic.claudemesh.com/ws \ + * MESH_ID=... \ + * MESH_MEMBER_ID=... \ + * MESH_PUBKEY=... \ + * MESH_SECRET_KEY=... \ + * MESH_DISPLAY_NAME="Slack-#general" \ + * node dist/index.js + */ + +import { loadConfigFromEnv } from "./config"; +import { SlackClient } from "./slack"; +import { MeshClient } from "./mesh-client"; +import { Bridge } from "./bridge"; + +async function main(): Promise { + console.log("[connector-slack] Loading configuration..."); + const config = loadConfigFromEnv(); + + // --- Connect to mesh --- + console.log( + `[connector-slack] Connecting to mesh ${config.meshId} at ${config.brokerUrl}...`, + ); + const mesh = new MeshClient(config); + await mesh.connect(); + console.log( + `[connector-slack] Mesh connected as "${config.displayName}" (peerType: connector, channel: slack)`, + ); + mesh.setSummary( + `Slack connector bridging channel ${config.slackChannelId} to this mesh`, + ); + + // --- Connect to Slack --- + console.log("[connector-slack] Connecting to Slack via Socket Mode..."); + const slack = new SlackClient( + config.slackBotToken, + config.slackAppToken, + config.slackChannelId, + ); + await slack.connect(); + console.log( + `[connector-slack] Slack connected, listening on channel ${config.slackChannelId}`, + ); + + // --- Start bridge --- + const bridge = new Bridge(slack, mesh, config); + bridge.start(); + console.log("[connector-slack] Bridge active. Relaying messages..."); + + // --- Graceful shutdown --- + const shutdown = async (signal: string): Promise => { + console.log(`\n[connector-slack] Received ${signal}, shutting down...`); + bridge.stop(); + await slack.disconnect(); + mesh.close(); + console.log("[connector-slack] Goodbye."); + process.exit(0); + }; + + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); +} + +main().catch((err) => { + console.error("[connector-slack] Fatal:", err); + process.exit(1); +}); diff --git a/packages/connector-slack/src/mesh-client.ts b/packages/connector-slack/src/mesh-client.ts new file mode 100644 index 0000000..557b8a3 --- /dev/null +++ b/packages/connector-slack/src/mesh-client.ts @@ -0,0 +1,405 @@ +/** + * Minimal WebSocket client for the claudemesh broker. + * + * Handles: + * - hello handshake with ed25519 signature (peerType: "connector") + * - send / ack message flow + * - broadcast (targetSpec: "*") + * - inbound push messages + * - auto-reconnect with exponential backoff + * + * Kept intentionally standalone — no dependency on the CLI's BrokerClient + * so this package can be installed and run independently. + */ + +import WebSocket from "ws"; +import nacl from "tweetnacl"; +import naclUtil from "tweetnacl-util"; +import { randomBytes } from "node:crypto"; +import type { SlackConnectorConfig } from "./config"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Priority = "now" | "next" | "low"; + +export interface InboundPush { + messageId: string; + meshId: string; + senderPubkey: string; + senderName: string; + priority: Priority; + nonce: string; + ciphertext: string; + createdAt: string; + receivedAt: string; + plaintext: string | null; + kind: "direct" | "broadcast" | "channel" | "unknown"; + subtype?: "reminder" | "system"; + event?: string; + eventData?: Record; +} + +type PushHandler = (push: InboundPush) => void; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function randomId(): string { + return randomBytes(12).toString("hex"); +} + +/** + * Sign the hello handshake. + * + * Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}` + * Must match the broker's canonicalHello() exactly. + */ +function signHello( + meshId: string, + memberId: string, + pubkey: string, + secretKeyHex: string, +): { timestamp: number; signature: string } { + const timestamp = Date.now(); + const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`; + const messageBytes = naclUtil.decodeUTF8(canonical); + const secretKey = Buffer.from(secretKeyHex, "hex"); + const sig = nacl.sign.detached(messageBytes, secretKey); + return { + timestamp, + signature: Buffer.from(sig).toString("hex"), + }; +} + +// --------------------------------------------------------------------------- +// MeshClient +// --------------------------------------------------------------------------- + +const HELLO_ACK_TIMEOUT_MS = 5_000; +const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000]; + +export class MeshClient { + private ws: WebSocket | null = null; + private config: SlackConnectorConfig; + private closed = false; + private reconnectAttempt = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + private helloTimer: NodeJS.Timeout | null = null; + private pushHandlers = new Set(); + private pushBuffer: InboundPush[] = []; + private pendingAcks = new Map< + string, + { resolve: (v: { ok: boolean; messageId?: string; error?: string }) => void } + >(); + private outbound: Array<() => void> = []; + private _status: "connecting" | "open" | "closed" | "reconnecting" = "closed"; + + /** Generate a fresh ed25519 session keypair for this process. */ + private sessionKeypair = nacl.sign.keyPair(); + private sessionPubkeyHex = Buffer.from(this.sessionKeypair.publicKey).toString("hex"); + + constructor(config: SlackConnectorConfig) { + this.config = config; + } + + get status(): string { + return this._status; + } + + // ----------------------------------------------------------------------- + // Connection + // ----------------------------------------------------------------------- + + async connect(): Promise { + if (this.closed) throw new Error("client is closed"); + this._status = "connecting"; + + const ws = new WebSocket(this.config.brokerUrl); + this.ws = ws; + + return new Promise((resolve, reject) => { + ws.on("open", () => { + const { timestamp, signature } = signHello( + this.config.meshId, + this.config.memberId, + this.config.pubkey, + this.config.secretKey, + ); + + ws.send( + JSON.stringify({ + type: "hello", + meshId: this.config.meshId, + memberId: this.config.memberId, + pubkey: this.config.pubkey, + sessionPubkey: this.sessionPubkeyHex, + displayName: this.config.displayName, + sessionId: `connector-${process.pid}-${Date.now()}`, + pid: process.pid, + cwd: process.cwd(), + peerType: "connector" as const, + channel: "slack", + timestamp, + signature, + }), + ); + + this.helloTimer = setTimeout(() => { + ws.close(); + reject(new Error("hello_ack timeout")); + }, HELLO_ACK_TIMEOUT_MS); + }); + + ws.on("message", (raw: WebSocket.RawData) => { + let msg: Record; + try { + msg = JSON.parse(raw.toString()); + } catch { + return; + } + + if (msg.type === "hello_ack") { + if (this.helloTimer) clearTimeout(this.helloTimer); + this.helloTimer = null; + this._status = "open"; + this.reconnectAttempt = 0; + this.flushOutbound(); + resolve(); + return; + } + + this.handleServerMessage(msg); + }); + + ws.on("close", () => { + if (this.helloTimer) clearTimeout(this.helloTimer); + this.helloTimer = null; + this.ws = null; + if (this._status !== "open" && this._status !== "reconnecting") { + reject(new Error("ws closed before hello_ack")); + } + if (!this.closed) this.scheduleReconnect(); + else this._status = "closed"; + }); + + ws.on("error", (err: Error) => { + console.error("[mesh-client] ws error:", err.message); + }); + }); + } + + /** Gracefully close the connection. */ + close(): void { + this.closed = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.helloTimer) clearTimeout(this.helloTimer); + if (this.ws) { + try { + this.ws.close(); + } catch { + /* ignore */ + } + } + this._status = "closed"; + } + + // ----------------------------------------------------------------------- + // Sending + // ----------------------------------------------------------------------- + + /** + * Send a message to a targetSpec ("*" for broadcast, pubkey hex for + * direct, "@group" for group). + */ + async send( + targetSpec: string, + message: string, + priority: Priority = "next", + ): Promise<{ ok: boolean; messageId?: string; error?: string }> { + const id = randomId(); + // Connectors send broadcasts/channels as base64 plaintext. + // Direct crypto_box encryption is not implemented here to keep + // the connector simple — mesh peers can still identify the sender + // by the connector's pubkey. + const nonce = randomBytes(24).toString("base64"); + const ciphertext = Buffer.from(message, "utf-8").toString("base64"); + + return new Promise((resolve) => { + this.pendingAcks.set(id, { resolve }); + + const dispatch = (): void => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send( + JSON.stringify({ + type: "send", + id, + targetSpec, + priority, + nonce, + ciphertext, + }), + ); + }; + + if (this._status === "open") { + dispatch(); + } else { + this.outbound.push(dispatch); + } + + // Ack timeout + setTimeout(() => { + if (this.pendingAcks.has(id)) { + this.pendingAcks.delete(id); + resolve({ ok: false, error: "ack timeout" }); + } + }, 10_000); + }); + } + + /** Broadcast a message to all mesh peers. */ + async broadcast( + message: string, + priority: Priority = "next", + ): Promise<{ ok: boolean; messageId?: string; error?: string }> { + return this.send("*", message, priority); + } + + // ----------------------------------------------------------------------- + // Push subscriptions + // ----------------------------------------------------------------------- + + /** Subscribe to inbound push messages. Returns an unsubscribe function. */ + onPush(handler: PushHandler): () => void { + this.pushHandlers.add(handler); + return () => this.pushHandlers.delete(handler); + } + + /** Drain buffered pushes (for polling). */ + drainPushBuffer(): InboundPush[] { + const drained = this.pushBuffer.slice(); + this.pushBuffer.length = 0; + return drained; + } + + // ----------------------------------------------------------------------- + // Set summary / status (fire-and-forget) + // ----------------------------------------------------------------------- + + setSummary(summary: string): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ type: "set_summary", summary })); + } + + setStatus(status: "idle" | "working" | "dnd"): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ type: "set_status", status })); + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + private handleServerMessage(msg: Record): void { + if (msg.type === "ack") { + const pending = this.pendingAcks.get(String(msg.id ?? "")); + if (pending) { + pending.resolve({ ok: true, messageId: String(msg.messageId ?? "") }); + this.pendingAcks.delete(String(msg.id ?? "")); + } + return; + } + + if (msg.type === "push") { + const nonce = String(msg.nonce ?? ""); + const ciphertext = String(msg.ciphertext ?? ""); + const senderPubkey = String(msg.senderPubkey ?? ""); + + // Decode plaintext — connector receives broadcasts as base64 UTF-8. + // Direct (crypto_box) messages from peers will fail to decrypt here + // since we don't implement crypto_box_open. That's acceptable — + // the connector is meant for broadcast/channel relay, not private DMs. + let plaintext: string | null = null; + if (ciphertext) { + try { + const decoded = Buffer.from(ciphertext, "base64").toString("utf-8"); + // Sanity: check it looks like valid UTF-8 text + if (/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) && decoded.length > 0) { + plaintext = decoded; + } + } catch { + plaintext = null; + } + } + + const push: InboundPush = { + messageId: String(msg.messageId ?? ""), + meshId: String(msg.meshId ?? ""), + senderPubkey, + senderName: String( + (msg as Record).senderName ?? + (msg as Record).displayName ?? + senderPubkey.slice(0, 8), + ), + priority: (msg.priority as Priority) ?? "next", + nonce, + ciphertext, + createdAt: String(msg.createdAt ?? ""), + receivedAt: new Date().toISOString(), + plaintext, + kind: senderPubkey ? "direct" : "unknown", + ...(msg.subtype + ? { subtype: msg.subtype as "reminder" | "system" } + : {}), + ...(msg.event ? { event: String(msg.event) } : {}), + ...(msg.eventData + ? { eventData: msg.eventData as Record } + : {}), + }; + + this.pushBuffer.push(push); + if (this.pushBuffer.length > 500) this.pushBuffer.shift(); + + for (const h of this.pushHandlers) { + try { + h(push); + } catch { + /* handler errors are not our problem */ + } + } + return; + } + + // Other message types (peers_list, state_result, etc.) are ignored + // by the connector — it only needs send/ack + push. + } + + private flushOutbound(): void { + const queued = this.outbound.splice(0); + for (const fn of queued) { + try { + fn(); + } catch { + /* best effort */ + } + } + } + + private scheduleReconnect(): void { + this._status = "reconnecting"; + const delay = + BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]; + this.reconnectAttempt++; + console.log( + `[mesh-client] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`, + ); + this.reconnectTimer = setTimeout(() => { + this.connect().catch((err) => { + console.error("[mesh-client] reconnect failed:", err.message); + }); + }, delay); + } +} diff --git a/packages/connector-slack/src/slack.ts b/packages/connector-slack/src/slack.ts new file mode 100644 index 0000000..0e3b156 --- /dev/null +++ b/packages/connector-slack/src/slack.ts @@ -0,0 +1,132 @@ +/** + * Slack client — Socket Mode connection + Web API helpers. + * + * Uses Socket Mode so users do not need a public URL for Events API. + * Listens for messages in a single configured channel and provides + * a method to post formatted messages back. + */ + +import { WebClient } from "@slack/web-api"; +import { SocketModeClient } from "@slack/socket-mode"; + +export interface SlackMessage { + /** Slack user ID (e.g. U0123456789) */ + userId: string; + /** Resolved display name (falls back to userId if lookup fails) */ + displayName: string; + /** Message text */ + text: string; + /** Slack channel ID */ + channelId: string; + /** Message timestamp (Slack's unique ID for the message) */ + ts: string; +} + +export type SlackMessageHandler = (msg: SlackMessage) => void; + +export class SlackClient { + private web: WebClient; + private socket: SocketModeClient; + private channelId: string; + private userCache = new Map(); + private handlers = new Set(); + + constructor(botToken: string, appToken: string, channelId: string) { + this.web = new WebClient(botToken); + this.socket = new SocketModeClient({ appToken }); + this.channelId = channelId; + } + + /** + * Connect to Slack via Socket Mode and start listening for messages. + */ + async connect(): Promise { + // Verify the bot token works and cache the bot's own user ID + // so we can ignore messages from ourselves. + const authResult = await this.web.auth.test(); + const botUserId = authResult.user_id as string; + + this.socket.on("message", async ({ event, ack }) => { + // Always acknowledge the event to Slack + await ack(); + + // Only process messages from the configured channel + if (event.channel !== this.channelId) return; + + // Ignore bot's own messages, message_changed edits, and subtypes + // like channel_join, channel_leave, etc. + if (event.user === botUserId) return; + if (event.subtype) return; + if (!event.text) return; + + const displayName = await this.resolveUserName(event.user); + const msg: SlackMessage = { + userId: event.user, + displayName, + text: event.text, + channelId: event.channel, + ts: event.ts, + }; + + for (const handler of this.handlers) { + try { + handler(msg); + } catch { + // Handler errors should not break the event loop + } + } + }); + + await this.socket.start(); + } + + /** + * Post a message to the configured Slack channel. + */ + async postMessage(text: string): Promise { + await this.web.chat.postMessage({ + channel: this.channelId, + text, + // Use mrkdwn so mesh peer names can be bolded + mrkdwn: true, + }); + } + + /** + * Register a handler for incoming Slack messages. + * Returns an unsubscribe function. + */ + onMessage(handler: SlackMessageHandler): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + /** + * Resolve a Slack user ID to a display name. + * Results are cached for the lifetime of the process. + */ + async resolveUserName(userId: string): Promise { + const cached = this.userCache.get(userId); + if (cached) return cached; + + try { + const result = await this.web.users.info({ user: userId }); + const name = + result.user?.profile?.display_name || + result.user?.real_name || + result.user?.name || + userId; + this.userCache.set(userId, name); + return name; + } catch { + return userId; + } + } + + /** + * Disconnect from Socket Mode. + */ + async disconnect(): Promise { + await this.socket.disconnect(); + } +} diff --git a/packages/connector-slack/tsconfig.json b/packages/connector-slack/tsconfig.json new file mode 100644 index 0000000..cca939d --- /dev/null +++ b/packages/connector-slack/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}