Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const DEFAULT_CONFIG: ChannelConfig = { channels: {} };
* Default location: ~/.ai-devkit/channels.json
* File permissions are set to 0600 to protect tokens.
*/
export class ConfigStore {
export class ChannelConfigRepository {
private configPath: string;

constructor(configPath?: string) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ConfigStore } from '../ConfigStore.js';
import { ChannelConfigRepository } from '../ChannelConfigRepository.js';
import { isSlackEntry } from '../types.js';
import type { ChannelEntry } from '../types.js';

describe('ConfigStore', () => {
describe('ChannelConfigRepository', () => {
let tmpDir: string;
let configPath: string;
let store: ConfigStore;
let store: ChannelConfigRepository;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'channel-connector-test-'));
configPath = path.join(tmpDir, 'channels.json');
store = new ConfigStore(configPath);
store = new ChannelConfigRepository(configPath);
});

afterEach(() => {
Expand Down Expand Up @@ -48,7 +48,7 @@ describe('ConfigStore', () => {

describe('constructor', () => {
it('should use default path when no configPath provided', async () => {
const defaultStore = new ConfigStore();
const defaultStore = new ChannelConfigRepository();
// Should not throw — just uses default path
const config = await defaultStore.getConfig();
expect(config).toBeDefined();
Expand Down Expand Up @@ -95,7 +95,7 @@ describe('ConfigStore', () => {

it('should create parent directory if missing', async () => {
const nestedPath = path.join(tmpDir, 'nested', 'dir', 'channels.json');
const nestedStore = new ConfigStore(nestedPath);
const nestedStore = new ChannelConfigRepository(nestedPath);

await nestedStore.saveChannel('telegram', sampleEntry);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events';
import { SlackAdapter, validateSlackAppToken, validateSlackCredentials } from '../../adapters/SlackAdapter.js';
import type { SlackConfig } from '../../types.js';
import { SlackAdapter, validateSlackAppToken, validateSlackCredentials } from '../../../adapters/slack/SlackAdapter.js';
import type { SlackConfig } from '../../../types.js';

class FakeSocketClient extends EventEmitter {
start = vi.fn().mockResolvedValue(undefined);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { SlackDeliveryQueue } from '../../utils/SlackDeliveryQueue.js';
import { SlackMessageDelivery } from '../../../adapters/slack/SlackMessageDelivery.js';

describe('SlackDeliveryQueue', () => {
describe('SlackMessageDelivery', () => {
it('serializes long output as a parent followed by threaded continuations', async () => {
const postMessage = vi.fn()
.mockResolvedValueOnce({ ok: true, ts: '100.1' })
.mockResolvedValue({ ok: true, ts: '100.2' });
const queue = new SlackDeliveryQueue({ postMessage }, { maxMessageLength: 20 });
const queue = new SlackMessageDelivery({ postMessage }, { maxMessageLength: 20 });
const result = await queue.send('D123', 'first paragraph\n\nsecond paragraph that is long');
expect(result).toEqual({ messageId: '100.1', threadId: '100.1' });
expect(postMessage).toHaveBeenCalledTimes(3);
Expand All @@ -18,7 +18,7 @@ describe('SlackDeliveryQueue', () => {
const error = Object.assign(new Error('rate limited'), { retryAfter: 2 });
const postMessage = vi.fn().mockRejectedValueOnce(error).mockResolvedValue({ ok: true, ts: '200.1' });
const sleep = vi.fn().mockResolvedValue(undefined);
const queue = new SlackDeliveryQueue({ postMessage }, { sleep });
const queue = new SlackMessageDelivery({ postMessage }, { sleep });
await expect(queue.send('D123', 'hello')).resolves.toEqual({ messageId: '200.1', threadId: '200.1' });
expect(sleep).toHaveBeenCalledWith(2000);
expect(postMessage).toHaveBeenCalledTimes(2);
Expand All @@ -28,7 +28,7 @@ describe('SlackDeliveryQueue', () => {
const error = Object.assign(new Error('rate limited'), { retryAfter: 86_400 });
const postMessage = vi.fn().mockRejectedValueOnce(error).mockResolvedValue({ ok: true, ts: '200.1' });
const sleep = vi.fn().mockResolvedValue(undefined);
const queue = new SlackDeliveryQueue({ postMessage }, { sleep });
const queue = new SlackMessageDelivery({ postMessage }, { sleep });
await queue.send('D123', 'hello');
expect(sleep).toHaveBeenCalledWith(60_000);
});
Expand All @@ -39,16 +39,16 @@ describe('SlackDeliveryQueue', () => {
release = () => resolve({ ok: true, ts: '300.1' });
});
const postMessage = vi.fn().mockReturnValue(pending);
const queue = new SlackDeliveryQueue({ postMessage }, { maxQueueSize: 1 });
const queue = new SlackMessageDelivery({ postMessage }, { maxQueueSize: 1 });
const first = queue.send('D123', 'first');
await expect(queue.send('D123', 'second')).rejects.toThrow('Slack delivery queue is full');
await expect(queue.send('D123', 'second')).rejects.toThrow('Slack message delivery queue is full');
release();
await first;
});

it('rejects empty output and responses without a timestamp', async () => {
const postMessage = vi.fn().mockResolvedValue({ ok: true });
const queue = new SlackDeliveryQueue({ postMessage });
const queue = new SlackMessageDelivery({ postMessage });
await expect(queue.send('D123', '')).rejects.toThrow('Cannot send an empty Slack message');
await expect(queue.send('D123', 'hello')).rejects.toThrow('Slack did not return a message timestamp');
});
Expand All @@ -57,14 +57,14 @@ describe('SlackDeliveryQueue', () => {
const sleep = vi.fn();
const permanent = new Error('invalid_auth');
const postMessage = vi.fn().mockRejectedValue(permanent);
const queue = new SlackDeliveryQueue({ postMessage }, { sleep });
const queue = new SlackMessageDelivery({ postMessage }, { sleep });
await expect(queue.send('D123', 'hello')).rejects.toBe(permanent);
expect(sleep).not.toHaveBeenCalled();
});

it('allows separate conversation queues to progress independently', async () => {
const postMessage = vi.fn().mockImplementation(async ({ channel }) => ({ ok: true, ts: `${channel}.1` }));
const queue = new SlackDeliveryQueue({ postMessage });
const queue = new SlackMessageDelivery({ postMessage });
await expect(Promise.all([queue.send('D1', 'one'), queue.send('D2', 'two')])).resolves.toHaveLength(2);
expect(postMessage).toHaveBeenCalledTimes(2);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
SLACK_MAX_MESSAGE_LENGTH,
chunkMarkdownForSlack,
markdownToSlackMrkdwn,
} from '../../utils/slackMarkdown.js';
} from '../../../adapters/slack/slackMarkdown.js';

describe('Slack Markdown', () => {
it('renders common Markdown without activating broad mentions', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { buildSlackQuestionMessage } from '../../../adapters/slack/slackQuestion.js';

describe('buildSlackQuestionMessage', () => {
it('builds an accessible Block Kit question with optional skip action', () => {
const message = buildSlackQuestionMessage('D123', {
id: 'q1',
header: 'Scope',
question: 'Choose one',
allowSkip: true,
options: [{ label: 'Safe', description: 'Read only', value: '1' }],
});

expect(message).toMatchObject({
channel: 'D123',
text: 'Scope: Choose one',
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: '*Scope*\nChoose one' },
},
{
type: 'actions',
elements: [
{
type: 'button',
action_id: 'ai_devkit_question',
text: { type: 'plain_text', text: 'Safe' },
value: 'q1:1',
},
{
type: 'button',
action_id: 'ai_devkit_question',
text: { type: 'plain_text', text: 'Skip' },
value: 'q1:skip',
},
],
},
],
});
});

it('escapes Slack control syntax in fallback and mrkdwn text', () => {
const message = buildSlackQuestionMessage('D123', {
id: 'q1',
header: '<!channel>',
question: 'Choose <one> & continue',
allowSkip: false,
options: [{ label: 'Safe', value: '1' }],
});

expect(message.text).toBe('&lt;!channel&gt;: Choose &lt;one&gt; &amp; continue');
expect(message.blocks[0].text.text).toBe('*&lt;!channel&gt;*\nChoose &lt;one&gt; &amp; continue');
});

it('limits button labels and values to Slack bounds', () => {
const message = buildSlackQuestionMessage('D123', {
id: 'q1',
question: 'Choose',
allowSkip: false,
options: [{ label: 'A'.repeat(100), value: 'v'.repeat(3000) }],
});

const button = message.blocks[1].elements[0];
expect(button.text.text).toHaveLength(75);
expect(button.value).toHaveLength(2000);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as telegrafModule from 'telegraf';
import * as telegramHtml from '../../utils/telegramHtml.js';
import { TelegramAdapter } from '../../adapters/TelegramAdapter.js';
import type { IncomingMessage } from '../../types.js';
import * as telegramHtml from '../../../adapters/telegram/telegramHtml.js';
import { TelegramAdapter } from '../../../adapters/telegram/TelegramAdapter.js';
import type { IncomingMessage } from '../../../types.js';

// Mock telegraf
vi.mock('telegraf', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { markdownToTelegramHtml } from '../../utils/telegramHtml.js';
import { markdownToTelegramHtml } from '../../../adapters/telegram/telegramHtml.js';

describe('markdownToTelegramHtml', () => {
it('renders bold, italic, strikethrough', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { SocketModeClient } from '@slack/socket-mode';
import { WebClient } from '@slack/web-api';
import type { InteractiveChannelAdapter } from './ChannelAdapter.js';
import type { InteractiveChannelAdapter } from '../ChannelAdapter.js';
import type {
ChannelQuestion,
IncomingInteraction,
IncomingMessage,
SentMessage,
SlackConfig,
} from '../types.js';
import { SlackDeliveryQueue } from '../utils/SlackDeliveryQueue.js';
import { escapeSlackText } from '../utils/slackMarkdown.js';
} from '../../types.js';
import { SlackMessageDelivery } from './SlackMessageDelivery.js';
import { buildSlackQuestionMessage } from './slackQuestion.js';

export const SLACK_CHANNEL_TYPE = 'slack';

Expand Down Expand Up @@ -83,7 +83,7 @@ export class SlackAdapter implements InteractiveChannelAdapter {
readonly type = SLACK_CHANNEL_TYPE;
private readonly socket: SocketClientLike;
private readonly web: WebClientLike;
private readonly delivery: SlackDeliveryQueue;
private readonly delivery: SlackMessageDelivery;
private readonly recentEventIds = new Map<string, number>();
private messageHandler: ((message: IncomingMessage) => Promise<void>) | null = null;
private interactionHandler: ((interaction: IncomingInteraction) => Promise<void>) | null = null;
Expand All @@ -93,7 +93,7 @@ export class SlackAdapter implements InteractiveChannelAdapter {
constructor(private readonly config: SlackConfig, dependencies: SlackAdapterDependencies = {}) {
this.socket = dependencies.socketClient ?? new SocketModeClient({ appToken: config.appToken });
this.web = dependencies.webClient ?? new WebClient(config.botToken) as unknown as WebClientLike;
this.delivery = new SlackDeliveryQueue({
this.delivery = new SlackMessageDelivery({
postMessage: (input) => this.web.chat.postMessage(input),
});
}
Expand Down Expand Up @@ -136,30 +136,7 @@ export class SlackAdapter implements InteractiveChannelAdapter {
}

async sendQuestion(chatId: string, question: ChannelQuestion): Promise<SentMessage> {
const fallback = escapeSlackText(`${question.header ? `${question.header}: ` : ''}${question.question}`);
const elements = question.options.map((option) => ({
type: 'button',
action_id: 'ai_devkit_question',
text: { type: 'plain_text', text: option.label.slice(0, 75) },
value: `${question.id}:${option.value}`.slice(0, 2000),
}));
if (question.allowSkip) {
elements.push({
type: 'button', action_id: 'ai_devkit_question',
text: { type: 'plain_text', text: 'Skip' }, value: `${question.id}:skip`,
});
}
const response = await this.web.chat.postMessage({
channel: chatId,
text: fallback,
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `*${escapeSlackText(question.header ?? 'Question')}*\n${escapeSlackText(question.question)}` },
},
{ type: 'actions', elements },
],
});
const response = await this.web.chat.postMessage(buildSlackQuestionMessage(chatId, question));
if (!response.ts) throw new Error('Slack did not return a question message timestamp');
return { messageId: response.ts };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SentMessage } from '../types.js';
import type { SentMessage } from '../../types.js';
import { chunkMarkdownForSlack, SLACK_MAX_MESSAGE_LENGTH } from './slackMarkdown.js';

interface SlackPoster {
Expand Down Expand Up @@ -27,7 +27,7 @@ interface ConversationState {
const defaultSleep = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));

export class SlackDeliveryQueue {
export class SlackMessageDelivery {
private readonly states = new Map<string, ConversationState>();
private readonly maxMessageLength: number;
private readonly maxQueueSize: number;
Expand All @@ -42,7 +42,7 @@ export class SlackDeliveryQueue {
async send(channel: string, markdown: string): Promise<SentMessage> {
const state = this.states.get(channel) ?? { tail: Promise.resolve(), pending: 0 };
if (state.pending >= this.maxQueueSize) {
throw new Error('Slack delivery queue is full');
throw new Error('Slack message delivery queue is full');
}
state.pending += 1;
this.states.set(channel, state);
Expand Down
62 changes: 62 additions & 0 deletions packages/channel-connector/src/adapters/slack/slackQuestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ChannelQuestion } from '../../types.js';
import { escapeSlackText } from './slackMarkdown.js';

interface SlackQuestionButton {
type: 'button';
action_id: 'ai_devkit_question';
text: { type: 'plain_text'; text: string };
value: string;
}

type SlackQuestionMessage = Record<string, unknown> & {
channel: string;
text: string;
blocks: [
{
type: 'section';
text: { type: 'mrkdwn'; text: string };
},
{
type: 'actions';
elements: SlackQuestionButton[];
},
];
};

export function buildSlackQuestionMessage(chatId: string, question: ChannelQuestion): SlackQuestionMessage {
const fallbackPrefix = question.header ? `${question.header}: ` : '';
const fallback = escapeSlackText(`${fallbackPrefix}${question.question}`);
const buttons = question.options.map((option): SlackQuestionButton => ({
type: 'button',
action_id: 'ai_devkit_question',
text: { type: 'plain_text', text: option.label.slice(0, 75) },
value: `${question.id}:${option.value}`.slice(0, 2000),
}));

if (question.allowSkip) {
buttons.push({
type: 'button',
action_id: 'ai_devkit_question',
text: { type: 'plain_text', text: 'Skip' },
value: `${question.id}:skip`,
});
}

return {
channel: chatId,
text: fallback,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${escapeSlackText(question.header ?? 'Question')}*\n${escapeSlackText(question.question)}`,
},
},
{
type: 'actions',
elements: buttons,
},
],
};
}
Loading
Loading