feat: add Slack channel adapter (Phase 3b)
Implement ChannelAdapter for Slack using @slack/bolt with Socket Mode: - Thread-aware peer IDs (channelId:threadTs) - Bot message and channel allowlist filtering - Bot mention stripping (<@U\w+> pattern) - Message chunking at 4000 chars for readability - Error handling in connect/disconnect lifecycle - Typed SlackMessageEvent interface - 22 tests covering all behaviors
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Slack channel adapter.
|
||||
*
|
||||
* Implements the ChannelAdapter interface using @slack/bolt with Socket Mode.
|
||||
* Thread-aware: each channel+thread gets its own session via channelId:threadTs peer IDs.
|
||||
* Messages are chunked at 4000 chars for readability.
|
||||
*/
|
||||
|
||||
import { App } from '@slack/bolt';
|
||||
import type {
|
||||
InboundMessage,
|
||||
OutboundMessage,
|
||||
ChannelAdapter,
|
||||
ChannelStatus,
|
||||
} from '../types.js';
|
||||
|
||||
/** Configuration for the Slack channel adapter. */
|
||||
export interface SlackAdapterConfig {
|
||||
botToken: string;
|
||||
appToken: string;
|
||||
signingSecret: string;
|
||||
/** Channel IDs to respond in. Empty = all channels. */
|
||||
allowedChannelIds?: string[];
|
||||
}
|
||||
|
||||
/** Minimal shape of a Slack message event from Bolt. */
|
||||
interface SlackMessageEvent {
|
||||
ts?: string;
|
||||
thread_ts?: string;
|
||||
channel?: string;
|
||||
user?: string;
|
||||
text?: string;
|
||||
bot_id?: string;
|
||||
subtype?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a long message into chunks that respect Slack's readability limit.
|
||||
* Prefers splitting at newlines, then spaces, then hard-cuts.
|
||||
*/
|
||||
function splitMessage(text: string, maxLength: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to split at a newline within the allowed window
|
||||
let splitIndex = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = remaining.lastIndexOf(' ', maxLength);
|
||||
}
|
||||
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
||||
splitIndex = maxLength;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIndex));
|
||||
remaining = remaining.slice(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack channel adapter backed by @slack/bolt.
|
||||
*
|
||||
* Handles channel filtering, thread-aware peer IDs,
|
||||
* and message chunking for readability (4000 chars).
|
||||
*/
|
||||
export class SlackAdapter implements ChannelAdapter {
|
||||
readonly name = 'slack';
|
||||
|
||||
private _status: ChannelStatus = 'disconnected';
|
||||
private app: App | null = null;
|
||||
private messageHandler?: (msg: InboundMessage) => void;
|
||||
private config: SlackAdapterConfig;
|
||||
|
||||
get status(): ChannelStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
constructor(config: SlackAdapterConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/** Register the inbound message handler. Called by the registry before connect(). */
|
||||
onMessage(handler: (msg: InboundMessage) => void): void {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
/** Create the Bolt app with Socket Mode, wire up event handlers, and start. */
|
||||
async connect(): Promise<void> {
|
||||
this._status = 'connecting';
|
||||
|
||||
try {
|
||||
this.app = new App({
|
||||
token: this.config.botToken,
|
||||
appToken: this.config.appToken,
|
||||
signingSecret: this.config.signingSecret,
|
||||
socketMode: true,
|
||||
});
|
||||
|
||||
// Register message event handler
|
||||
this.app.message(async ({ message }) => {
|
||||
this.handleMessage(message as unknown as SlackMessageEvent);
|
||||
});
|
||||
|
||||
await this.app.start();
|
||||
this._status = 'connected';
|
||||
console.log('Slack bot connected via Socket Mode');
|
||||
} catch (error) {
|
||||
this._status = 'error';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the Bolt app and clean up. */
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.app) {
|
||||
try {
|
||||
await this.app.stop();
|
||||
} finally {
|
||||
this.app = null;
|
||||
}
|
||||
}
|
||||
this._status = 'disconnected';
|
||||
}
|
||||
|
||||
/** Send an outbound message, automatically chunking if it exceeds 4000 chars. */
|
||||
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||
if (!this.app) throw new Error('Slack adapter not connected');
|
||||
|
||||
// Parse peerId: "channelId:threadTs"
|
||||
const colonIndex = peerId.indexOf(':');
|
||||
if (colonIndex === -1) throw new Error(`Invalid peer ID format: ${peerId}`);
|
||||
|
||||
const channel = peerId.slice(0, colonIndex);
|
||||
const threadTs = peerId.slice(colonIndex + 1);
|
||||
if (!channel || !threadTs) throw new Error(`Invalid peer ID format: ${peerId}`);
|
||||
|
||||
const text = message.text;
|
||||
|
||||
if (text.length <= 4000) {
|
||||
await this.app.client.chat.postMessage({
|
||||
channel,
|
||||
text,
|
||||
thread_ts: threadTs,
|
||||
});
|
||||
} else {
|
||||
const chunks = splitMessage(text, 4000);
|
||||
for (const chunk of chunks) {
|
||||
await this.app.client.chat.postMessage({
|
||||
channel,
|
||||
text: chunk,
|
||||
thread_ts: threadTs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal: process an inbound Slack message event. */
|
||||
private handleMessage(message: SlackMessageEvent): void {
|
||||
if (!this.messageHandler) return;
|
||||
|
||||
// Ignore bot messages
|
||||
if (message.bot_id || message.subtype === 'bot_message') return;
|
||||
|
||||
const channelId = message.channel;
|
||||
if (!channelId) return;
|
||||
|
||||
// Check allowed channel IDs
|
||||
if (
|
||||
this.config.allowedChannelIds &&
|
||||
this.config.allowedChannelIds.length > 0 &&
|
||||
!this.config.allowedChannelIds.includes(channelId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build peer ID: channelId:threadTs (thread-aware)
|
||||
const threadTs = message.thread_ts ?? message.ts ?? '';
|
||||
const peerId = `${channelId}:${threadTs}`;
|
||||
|
||||
// Strip bot mentions: <@U\w+> pattern
|
||||
let text = (message.text ?? '').replace(/<@U\w+>/g, '').trim();
|
||||
|
||||
// TODO: message.user is a Slack user ID (e.g. U0123ABC), not a display name.
|
||||
// To resolve display names, use this.app.client.users.info() with caching.
|
||||
const senderName = message.user;
|
||||
|
||||
// Detect reset command
|
||||
if (text === '!reset' || text === 'reset') {
|
||||
this.messageHandler({
|
||||
id: message.ts ?? '',
|
||||
channel: 'slack',
|
||||
senderId: peerId,
|
||||
senderName,
|
||||
text: '!reset',
|
||||
timestamp: Date.now(),
|
||||
metadata: { isCommand: true, command: 'reset' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular message
|
||||
this.messageHandler({
|
||||
id: message.ts ?? '',
|
||||
channel: 'slack',
|
||||
senderId: peerId,
|
||||
senderName,
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user