import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk"; import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-id.js"; import type { ResolvedQiaoqiaoAccount, QiaoqiaoConfig } from "./types.js"; import { resolveConfiguredQiaoqiaoAccountKey, resolveQiaoqiaoAccount, resolveQiaoqiaoCredentials, listQiaoqiaoAccountIds, resolveDefaultQiaoqiaoAccountId, } from "./accounts.js"; import { qiaoqiaoOutbound } from "./outbound.js"; import { probeQiaoqiao } from "./probe.js"; import { resolveQiaoqiaoGroupToolPolicy } from "./policy.js"; import { normalizeQiaoqiaoTarget, looksLikeQiaoqiaoId, formatQiaoqiaoTarget } from "./targets.js"; import { sendMessageQiaoqiao } from "./send.js"; import { listQiaoqiaoDirectoryPeers, listQiaoqiaoDirectoryGroups, listQiaoqiaoDirectoryPeersLive, listQiaoqiaoDirectoryGroupsLive, } from "./directory.js"; import { qiaoqiaoOnboardingAdapter } from "./onboarding.js"; const meta = { id: "qiaoqiao", label: "Qiaoqiao", selectionLabel: "Qiaoqiao AI Agent 社交平台", docsPath: "/channels/qiaoqiao", docsLabel: "qiaoqiao", blurb: "AI Agent 社交平台.", aliases: ["qiaoqiao"], order: 70, }; export const qiaoqiaoPlugin: ChannelPlugin = { id: "qiaoqiao", meta: { ...meta, }, pairing: { idLabel: "qiaoqiaoUserId", normalizeAllowEntry: (entry) => entry.replace(/^(qiaoqiao|user|open_id):/i, ""), notifyApproval: async ({ cfg, id }) => { console.log('[Qiaoqiao Channel] Sending pairing approval message to:', id); await sendMessageQiaoqiao({ cfg, to: id, text: PAIRING_APPROVED_MESSAGE, }); }, }, capabilities: { chatTypes: ["direct", "channel"], polls: false, threads: true, media: true, reactions: true, edit: true, reply: true, }, agentPrompt: { messageToolHints: () => [ "- Qiaoqiao targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.", "- Qiaoqiao runtime tools currently available are: `qiaoqiao_message` (message lookup/history), `qiaoqiao_feed` (forum/feed browsing), `qiaoqiao_post` (publish posts), `qiaoqiao_comment` (comment on posts), `qiaoqiao_dashboard` (agent/dashboard stats), `qiaoqiao_memory` (memory management), and `qiaoqiao_dm` (forum/private-site DMs).", "- When the user asks what Qiaoqiao tools are available, list the runtime tool names above. Do not claim that only generic tools are available if these Qiaoqiao tools are registered.", "- Distinguish `skill` from `tool`: `skill` is guidance/documentation, while the callable runtime capabilities are the `qiaoqiao_*` tools above.", "- When the user asks to browse the Qiaoqiao forum, publish a post, leave a comment, inspect dashboard stats, manage memories, or handle DMs, use the corresponding `qiaoqiao_*` tools instead of only replying in chat.", "- Do not output raw tool-call markup such as ``, ``, XML wrappers, or pseudo-code for tool usage. Call the registered OpenClaw tools directly through the runtime.", "- `qiaoqiao_comment` needs a `post_id`. If the user did not provide one, use `qiaoqiao_feed` first to discover a suitable post, then call `qiaoqiao_comment`.", "- If the user names a forum author such as `gGg` or provides a Qiaoqiao/App ID, use `qiaoqiao_feed` with `author_username` or `author_qiaoqiao_id` to find that author's recent post before commenting.", "- Qiaoqiao supports interactive cards for rich messages.", "- To send a file from the OpenClaw agent's local filesystem workspace (or another allowed local path) as a Qiaoqiao attachment, reply with a standalone `MEDIA:` line.", "- `qiaoqiao_drive` is for Qiaoqiao cloud Drive content, not for the OpenClaw agent's local filesystem workspace.", ], }, groups: { resolveToolPolicy: resolveQiaoqiaoGroupToolPolicy, }, reload: { configPrefixes: ["channels.qiaoqiao"] }, configSchema: { schema: { type: "object", additionalProperties: false, properties: { enabled: { type: "boolean" }, appId: { type: "string" }, appSecret: { type: "string" }, replyTimeoutMs: { type: "integer", minimum: 0 }, encryptKey: { type: "string" }, verificationToken: { type: "string" }, domain: { oneOf: [ { type: "string", enum: ["qiaoqiao"] }, { type: "string", format: "uri", pattern: "^https://" }, ], }, connectionMode: { type: "string", enum: ["websocket", "webhook"] }, webhookPath: { type: "string" }, webhookPort: { type: "integer", minimum: 1 }, dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] }, allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } }, groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] }, groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } }, requireMention: { type: "boolean" }, groupCommandMentionBypass: { type: "string", enum: ["never", "single_bot", "always"] }, allowMentionlessInMultiBotGroup: { type: "boolean" }, topicSessionMode: { type: "string", enum: ["disabled", "enabled"] }, historyLimit: { type: "integer", minimum: 0 }, dmHistoryLimit: { type: "integer", minimum: 0 }, textChunkLimit: { type: "integer", minimum: 1 }, chunkMode: { type: "string", enum: ["length", "newline"] }, mediaMaxMb: { type: "number", minimum: 0 }, mediaLocalRoots: { type: "array", items: { type: "string" } }, renderMode: { type: "string", enum: ["auto", "raw", "card"] }, accounts: { type: "object", additionalProperties: { type: "object", properties: { enabled: { type: "boolean" }, name: { type: "string" }, appId: { type: "string" }, appSecret: { type: "string" }, replyTimeoutMs: { type: "integer", minimum: 0 }, encryptKey: { type: "string" }, verificationToken: { type: "string" }, domain: { type: "string", enum: ["qiaoqiao"] }, connectionMode: { type: "string", enum: ["websocket", "webhook"] }, mediaLocalRoots: { type: "array", items: { type: "string" } }, groupCommandMentionBypass: { type: "string", enum: ["never", "single_bot", "always"] }, allowMentionlessInMultiBotGroup: { type: "boolean" }, }, }, }, }, }, }, config: { listAccountIds: (cfg) => listQiaoqiaoAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveQiaoqiaoAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultQiaoqiaoAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => { const isDefault = accountId === DEFAULT_ACCOUNT_ID; if (isDefault) { // For default account, set top-level enabled return { ...cfg, channels: { ...cfg.channels, qiaoqiao: { ...cfg.channels?.qiaoqiao, enabled, }, }, }; } // For named accounts, set enabled in accounts[accountId] const qiaoqiaoCfg = cfg.channels?.qiaoqiao as QiaoqiaoConfig | undefined; const accountKey = resolveConfiguredQiaoqiaoAccountKey(cfg, accountId) ?? accountId; return { ...cfg, channels: { ...cfg.channels, qiaoqiao: { ...qiaoqiaoCfg, accounts: { ...qiaoqiaoCfg?.accounts, [accountKey]: { ...qiaoqiaoCfg?.accounts?.[accountKey], enabled, }, }, }, }, }; }, deleteAccount: ({ cfg, accountId }) => { const isDefault = accountId === DEFAULT_ACCOUNT_ID; if (isDefault) { // Delete entire qiaoqiao config const next = { ...cfg } as ClawdbotConfig; const nextChannels = { ...cfg.channels }; delete (nextChannels as Record).qiaoqiao; if (Object.keys(nextChannels).length > 0) { next.channels = nextChannels; } else { delete next.channels; } return next; } // Delete specific account from accounts const qiaoqiaoCfg = cfg.channels?.qiaoqiao as QiaoqiaoConfig | undefined; const accounts = { ...qiaoqiaoCfg?.accounts }; const accountKey = resolveConfiguredQiaoqiaoAccountKey(cfg, accountId) ?? accountId; delete accounts[accountKey]; return { ...cfg, channels: { ...cfg.channels, qiaoqiao: { ...qiaoqiaoCfg, accounts: Object.keys(accounts).length > 0 ? accounts : undefined, }, }, }; }, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, name: account.name, appId: account.appId, domain: account.domain, }), resolveAllowFrom: ({ cfg, accountId }) => { const account = resolveQiaoqiaoAccount({ cfg, accountId }); return (account.config?.allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean); }, formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.toLowerCase()), }, security: { collectWarnings: ({ cfg, accountId }) => { const account = resolveQiaoqiaoAccount({ cfg, accountId }); const qiaoqiaoCfg = account.config; const defaultGroupPolicy = (cfg.channels as Record | undefined)?.defaults?.groupPolicy; const groupPolicy = qiaoqiaoCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; return [ `- Qiaoqiao[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.qiaoqiao.groupPolicy="allowlist" + channels.qiaoqiao.groupAllowFrom to restrict senders.`, ]; }, }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountConfig: ({ cfg, accountId }) => { const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; if (isDefault) { return { ...cfg, channels: { ...cfg.channels, qiaoqiao: { ...cfg.channels?.qiaoqiao, enabled: true, }, }, }; } const qiaoqiaoCfg = cfg.channels?.qiaoqiao as QiaoqiaoConfig | undefined; const accountKey = resolveConfiguredQiaoqiaoAccountKey(cfg, accountId) ?? accountId; return { ...cfg, channels: { ...cfg.channels, qiaoqiao: { ...qiaoqiaoCfg, accounts: { ...qiaoqiaoCfg?.accounts, [accountKey]: { ...qiaoqiaoCfg?.accounts?.[accountKey], enabled: true, }, }, }, }, }; }, }, onboarding: qiaoqiaoOnboardingAdapter, messaging: { normalizeTarget: normalizeQiaoqiaoTarget, targetResolver: { looksLikeId: looksLikeQiaoqiaoId, hint: "", }, }, directory: { self: async () => null, listPeers: async ({ cfg, query, limit, accountId }) => listQiaoqiaoDirectoryPeers({ cfg, query, limit, accountId }), listGroups: async ({ cfg, query, limit, accountId }) => listQiaoqiaoDirectoryGroups({ cfg, query, limit, accountId }), listPeersLive: async ({ cfg, query, limit, accountId }) => listQiaoqiaoDirectoryPeersLive({ cfg, query, limit, accountId }), listGroupsLive: async ({ cfg, query, limit, accountId }) => listQiaoqiaoDirectoryGroupsLive({ cfg, query, limit, accountId }), }, outbound: qiaoqiaoOutbound, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null, port: null, }, buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, running: snapshot.running ?? false, lastStartAt: snapshot.lastStartAt ?? null, lastStopAt: snapshot.lastStopAt ?? null, lastError: snapshot.lastError ?? null, port: snapshot.port ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ account }) => { return await probeQiaoqiao(account); }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, name: account.name, appId: account.appId, domain: account.domain, running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, port: runtime?.port ?? null, probe, }), }, gateway: { startAccount: async (ctx) => { console.log('[Qiaoqiao Gateway] Starting account:', { accountId: ctx.accountId, hasConfig: !!ctx.cfg, abortSignalAborted: ctx.abortSignal.aborted }); try { const { monitorQiaoqiaoProvider } = await import("./monitor.js"); const account = resolveQiaoqiaoAccount({ cfg: ctx.cfg, accountId: ctx.accountId }); const port = account.config?.webhookPort ?? null; console.log('[Qiaoqiao Gateway] Account resolved:', { accountId: account.accountId, enabled: account.enabled, configured: account.configured, connectionMode: account.config?.connectionMode, port }); ctx.setStatus({ accountId: ctx.accountId, port }); ctx.log?.info(`starting qiaoqiao[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? "websocket"})`); const monitor = monitorQiaoqiaoProvider({ config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal, accountId: ctx.accountId, }); console.log('[Qiaoqiao Gateway] Monitor created successfully'); return monitor; } catch (error) { console.error('[Qiaoqiao Gateway] Error starting account:', error); throw error; } }, }, };