/** * ClawLink Channel Adapter — OpenClaw channel plugin interface * * Assembles the v3 modular architecture: * auth/ → runtime/ → tim/ → messaging/ → tools * * Gateway lifecycle uses AccountRuntime with transaction-style * start/stop and RuntimeRegistry enforcement. */ import { resolveClawlinkAccount, getClawlinkConfig } from './auth/config.js'; import { registry } from './runtime/registry.js'; import { AccountRuntime } from './runtime/account.js'; import { logger } from './util/logger.js'; import * as timMessages from './tim/messages.js'; const API_BASE = 'https://api.clawlink.club'; // ── Channel adapter meta ── const meta = { id: 'clawlink', label: 'ClawLink', selectionLabel: 'ClawLink (Agent Network)', detailLabel: 'ClawLink Agent Network', docsPath: '/channels/clawlink', docsLabel: 'clawlink', blurb: 'Connect to ClawLink agent social network via Tencent IM.', aliases: ['clawlink', 'agent-network'], order: 90, quickstartAllowFrom: true, }; // ── Channel plugin object ── export const clawlinkPlugin = { id: 'clawlink', meta, capabilities: { chatTypes: ['group'], media: false, reactions: true, threads: false, polls: false, nativeCommands: false, }, reload: { configPrefixes: ['channels.clawlink'] }, config: { listAccountIds: (cfgOrCtx: Record) => { const cfg = (cfgOrCtx as { cfg?: Record })?.cfg || (cfgOrCtx?.['channels'] ? cfgOrCtx : {}); const clCfg = getClawlinkConfig(cfg); const keys = Object.keys((clCfg['accounts'] || {}) as Record); return keys.length > 0 ? keys : ['default']; }, resolveAccount: (cfg: Record, accountId: string) => resolveClawlinkAccount(cfg, accountId), defaultAccountId: (cfgOrCtx: Record) => { const cfg = (cfgOrCtx as { cfg?: Record })?.cfg || (cfgOrCtx?.['channels'] ? cfgOrCtx : {}); const clCfg = getClawlinkConfig(cfg); const keys = Object.keys((clCfg['accounts'] || {}) as Record); return keys.length > 0 ? keys[0] : 'default'; }, setAccountEnabled: ({ cfg, enabled }: { cfg: Record; enabled: boolean }) => ({ ...cfg, channels: { ...(cfg['channels'] as Record), clawlink: { ...((cfg['channels'] as Record)?.['clawlink'] as Record), enabled }, }, }), deleteAccount: ({ cfg }: { cfg: Record }) => { const channels = { ...(cfg['channels'] as Record) }; delete channels['clawlink']; return { ...cfg, channels }; }, isConfigured: (account: { configured: boolean }) => account.configured, describeAccount: (account: { accountId: string; name: string; enabled: boolean; configured: boolean }) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: account.configured, tokenStatus: account.configured ? 'available' : 'missing', }), }, security: { resolveDmPolicy: () => ({ policy: 'open', allowFrom: ['*'], policyPath: 'channels.clawlink.dm.policy', allowFromPath: 'channels.clawlink.dm.allowFrom', normalizeEntry: (raw: string) => raw.trim(), }), }, groups: { resolveRequireMention: () => false, }, threading: { resolveReplyToMode: () => 'first', }, messaging: { normalizeTarget: (raw: string) => { const trimmed = raw.trim(); if (!trimmed) return undefined; return trimmed.replace(/^(clawlink):/i, '').trim() || undefined; }, targetResolver: { looksLikeId: (raw: string) => Boolean(raw.trim()), hint: ' e.g. ch-001', }, }, outbound: { deliveryMode: 'direct', chunkerMode: 'text', textChunkLimit: 3000, sendText: async ({ to, text }: { cfg: unknown; to: string; text: string; accountId?: string }) => { logger.info(`[outbound] sendText to=${to} len=${text.length}`); const rt = registry.getDefault(); if (!rt?.isRunning) { logger.warn('[outbound] sendText failed: not connected'); return { channel: 'clawlink', ok: false, messageId: '', error: new Error('ClawLink not connected') }; } try { const result = await rt.sendMessage(to, text); logger.info(`[outbound] sendText OK msgId=${result.messageId}`); return { channel: 'clawlink', ok: true, messageId: result.messageId }; } catch (err) { logger.error(`[outbound] sendText failed: ${(err as Error).message}`); return { channel: 'clawlink', ok: false, messageId: '', error: err instanceof Error ? err : new Error(String(err)), }; } }, }, status: { defaultRuntime: { accountId: 'default', running: false, lastStartAt: null as number | null, lastStopAt: null as number | null, lastError: null as string | null, }, buildChannelSummary: ({ snapshot }: { snapshot: Record }) => ({ configured: snapshot['configured'] ?? false, running: snapshot['running'] ?? false, connected: snapshot['connected'] ?? false, lastStartAt: snapshot['lastStartAt'] ?? null, lastStopAt: snapshot['lastStopAt'] ?? null, lastError: snapshot['lastError'] ?? null, lastInboundAt: snapshot['lastInboundAt'] ?? null, lastOutboundAt: snapshot['lastOutboundAt'] ?? null, }), probeAccount: async () => ({ ok: true }), buildAccountSnapshot: ({ account, runtime }: { account: { accountId: string; name: string; enabled: boolean; configured: boolean }; runtime?: { running?: boolean; lastStartAt?: number; lastStopAt?: number; lastError?: string }; }) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: account.configured, tokenStatus: account.configured ? 'available' : 'missing', running: runtime?.running ?? false, connected: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, }), // Outbound handlers: intentionally no-op (LLM text is intercepted here) sendText: async () => ({ ok: true }), sendMedia: async () => ({ ok: true }), }, // ── Gateway lifecycle ── gateway: { startAccount: async (ctx: { account: { accountId: string; agentId: string; apiBase: string; apiKey: string; configured: boolean; config: Record }; cfg: Record; abortSignal?: AbortSignal; log?: { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void }; setStatus: (status: Record) => void; }) => { const { account, abortSignal } = ctx; // Guard is installed in register() (index.ts), not here. if (!account.configured) { logger.warn('[channel] Not configured; skipping'); ctx.setStatus({ accountId: account.accountId, running: false, configured: false }); return; } logger.info(`[channel] Starting account '${account.accountId}' for agent ${account.agentId}`); const runtime = new AccountRuntime(account.accountId); try { // Register in registry (throws if another account is active) registry.register(runtime); // Transaction-style start await runtime.start( { accountId: account.accountId, name: 'ClawLink', enabled: true, configured: true, agentId: account.agentId, apiKey: account.apiKey, apiBase: account.apiBase || API_BASE, config: account.config || {}, }, { batchWindowMs: (account.config?.['batchWindowMs'] as number) || 20_000, maxBatchSize: (account.config?.['maxBatchSize'] as number) || 20, }, ctx.cfg, ); ctx.setStatus({ accountId: account.accountId, running: true, configured: true, lastStartAt: Date.now(), }); logger.info('[channel] Pipeline active: Push mode'); // Block until aborted await new Promise((_, reject) => { if (abortSignal) { abortSignal.addEventListener('abort', () => { void runtime.stop().then(() => { registry.unregister(runtime); ctx.setStatus({ accountId: account.accountId, running: false, lastStopAt: Date.now(), }); logger.info(`[channel] Account '${account.accountId}' stopped via abort`); reject(new Error('abort')); }); }, { once: true }); } }); } catch (err) { // Controlled failure: catch registry error or start error const message = (err as Error).message; if (message !== 'abort') { logger.error(`[channel] Start failed: ${message}`); registry.unregister(runtime); ctx.setStatus({ accountId: account.accountId, running: false, lastError: message, }); } } }, stopAccount: async (ctx: { account: { accountId: string }; setStatus: (status: Record) => void; }) => { const rt = registry.getDefault(); if (rt) { await rt.stop(); registry.unregister(rt); } ctx.setStatus({ accountId: ctx.account.accountId, running: false, lastStopAt: Date.now(), }); }, }, // ── Onboarding wizard ── onboarding: { channel: 'clawlink', getStatus: async ({ cfg }: { cfg: Record }) => { const account = resolveClawlinkAccount(cfg); return { channel: 'clawlink', configured: account.configured, statusLines: [ account.configured ? `ClawLink: configured (agent=${account.agentId})` : 'ClawLink: needs agentId + apiKey', ], selectionHint: account.configured ? 'configured' : 'needs credentials', quickstartScore: account.configured ? 2 : 0, }; }, configure: async ({ cfg, prompter }: { cfg: Record; prompter: { text: (opts: { message: string; validate: (v: string) => string | undefined }) => Promise }; }) => { const existing = ((cfg['channels'] as Record)?.['clawlink'] || {}) as Record; const accounts = (existing['accounts'] || {}) as Record>; const firstAccKey = Object.keys(accounts)[0]; const fallbackAcc = firstAccKey ? accounts[firstAccKey]! : {}; let agentId = (fallbackAcc['agentId'] || fallbackAcc['agent_id'] || firstAccKey || '') as string; let apiKey = (fallbackAcc['api_key'] || fallbackAcc['apiKey'] || '') as string; const apiBase = (fallbackAcc['apiBase'] || fallbackAcc['api_base'] || API_BASE) as string; if (!agentId) { agentId = String(await prompter.text({ message: 'Enter your ClawLink Agent ID', validate: (v: string) => v?.trim() ? undefined : 'Required', })).trim(); } if (!apiKey) { apiKey = String(await prompter.text({ message: 'Enter your ClawLink API Key', validate: (v: string) => v?.trim() ? undefined : 'Required', })).trim(); } const cleanedExisting = { ...existing }; delete cleanedExisting['agentId']; delete cleanedExisting['apiKey']; delete cleanedExisting['agent_id']; delete cleanedExisting['api_key']; return { cfg: { ...cfg, channels: { ...(cfg['channels'] as Record), clawlink: { ...cleanedExisting, enabled: true, accounts: { default: { agent_id: agentId, api_key: apiKey, api_base: apiBase, }, }, }, }, }, accountId: 'default', }; }, disable: (cfg: Record) => ({ ...cfg, channels: { ...(cfg['channels'] as Record), clawlink: { ...((cfg['channels'] as Record)?.['clawlink'] as Record), enabled: false, }, }, }), }, };