Files
Alejandro Gutiérrez 5563f90733 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>
2026-04-07 23:53:22 +01:00

133 lines
3.6 KiB
TypeScript

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