/** * PIT Channel 主文? * @module channel */ import type { ChannelPlugin, ChannelMeta, ChannelCapabilities, ChannelMessagingAdapter, ChannelConfigAdapter, ChannelOutboundAdapter, ChannelGatewayAdapter, ChannelStatusAdapter, OpenClawConfig, } from "openclaw/plugin-sdk"; import type { ResolvedPITBotAccount, PITUserMessage, PITConnectionState } from "./types.js"; import type { Gateway } from "./gateway.js"; import { DEFAULT_ACCOUNT_ID, listPITBotAccountIds, resolvePITBotAccount, applyPITBotAccountConfig, validateConfig, } from "./config.js"; import { startGateway } from "./gateway.js"; import { chunkText } from "./utils/chunker.js"; import { createLogger } from "./utils/logger.js"; import { registerWebUIRoutes } from "./webui/routes.js"; const MODULE = "zhidui-channel"; // Gateway 实例映射 const gateways = new Map(); /** * PIT Bot Channel Plugin */ export const pitBotPlugin: ChannelPlugin = { id: "zhidui-channel", meta: { id: "zhidui-channel", label: "PIT Bot", selectionLabel: "PIT Bot", docsPath: "/docs/channels/pit-bot", blurb: "Connect to PIT Router for multi-agent support", order: 60, } as ChannelMeta, capabilities: { chatTypes: ["direct"], media: true, reactions: false, threads: false, blockStreaming: false, } as ChannelCapabilities, reload: { configPrefixes: ["channels.pit-bot"] }, messaging: { normalizeTarget: (target: string) => { const normalized = target.replace(/^pit-bot:/i, ""); if (normalized.startsWith("user:")) { return { ok: true, to: normalized }; } return { ok: true, to: `user:${normalized}` }; }, targetResolver: { looksLikeId: (id: string) => { const normalized = id.replace(/^pit-bot:/i, ""); return normalized.startsWith("user:") || normalized.length > 0; }, hint: "user: or ", }, } as ChannelMessagingAdapter, config: { listAccountIds: (cfg: OpenClawConfig) => listPITBotAccountIds(cfg), resolveAccount: (cfg: OpenClawConfig, accountId: string) => resolvePITBotAccount(cfg, accountId), defaultAccountId: () => DEFAULT_ACCOUNT_ID, applyAccountName: (cfg: OpenClawConfig, accountId: string, name: string) => { applyPITBotAccountConfig(cfg, accountId, { name }); }, deleteAccount: (cfg: OpenClawConfig, accountId: string) => { const accounts = cfg.getSection>("channels.pit-bot.accounts"); if (accounts && accountId in accounts) { delete accounts[accountId]; cfg.set("channels.pit-bot.accounts", accounts); } }, setAccountEnabled: (cfg: OpenClawConfig, accountId: string, enabled: boolean) => { applyPITBotAccountConfig(cfg, accountId, { enabled }); }, validateAccountConfig: (cfg: OpenClawConfig, accountId: string) => { const account = resolvePITBotAccount(cfg, accountId); return validateConfig(account.config); }, } as ChannelConfigAdapter, outbound: { deliveryMode: "direct", chunker: chunkText, chunkerMode: "markdown", textChunkLimit: 4000, sendText: async ({ to, text, accountId, replyToId }: { to: string; text: string; accountId: string; replyToId?: string }): Promise<{ channel: string; messageId: string; error?: Error }> => { const log = createLogger(`${MODULE}:outbound`); try { const gateway = gateways.get(accountId); if (!gateway) { return { channel: "zhidui-channel", messageId: "", error: new Error(`Gateway not available for account ${accountId}`), }; } const result = await gateway.sendText(to, text, replyToId); return { channel: "zhidui-channel", messageId: result.messageId ?? "", error: result.error ? new Error(result.error) : undefined, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error("sendText failed", { to, accountId, error: message }); return { channel: "zhidui-channel", messageId: "", error: new Error(message), }; } }, sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }: { to: string; text?: string; mediaUrl: string; accountId: string; replyToId?: string }): Promise<{ channel: string; messageId: string; error?: Error }> => { const log = createLogger(`${MODULE}:outbound`); try { const gateway = gateways.get(accountId); if (!gateway) { return { channel: "zhidui-channel", messageId: "", error: new Error(`Gateway not available for account ${accountId}`), }; } const result = await gateway.sendMedia(to, mediaUrl ?? "", text, replyToId); return { channel: "zhidui-channel", messageId: result.messageId ?? "", error: result.error ? new Error(result.error) : undefined, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error("sendMedia failed", { to, accountId, error: message }); return { channel: "zhidui-channel", messageId: "", error: new Error(message), }; } }, } as ChannelOutboundAdapter, gateway: { startAccount: async (ctx: { account: ResolvedPITBotAccount; abortSignal?: AbortSignal; cfg: OpenClawConfig; log?: { info: (msg: string) => void; error: (msg: string) => void }; setStatus: (state: Partial) => void; getStatus: () => PITConnectionState; emitMessage: (msg: unknown) => void; }) => { const { account, abortSignal, cfg } = ctx; const log = createLogger(`${MODULE}:${account.accountId}`); log.info("Starting gateway"); try { const gateway = await startGateway({ account, abortSignal, cfg, log, setStatus: (state) => { const current = ctx.getStatus(); ctx.setStatus({ ...current, ...state }); }, getStatus: () => ctx.getStatus(), onMessage: (message: PITUserMessage) => { ctx.emitMessage({ channel: "zhidui-channel", accountId: account.accountId, chatId: `pit-bot:user:${message.userId}`, chatType: "direct", senderId: message.userId, text: message.content, attachments: message.attachments?.map(a => ({ type: a.type, url: a.url, filename: a.filename, size: a.size, })), replyToId: message.replyTo, metadata: { sessionId: message.sessionId, timestamp: message.timestamp, }, }); }, }); gateways.set(account.accountId, gateway); ctx.setStatus({ ...ctx.getStatus(), running: true, connected: true, }); log.info("Gateway started successfully"); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error("Failed to start gateway", { error: message }); ctx.setStatus({ ...ctx.getStatus(), running: false, connected: false, lastError: message, }); throw error; } }, stopAccount: async (ctx: { account: ResolvedPITBotAccount }) => { const { account } = ctx; const log = createLogger(`${MODULE}:${account.accountId}`); log.info("Stopping gateway"); const gateway = gateways.get(account.accountId); if (gateway) { gateway.disconnect(); gateways.delete(account.accountId); } log.info("Gateway stopped"); }, restartAccount: async (ctx: { account: ResolvedPITBotAccount; abortSignal?: AbortSignal; cfg: OpenClawConfig; setStatus: (state: Partial) => void; getStatus: () => PITConnectionState; emitMessage: (msg: unknown) => void; }) => { const { account } = ctx; const log = createLogger(`${MODULE}:${account.accountId}`); log.info("Restarting gateway"); const gateway = gateways.get(account.accountId); if (gateway) { gateway.disconnect(); gateways.delete(account.accountId); } await pitBotPlugin.gateway!.startAccount!(ctx as never); }, } as ChannelGatewayAdapter, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, connected: false, }, formatStatus: (runtime: PITConnectionState) => { const parts: string[] = []; parts.push(runtime.connected ? "🟢 Connected" : "🔴 Disconnected"); if (runtime.sessionId) { parts.push(`Session: ${runtime.sessionId.slice(0, 8)}...`); } if (runtime.lastError) { parts.push(`Error: ${runtime.lastError}`); } return parts.join(" | "); }, } as ChannelStatusAdapter, // 初始化钩? init: async (api: unknown) => { const log = createLogger(MODULE); log.info("PIT Bot plugin initializing"); registerWebUIRoutes(api as never); log.info("PIT Bot plugin initialized"); }, // 清理钩子 cleanup: async () => { const log = createLogger(MODULE); log.info("PIT Bot plugin cleaning up"); for (const [accountId, gateway] of gateways.entries()) { log.info(`Disconnecting gateway: ${accountId}`); gateway.disconnect(); } gateways.clear(); log.info("PIT Bot plugin cleaned up"); }, }; export default pitBotPlugin;