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:
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user