refactor(v2): move channel adapters off v2 trunk

v2 ships with no channels baked in. All channel adapters (discord, slack,
telegram + helpers, whatsapp, whatsapp-cloud, gchat, github, imessage,
linear, matrix, resend, teams, webex) and their channel-specific setup
steps (pair-telegram, whatsapp-auth) now live on the `channels` branch
and get copied in via /add-*-v2 skills.

Removed:
- src/channels/{discord,slack,telegram*,whatsapp*,gchat,github,imessage,linear,matrix,resend,teams,webex}.ts
- setup/{pair-telegram,whatsapp-auth}.ts
- 14 channel-specific deps from package.json (@chat-adapter/*, @beeper/*,
  @bitbasti/*, @resend/chat-sdk-adapter, @whiskeysockets/baileys,
  chat-adapter-imessage, qrcode, @chat-adapter/state-memory unused)
- Their corresponding STEPS entries from setup/index.ts
- Channel imports from src/channels/index.ts

Kept:
- Channel infra: adapter.ts, channel-registry.ts (+ test), chat-sdk-bridge.ts,
  ask-question.ts, an empty-imports index.ts
- Chat SDK runtime (`chat`) for channels that copy in via Chat SDK bridge
- @chat-adapter/shared promoted from transitive to direct dep
  (channel-registry.ts uses NetworkError from it)

Verified: pnpm run build clean, 326 host tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-17 14:20:28 +03:00
parent 32a973f1cd
commit 437ba63a2f
23 changed files with 9 additions and 6191 deletions

View File

@@ -14,8 +14,6 @@ const STEPS: Record<
container: () => import('./container.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
'pair-telegram': () => import('./pair-telegram.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),

View File

@@ -1,116 +0,0 @@
/**
* Step: pair-telegram — issue a one-time pairing code and wait for the
* operator to send `@botname CODE` from the chat they want to register.
*
* On success, prints platformId / isGroup / pairedUserId / intent. The caller
* (skill) can then wire the chat to an agent group (e.g. via /init-first-agent
* or setup --step register). telegram.ts's inbound interceptor has already
* upserted the paired user and granted owner if no owner existed yet.
*
* The service must already be running so the telegram adapter is polling.
*/
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { DATA_DIR } from '../src/config.js';
import path from 'path';
import {
createPairing,
waitForPairing,
type PairingIntent,
} from '../src/channels/telegram-pairing.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): PairingIntent {
let intent: PairingIntent = 'main';
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--intent': {
const raw = args[++i] || 'main';
if (raw === 'main') {
intent = 'main';
} else if (raw.startsWith('wire-to:')) {
intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) };
} else if (raw.startsWith('new-agent:')) {
intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) };
} else {
throw new Error(`Unknown intent: ${raw}`);
}
break;
}
}
}
return intent;
}
function intentToString(intent: PairingIntent): string {
if (intent === 'main') return 'main';
return `${intent.kind}:${intent.folder}`;
}
export async function run(args: string[]): Promise<void> {
const intent = parseArgs(args);
// Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly
// required for the pairing primitive itself, but the inbound interceptor
// (running in the live service) needs it. Touch it here so a fresh install
// doesn't blow up on the first match.
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db);
const MAX_REGENERATIONS = 5;
let record = await createPairing(intent);
emitStatus('PAIR_TELEGRAM_ISSUED', {
CODE: record.code,
INTENT: intentToString(intent),
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@<botname> ${record.code}" in a group with privacy on).`,
REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`,
});
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
try {
const consumed = await waitForPairing(record.code, {
onAttempt: (a) => {
emitStatus('PAIR_TELEGRAM_ATTEMPT', {
EXPECTED_CODE: record.code,
RECEIVED_CODE: a.candidate,
PLATFORM_ID: a.platformId,
AT: a.at,
});
},
});
emitStatus('PAIR_TELEGRAM', {
STATUS: 'success',
CODE: record.code,
INTENT: intentToString(consumed.intent),
PLATFORM_ID: consumed.consumed!.platformId,
IS_GROUP: consumed.consumed!.isGroup,
PAIRED_USER_ID: consumed.consumed!.adminUserId
? `telegram:${consumed.consumed!.adminUserId}`
: '',
});
return;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const invalidated = /invalidated by wrong code/.test(message);
if (invalidated && regen < MAX_REGENERATIONS) {
record = await createPairing(intent);
emitStatus('PAIR_TELEGRAM_NEW_CODE', {
CODE: record.code,
INTENT: intentToString(intent),
REASON: 'previous code invalidated by wrong attempt',
REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1,
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`,
REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`,
});
continue;
}
emitStatus('PAIR_TELEGRAM', {
STATUS: 'failed',
CODE: record.code,
ERROR: invalidated ? 'max-regenerations-exceeded' : message,
});
process.exit(2);
}
}
}

View File

@@ -1,299 +0,0 @@
/**
* Step: whatsapp-auth — standalone WhatsApp authentication.
*
* Supports three methods:
* --method qr-browser Opens a local HTTP server with a large scannable QR code
* --method qr-terminal Prints QR code in the terminal
* --method pairing-code Requests a pairing code (requires --phone <number>)
*
* On success, credentials are saved to store/auth/ and the process exits.
*/
import fs from 'fs';
import path from 'path';
import http from 'http';
import { createRequire } from 'module';
import pino from 'pino';
import {
makeWASocket,
Browsers,
DisconnectReason,
fetchLatestWaWebVersion,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import { emitStatus } from './status.js';
const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
const baileysLogger = pino({ level: 'silent' });
// proto is not available as a named ESM export — use createRequire (same as v1)
const _require = createRequire(import.meta.url);
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
try {
const _generics = _require('@whiskeysockets/baileys/lib/Utils/generics') as Record<string, unknown>;
_generics.getPlatformId = (browser: string): string => {
const platformType =
proto.DeviceProps.PlatformType[browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType];
return platformType ? platformType.toString() : '1';
};
} catch {
// QR auth still works without this patch
}
type AuthMethod = 'qr-browser' | 'qr-terminal' | 'pairing-code';
function parseArgs(args: string[]): { method: AuthMethod; phone?: string } {
let method: AuthMethod = 'qr-terminal';
let phone: string | undefined;
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--method':
method = args[++i] as AuthMethod;
break;
case '--phone':
phone = args[++i];
break;
}
}
if (method === 'pairing-code' && !phone) {
console.error('--phone is required for pairing-code method');
process.exit(1);
}
return { method, phone };
}
/** Serve a web page with a large QR code. Returns cleanup function. */
function startQrServer(port: number): {
updateQr: (qr: string) => void;
close: () => void;
url: string;
} {
let currentQr = '';
let waitingClients: Array<http.ServerResponse> = [];
const server = http.createServer((_req, res) => {
if (_req.url === '/poll') {
// Long-poll endpoint for QR updates
if (currentQr) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(currentQr);
} else {
waitingClients.push(res);
// Timeout after 30s
setTimeout(() => {
const idx = waitingClients.indexOf(res);
if (idx !== -1) {
waitingClients.splice(idx, 1);
res.writeHead(204);
res.end();
}
}, 30000);
}
return;
}
if (_req.url === '/authenticated') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html><html><body style="display:flex;justify-content:center;align-items:center;height:100vh;margin:0;font-family:system-ui;font-size:2em;color:#22c55e">Authenticated!</body></html>`);
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html>
<html>
<head>
<title>WhatsApp Auth</title>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
<style>
body { display:flex; flex-direction:column; justify-content:center; align-items:center; height:100vh; margin:0; font-family:system-ui; background:#111; color:#fff; }
#qr { margin:2em 0; }
canvas { border-radius: 12px; }
.status { font-size:1.2em; opacity:0.7; }
</style>
</head>
<body>
<h2>Scan with WhatsApp</h2>
<p class="status">Settings → Linked Devices → Link a Device</p>
<div id="qr"></div>
<p class="status" id="timer">Waiting for QR code...</p>
<script>
let lastQr = '';
async function poll() {
try {
const res = await fetch('/poll');
if (res.status === 200) {
const qr = await res.text();
if (qr && qr !== lastQr) {
lastQr = qr;
document.getElementById('qr').innerHTML = '';
QRCode.toCanvas(qr, { width: 400, margin: 2 }, (err, canvas) => {
if (!err) document.getElementById('qr').appendChild(canvas);
});
document.getElementById('timer').textContent = 'QR code ready — scan now';
}
}
} catch {}
setTimeout(poll, 1000);
}
poll();
</script>
</body>
</html>`);
});
server.listen(port, '127.0.0.1');
return {
updateQr(qr: string) {
currentQr = qr;
for (const res of waitingClients) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(qr);
}
waitingClients = [];
},
close() {
server.close();
},
url: `http://127.0.0.1:${port}`,
};
}
export async function run(args: string[]): Promise<void> {
const { method, phone } = parseArgs(args);
// Clean previous auth if present
if (fs.existsSync(path.join(AUTH_DIR, 'creds.json'))) {
emitStatus('WHATSAPP_AUTH', {
STATUS: 'already-authenticated',
AUTH_DIR,
});
return;
}
fs.mkdirSync(AUTH_DIR, { recursive: true });
let qrServer: ReturnType<typeof startQrServer> | undefined;
if (method === 'qr-browser') {
qrServer = startQrServer(9437);
emitStatus('WHATSAPP_AUTH', {
STATUS: 'qr-browser-started',
URL: qrServer.url,
});
// Try to open browser
const { exec } = await import('child_process');
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
exec(`${openCmd} ${qrServer.url}`);
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
emitStatus('WHATSAPP_AUTH', { STATUS: 'failed', ERROR: 'timeout' });
qrServer?.close();
process.exit(1);
}, 120_000);
let succeeded = false;
function succeed(): void {
if (succeeded) return;
succeeded = true;
clearTimeout(timeout);
try { if (fs.existsSync(PAIRING_CODE_FILE)) fs.unlinkSync(PAIRING_CODE_FILE); } catch {}
emitStatus('WHATSAPP_AUTH', { STATUS: 'authenticated' });
qrServer?.close();
resolve();
// Give a moment for creds to flush, then exit
setTimeout(() => process.exit(0), 1000);
}
async function connectSocket(isReconnect = false): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({ version: undefined }));
const sock = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, baileysLogger),
},
printQRInTerminal: false,
logger: baileysLogger,
browser: Browsers.macOS('Chrome'),
});
// Request pairing code only on first connect (not reconnect after 515)
if (!isReconnect && method === 'pairing-code' && phone && !state.creds.registered) {
setTimeout(async () => {
try {
const code = await sock.requestPairingCode(phone);
fs.writeFileSync(PAIRING_CODE_FILE, code, 'utf-8');
emitStatus('WHATSAPP_AUTH', {
STATUS: 'pairing-code-ready',
CODE: code,
REMINDER_TO_ASSISTANT: 'Your next user-visible message MUST include this CODE in plain text.',
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
emitStatus('WHATSAPP_AUTH', { STATUS: 'failed', ERROR: message });
process.exit(1);
}
}, 3000);
}
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
if (method === 'qr-browser' && qrServer) {
qrServer.updateQr(qr);
} else if (method === 'qr-terminal') {
(async () => {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(qr, { type: 'terminal' });
console.log('\nWhatsApp QR code — scan with WhatsApp > Linked Devices:\n');
console.log(qrText);
} catch {
console.log('QR code (raw):', qr);
}
})();
}
}
if (connection === 'open') {
succeed();
sock.end(undefined);
}
if (connection === 'close') {
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode;
if (reason === DisconnectReason.loggedOut) {
clearTimeout(timeout);
emitStatus('WHATSAPP_AUTH', { STATUS: 'failed', ERROR: 'logged_out' });
qrServer?.close();
process.exit(1);
} else if (reason === DisconnectReason.timedOut) {
clearTimeout(timeout);
emitStatus('WHATSAPP_AUTH', { STATUS: 'failed', ERROR: 'qr_timeout' });
qrServer?.close();
process.exit(1);
} else if (reason === 515) {
// 515 = stream error, happens after pairing succeeds but before
// registration completes. Reconnect to finish the handshake.
connectSocket(true);
}
}
});
sock.ev.on('creds.update', saveCreds);
}
connectSocket();
});
}