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:
Alejandro Gutiérrez
2026-04-07 23:53:22 +01:00
parent b3b9972e60
commit 5563f90733
8 changed files with 929 additions and 0 deletions

View 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.

View 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"
}

View 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");
}
}

View 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",
};
}

View 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);
});

View 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);
}
}

View 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();
}
}

View 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"]
}