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,377 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// ── Mock @slack/bolt before importing adapter ──────────────────────
|
||||||
|
|
||||||
|
let capturedMessageHandler: ((args: Record<string, unknown>) => Promise<void>) | null = null;
|
||||||
|
const mockPostMessage = vi.fn();
|
||||||
|
const mockStart = vi.fn();
|
||||||
|
const mockStop = vi.fn();
|
||||||
|
|
||||||
|
/** Create a fresh mock Bolt App instance. */
|
||||||
|
function createMockApp() {
|
||||||
|
capturedMessageHandler = null;
|
||||||
|
return {
|
||||||
|
message: vi.fn((handler: (args: Record<string, unknown>) => Promise<void>) => {
|
||||||
|
capturedMessageHandler = handler;
|
||||||
|
}),
|
||||||
|
start: mockStart,
|
||||||
|
stop: mockStop,
|
||||||
|
client: {
|
||||||
|
chat: {
|
||||||
|
postMessage: mockPostMessage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockApp = createMockApp();
|
||||||
|
|
||||||
|
vi.mock('@slack/bolt', () => ({
|
||||||
|
App: vi.fn().mockImplementation(() => mockApp),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SlackAdapter, type SlackAdapterConfig } from './adapter.js';
|
||||||
|
import type { InboundMessage } from '../types.js';
|
||||||
|
|
||||||
|
const baseConfig: SlackAdapterConfig = {
|
||||||
|
botToken: 'xoxb-test-token',
|
||||||
|
appToken: 'xapp-test-token',
|
||||||
|
signingSecret: 'test-signing-secret',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Helper: simulate a Slack message event through the captured handler. */
|
||||||
|
async function simulateMessage(message: Record<string, unknown>) {
|
||||||
|
if (!capturedMessageHandler) throw new Error('No message handler captured — call connect() first');
|
||||||
|
await capturedMessageHandler({ message });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SlackAdapter', () => {
|
||||||
|
let adapter: SlackAdapter;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockApp = createMockApp();
|
||||||
|
// Re-wire the App mock to return the fresh mockApp
|
||||||
|
const { App } = vi.mocked(await import('@slack/bolt'));
|
||||||
|
(App as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockApp);
|
||||||
|
adapter = new SlackAdapter(baseConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Basic properties ──────────────────────────────────────────
|
||||||
|
|
||||||
|
it('has name "slack"', () => {
|
||||||
|
expect(adapter.name).toBe('slack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts as disconnected', () => {
|
||||||
|
expect(adapter.status).toBe('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── connect / disconnect ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('connect creates Bolt App with Socket Mode and sets connected status', async () => {
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
expect(adapter.status).toBe('connected');
|
||||||
|
const { App } = await import('@slack/bolt');
|
||||||
|
expect(App).toHaveBeenCalledWith({
|
||||||
|
token: 'xoxb-test-token',
|
||||||
|
appToken: 'xapp-test-token',
|
||||||
|
signingSecret: 'test-signing-secret',
|
||||||
|
socketMode: true,
|
||||||
|
});
|
||||||
|
expect(mockStart).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connect registers message handler via app.message()', async () => {
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
expect(mockApp.message).toHaveBeenCalledTimes(1);
|
||||||
|
expect(capturedMessageHandler).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnect stops app and sets disconnected', async () => {
|
||||||
|
await adapter.connect();
|
||||||
|
expect(adapter.status).toBe('connected');
|
||||||
|
|
||||||
|
await adapter.disconnect();
|
||||||
|
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(adapter.status).toBe('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnect is safe when not connected', async () => {
|
||||||
|
await adapter.disconnect();
|
||||||
|
expect(adapter.status).toBe('disconnected');
|
||||||
|
// No app to stop — should not throw
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── send ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('send throws when not connected', async () => {
|
||||||
|
await expect(adapter.send('C123:1234.5678', { text: 'hello' })).rejects.toThrow(
|
||||||
|
'Slack adapter not connected',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send delivers a short message with correct channel and thread_ts', async () => {
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await adapter.send('C123:1234.5678', { text: 'Hello there' });
|
||||||
|
|
||||||
|
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||||
|
channel: 'C123',
|
||||||
|
text: 'Hello there',
|
||||||
|
thread_ts: '1234.5678',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send chunks long messages (>4000 chars)', async () => {
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
// Create a message longer than 4000 chars — two halves joined by a newline
|
||||||
|
const half = 'A'.repeat(3000);
|
||||||
|
const longMessage = `${half}\n${'B'.repeat(3000)}`;
|
||||||
|
|
||||||
|
await adapter.send('C123:1234.5678', { text: longMessage });
|
||||||
|
|
||||||
|
// Should have been split into at least 2 chunks
|
||||||
|
expect(mockPostMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
// All calls should target the same channel and thread
|
||||||
|
for (const call of mockPostMessage.mock.calls) {
|
||||||
|
expect(call[0].channel).toBe('C123');
|
||||||
|
expect(call[0].thread_ts).toBe('1234.5678');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send throws on invalid peer ID format (no colon)', async () => {
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await expect(adapter.send('invalid-no-colon', { text: 'hello' })).rejects.toThrow(
|
||||||
|
'Invalid peer ID format: invalid-no-colon',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── onMessage / inbound handling ──────────────────────────────
|
||||||
|
|
||||||
|
it('inbound message triggers handler with correct peerId (channelId:threadTs)', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
thread_ts: '1111.0000',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'Hello Flynn',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
|
expect(msg.channel).toBe('slack');
|
||||||
|
expect(msg.senderId).toBe('C123:1111.0000');
|
||||||
|
expect(msg.senderName).toBe('U456');
|
||||||
|
expect(msg.text).toBe('Hello Flynn');
|
||||||
|
expect(msg.id).toBe('1234.5678');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inbound message uses thread_ts for peer ID when in thread', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '2222.3333',
|
||||||
|
thread_ts: '1111.0000',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'Threaded reply',
|
||||||
|
});
|
||||||
|
|
||||||
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
|
expect(msg.senderId).toBe('C123:1111.0000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inbound message falls back to ts when no thread_ts', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '2222.3333',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'Top-level message',
|
||||||
|
});
|
||||||
|
|
||||||
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
|
expect(msg.senderId).toBe('C123:2222.3333');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores bot messages (bot_id present)', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'Bot message',
|
||||||
|
bot_id: 'B789',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores bot messages (subtype === "bot_message")', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'Bot message',
|
||||||
|
subtype: 'bot_message',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores messages in disallowed channels', async () => {
|
||||||
|
const restrictedAdapter = new SlackAdapter({
|
||||||
|
...baseConfig,
|
||||||
|
allowedChannelIds: ['C-allowed'],
|
||||||
|
});
|
||||||
|
const handler = vi.fn();
|
||||||
|
restrictedAdapter.onMessage(handler);
|
||||||
|
|
||||||
|
await restrictedAdapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C-not-allowed',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'Hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips bot mentions (<@U\\w+> pattern)', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: '<@UBOT123> What is the weather?',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
|
expect(msg.text).toBe('What is the weather?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('!reset command delivers reset metadata', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: '!reset',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
|
expect(msg.text).toBe('!reset');
|
||||||
|
expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"reset" text (without !) delivers reset metadata', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'reset',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
|
expect(msg.text).toBe('!reset');
|
||||||
|
expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no message handler is registered', async () => {
|
||||||
|
// Don't call onMessage — no handler registered
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'Hello',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('messages with no text property are handled (empty string)', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
|
await adapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C123',
|
||||||
|
user: 'U456',
|
||||||
|
// no text property
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
|
expect(msg.text).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allowed channel messages pass through when allowedChannelIds configured', async () => {
|
||||||
|
const restrictedAdapter = new SlackAdapter({
|
||||||
|
...baseConfig,
|
||||||
|
allowedChannelIds: ['C-allowed'],
|
||||||
|
});
|
||||||
|
const handler = vi.fn();
|
||||||
|
restrictedAdapter.onMessage(handler);
|
||||||
|
|
||||||
|
await restrictedAdapter.connect();
|
||||||
|
|
||||||
|
await simulateMessage({
|
||||||
|
ts: '1234.5678',
|
||||||
|
channel: 'C-allowed',
|
||||||
|
user: 'U456',
|
||||||
|
text: 'Hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
|
expect(msg.text).toBe('Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SlackAdapter, type SlackAdapterConfig } from './adapter.js';
|
||||||
Reference in New Issue
Block a user