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
151 changes: 148 additions & 3 deletions packages/bridges/irc/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,149 @@
import { smokeTest } from '@profullstack/sh1pt-core/testing';
import adapter from './index.js';
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { contractTestBridge } from '@profullstack/sh1pt-core/testing';

smokeTest(adapter, { idPrefix: 'bridge' });
class FakeSocket extends EventEmitter {
writes: string[] = [];
end = vi.fn();
destroy = vi.fn();

write(data: string | Uint8Array): boolean {
this.writes.push(data.toString());
return true;
}
}

const sockets = {
net: [] as FakeSocket[],
tls: [] as FakeSocket[],
};

vi.mock('node:net', () => ({
createConnection: vi.fn(() => {
const socket = new FakeSocket();
sockets.net.push(socket);
return socket;
}),
}));

vi.mock('node:tls', () => ({
connect: vi.fn(() => {
const socket = new FakeSocket();
sockets.tls.push(socket);
return socket;
}),
}));

const { default: adapter } = await import('./index.js');

contractTestBridge(adapter, {
sampleConfig: { server: 'irc.example.net', nick: 'sh1pt' },
sampleChannel: '#ship',
});

afterEach(() => {
sockets.net.length = 0;
sockets.tls.length = 0;
vi.clearAllMocks();
});

describe('bridge-irc socket integration', () => {
it('registers and sends PRIVMSG lines over TLS', async () => {
const result = await adapter.send(ctx({}), '#ship', {
id: 'src-1',
channel: 'src',
identity: { network: 'discord', username: 'alice' },
text: 'release shipped',
attachments: [{ kind: 'file', url: 'https://example.com/log.txt', filename: 'log.txt' }],
timestamp: '2026-05-21T00:00:00.000Z',
originalNetwork: 'discord',
}, {
server: 'irc.example.net',
nick: 'sh1pt',
});

expect(result.id).toMatch(/^irc_/);
expect(sockets.tls).toHaveLength(1);
const socket = sockets.tls[0]!;
socket.emit('connect');

expect(socket.writes).toEqual([
'NICK sh1pt\r\n',
'USER sh1pt 0 * :sh1pt\r\n',
'PRIVMSG #ship :alice [discord]: release shipped file: https://example.com/log.txt\r\n',
]);
expect(socket.end).toHaveBeenCalledOnce();
});

it('uses plaintext sockets and PASS when TLS is disabled', async () => {
await adapter.send(ctx({ IRC_PASSWORD: 'pw' }), '#ship', {
id: 'src-1',
channel: 'src',
identity: { network: 'slack', username: 'bob' },
text: 'hello',
timestamp: '2026-05-21T00:00:00.000Z',
}, {
server: 'irc.example.net',
nick: 'sh1pt',
tls: false,
passwordKey: 'IRC_PASSWORD',
});

expect(sockets.net).toHaveLength(1);
const socket = sockets.net[0]!;
socket.emit('connect');
expect(socket.writes.slice(0, 3)).toEqual([
'PASS pw\r\n',
'NICK sh1pt\r\n',
'USER sh1pt 0 * :sh1pt\r\n',
]);
});

it('subscribes to channels, responds to PING, and maps PRIVMSG events', async () => {
const onMessage = vi.fn();
const subscription = await adapter.subscribe(ctx({}), ['#ship'], onMessage, {
server: 'irc.example.net',
nick: 'sh1pt',
});

expect(sockets.tls).toHaveLength(1);
const socket = sockets.tls[0]!;
socket.emit('connect');
expect(socket.writes).toContain('JOIN #ship\r\n');

socket.emit('data', 'PING :server.example\r\n');
socket.emit('data', ':ada!~ada@example PRIVMSG #ship :hello from irc\r\n');
socket.emit('data', ':ada!~ada@example PRIVMSG #other :ignored\r\n');

await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(1));
expect(socket.writes).toContain('PONG :server.example\r\n');
expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({
channel: '#ship',
identity: { network: 'irc', username: 'ada' },
text: 'hello from irc',
originalNetwork: 'irc',
}));

await subscription.close();
expect(socket.end).toHaveBeenCalledOnce();
expect(socket.destroy).toHaveBeenCalledOnce();
});

it('requires a vault password when SASL is enabled', async () => {
await expect(adapter.subscribe(ctx({}), ['#ship'], vi.fn(), {
server: 'irc.example.net',
nick: 'sh1pt',
sasl: true,
})).rejects.toThrow('IRC_PASSWORD not in vault');
});
});

function ctx(secrets: Record<string, string>) {
return {
secret(key: string) {
return secrets[key];
},
log: vi.fn(),
dryRun: false,
};
}
135 changes: 127 additions & 8 deletions packages/bridges/irc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { defineBridge, manualSetup } from '@profullstack/sh1pt-core';
import { createConnection, type Socket } from 'node:net';
import { connect as tlsConnect } from 'node:tls';
import { defineBridge, manualSetup, type BridgeMessage } from '@profullstack/sh1pt-core';

// IRC bridge — classic TCP/TLS client against any IRC network (Libera,
// OFTC, self-hosted InspIRCd, etc.). Use SASL for auth on modern networks.
Expand All @@ -8,26 +10,69 @@ interface Config {
nick: string;
realname?: string;
sasl?: boolean; // use SASL PLAIN — requires NICK password in vault
tls?: boolean;
username?: string;
passwordKey?: string;
}

const DEFAULT_PASSWORD_KEY = 'IRC_PASSWORD';

export default defineBridge<Config>({
id: 'bridge-irc',
label: 'IRC',

async subscribe(ctx, channels, onMessage, config) {
if (config.sasl && !ctx.secret('IRC_PASSWORD')) throw new Error('IRC_PASSWORD not in vault (SASL enabled)');
const password = ircPassword(ctx, config);
ctx.log(`irc bridge · ${config.server}:${config.port ?? 6697} · channels=${channels.join(',')}`);
// TODO: TLS socket connect, CAP LS → SASL PLAIN → NICK → USER → JOIN
// channels; parse PRIVMSG into BridgeMessage.
return { async close() {} };

const socket = connectIrc(config);
const subscribed = new Set(channels);
let buffer = '';

socket.on('connect', () => {
register(socket, config, password);
for (const channel of channels) writeLine(socket, `JOIN ${channel}`);
});

socket.on('data', async (chunk: Buffer | string) => {
buffer += chunk.toString();
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line) continue;
if (line.startsWith('PING ')) {
writeLine(socket, `PONG ${line.slice(5)}`);
continue;
}
const message = parsePrivmsg(line);
if (message && subscribed.has(message.channel)) {
await onMessage(message);
}
}
});

return {
async close() {
socket.end();
socket.destroy();
},
};
},

async send(ctx, channel, msg, config) {
const password = ircPassword(ctx, config);
ctx.log(`irc bridge · PRIVMSG ${channel}`);
if (ctx.dryRun) return { id: 'dry-run' };
// TODO: write "PRIVMSG <channel> :<username> [<network>]: <text>\r\n"
// IRC has no native multi-line; split at ~400 bytes and send multiple
// PRIVMSGs. Media: post a link (IRC has no attachments).

const socket = connectIrc(config);
socket.on('connect', () => {
register(socket, config, password);
for (const line of splitIrcLines(renderIrcMessage(msg))) {
writeLine(socket, `PRIVMSG ${channel} :${line}`);
}
socket.end();
});

return { id: `irc_${Date.now()}` };
},

Expand All @@ -40,3 +85,77 @@ export default defineBridge<Config>({
],
}),
});

function ircPassword(
ctx: { secret(k: string): string | undefined },
config: Config,
): string | undefined {
const key = config.passwordKey ?? DEFAULT_PASSWORD_KEY;
const password = ctx.secret(key);
if (config.sasl && !password) throw new Error(`${key} not in vault (SASL enabled)`);
return password;
}

function connectIrc(config: Config): Socket {
const port = config.port ?? (config.tls === false ? 6667 : 6697);
if (config.tls === false) return createConnection({ host: config.server, port });
return tlsConnect({ host: config.server, port, servername: config.server });
}

function register(socket: Socket, config: Config, password?: string): void {
const username = sanitizeIrcToken(config.username ?? config.nick);
const nick = sanitizeIrcToken(config.nick);
if (config.sasl) writeLine(socket, 'CAP REQ :sasl');
if (password && !config.sasl) writeLine(socket, `PASS ${password}`);
writeLine(socket, `NICK ${nick}`);
writeLine(socket, `USER ${username} 0 * :${config.realname ?? nick}`);
if (config.sasl && password) {
writeLine(socket, 'AUTHENTICATE PLAIN');
writeLine(socket, `AUTHENTICATE ${Buffer.from(`${username}\0${username}\0${password}`).toString('base64')}`);
writeLine(socket, 'CAP END');
}
}

function parsePrivmsg(line: string): BridgeMessage | undefined {
const match = /^:([^!\s]+)(?:![^\s]+)? PRIVMSG ([^\s]+) :(.+)$/.exec(line);
if (!match) return undefined;
const nick = match[1]!;
const channel = match[2]!;
const text = match[3]!;
return {
id: `irc-${channel}-${Date.now()}`,
channel,
identity: { network: 'irc', username: nick },
text,
timestamp: new Date().toISOString(),
originalNetwork: 'irc',
};
}

function renderIrcMessage(msg: BridgeMessage): string {
const source = `${msg.identity.username} [${msg.originalNetwork ?? msg.identity.network}]`;
const attachments = (msg.attachments ?? []).map((a) => `${a.kind}: ${a.url}`);
return [ `${source}: ${msg.text}`, ...attachments ].filter(Boolean).join(' ');
}

function splitIrcLines(value: string): string[] {
const maxLength = 400;
const chunks: string[] = [];
for (const rawLine of value.split(/\r?\n/)) {
let line = rawLine;
while (line.length > maxLength) {
chunks.push(line.slice(0, maxLength));
line = line.slice(maxLength);
}
if (line) chunks.push(line);
}
return chunks.length > 0 ? chunks : [''];
}

function sanitizeIrcToken(value: string): string {
return value.replace(/[\s\r\n]+/g, '_');
}

function writeLine(socket: Socket, line: string): void {
socket.write(`${line}\r\n`);
}
Loading