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 <noreply@anthropic.com>
This commit is contained in:
102
packages/connector-slack/README.md
Normal file
102
packages/connector-slack/README.md
Normal file
@@ -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.
|
||||||
26
packages/connector-slack/package.json
Normal file
26
packages/connector-slack/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
97
packages/connector-slack/src/bridge.ts
Normal file
97
packages/connector-slack/src/bridge.ts
Normal file
@@ -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<string>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
71
packages/connector-slack/src/config.ts
Normal file
71
packages/connector-slack/src/config.ts
Normal file
@@ -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<string, string> = {};
|
||||||
|
|
||||||
|
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<SlackConnectorConfig, "displayName">),
|
||||||
|
displayName:
|
||||||
|
process.env.MESH_DISPLAY_NAME ??
|
||||||
|
process.env.DISPLAY_NAME ??
|
||||||
|
"Slack-connector",
|
||||||
|
};
|
||||||
|
}
|
||||||
77
packages/connector-slack/src/index.ts
Normal file
77
packages/connector-slack/src/index.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
405
packages/connector-slack/src/mesh-client.ts
Normal file
405
packages/connector-slack/src/mesh-client.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PushHandler>();
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<string, unknown>;
|
||||||
|
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<string, unknown>): 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<string, unknown>).senderName ??
|
||||||
|
(msg as Record<string, unknown>).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<string, unknown> }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
packages/connector-slack/src/slack.ts
Normal file
132
packages/connector-slack/src/slack.ts
Normal file
@@ -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<string, string>();
|
||||||
|
private handlers = new Set<SlackMessageHandler>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
await this.socket.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/connector-slack/tsconfig.json
Normal file
19
packages/connector-slack/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user