feat(channels): add WeChat Official Account support

This PR introduces a new extension @haiyanfengli-llc/webhook-server that adds support for WeChat Official Accounts (Weixin Service/Subscription Accounts).

It works by exposing a webhook server that interfaces with a self-hosted WeChat Bridge, allowing Clawdbot to send and receive messages from WeChat users.

Key Changes:

Added extensions/wechat with the plugin implementation.
Implemented fastify server for efficient webhook handling.
Added full documentation in
PLUGIN_USAGE.md
.
AI/Vibe-Coded:

 AI-Assisted: Yes (Google Antigravity)
 Degree of Testing: Lightly tested (Verified with local WeChat Bridge and test account)
 Understanding: Confirmed (I understand how the webhook/bridge architecture works)
Related Links:

Companion Bridge Repository
This commit is contained in:
1bytesweetheart 2026-01-27 21:54:10 +08:00
parent 300cda5d7d
commit b18c6c1a84
8 changed files with 11169 additions and 0 deletions

View File

@ -0,0 +1,54 @@
# Clawdbot WeChat Channel Plugin
Connect your Clawdbot agent to WeChat Official Accounts.
**This plugin is part of the Clawdbot WeChat Integration Suite.**
For full source code, issues, and bridge deployment guide, please visit our GitHub repository:
👉 **[https://github.com/NannaOlympicBroadcast/clawdbot-wechat-plugin](https://github.com/NannaOlympicBroadcast/clawdbot-wechat-plugin)**
---
## 🚀 Installation
Install the plugin from NPM:
```bash
clawdbot plugins install @haiyanfengli-llc/webhook-server
```
## ⚙️ Configuration
Add the following configuration to your Clawdbot `config.yaml`:
```yaml
channels:
wechat:
enabled: true
config:
# Optional: Explicitly set the callback URL if auto-detection fails
# callbackUrl: "http://<bridge-host>:3000/callback"
```
## 🔗 Architecture
This plugin requires the **WeChat Bridge** service to function.
The bridge handles the communication with WeChat servers and forwards messages to this plugin.
1. **WeChat** sends message to **Bridge**.
2. **Bridge** forwards message to **Clawdbot Plugin**.
3. **Clawdbot Agent** processes message.
4. **Clawdbot Plugin** sends reply back to **Bridge**.
5. **Bridge** sends reply to **WeChat**.
Please refer to the [GitHub Repository](https://github.com/NannaOlympicBroadcast/clawdbot-wechat-plugin) for instructions on how to deploy the Bridge.
## 📋 Requirements
* Clawdbot v0.5.0 or later
* Self-hosted WeChat Bridge
* WeChat Service Account (服务号) or verified Subscription Account (认证订阅号)
## 🤝 Commercial Support
For commercial usage, verified builds, or enterprise support, please contact:
📧 **nomorelighthouse@gmail.com**

View File

@ -0,0 +1,65 @@
{
"id": "webhook-server",
"name": "Webhook Server",
"version": "1.0.1",
"description": "Exposes a webhook HTTP server for receiving external messages and triggering agent tasks",
"author": "",
"license": "MIT",
"main": "dist/index.js",
"configSchema": {
"type": "object",
"properties": {
"port": {
"type": "number",
"default": 8765,
"description": "Port number for the webhook server"
},
"host": {
"type": "string",
"default": "0.0.0.0",
"description": "Host address to bind to"
},
"authToken": {
"type": "string",
"description": "Authentication token for incoming requests (Bearer token). Auto-generated if not provided."
},
"timeout": {
"type": "number",
"default": 300000,
"description": "Maximum time (ms) to wait for agent task completion"
},
"agentId": {
"type": "string",
"default": "default",
"description": "Agent ID to use for processing messages (e.g., 'default', 'main')"
}
},
"required": []
},
"configDefaults": {
"port": 8765,
"host": "0.0.0.0",
"authToken": "$auto:uuid",
"timeout": 300000,
"agentId": "default"
},
"uiHints": {
"authToken": {
"label": "Auth Token",
"sensitive": true,
"placeholder": "Auto-generated secure token"
},
"port": {
"label": "Server Port",
"placeholder": "8765"
},
"host": {
"label": "Bind Host",
"placeholder": "0.0.0.0"
},
"timeout": {
"label": "Task Timeout (ms)",
"placeholder": "300000"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
{
"name": "@haiyanfengli-llc/webhook-server",
"version": "1.0.0",
"description": "Clawdbot plugin that exposes a webhook server for external integrations",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc -w",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"files": [
"dist",
"clawdbot.plugin.json"
],
"keywords": [
"clawdbot",
"plugin",
"webhook",
"wechat"
],
"author": "",
"license": "MIT",
"clawdbot": {
"extensions": [
"dist/index.js"
]
},
"dependencies": {
"fastify": "^4.26.1",
"axios": "^1.6.7"
},
"devDependencies": {
"@types/node": "^20.11.19",
"typescript": "^5.3.3",
"vitest": "^1.3.1"
},
"peerDependencies": {
"clawdbot": ">=1.0.0"
}
}

View File

@ -0,0 +1,75 @@
declare module 'clawdbot/plugin-sdk' {
export interface ClawdbotPluginApi {
logger: {
info: (msg: string, ...args: unknown[]) => void;
warn: (msg: string, ...args: unknown[]) => void;
error: (msg: string, ...args: unknown[]) => void;
debug: (msg: string, ...args: unknown[]) => void;
};
config: ClawdbotConfig;
registerChannel: (options: { plugin: ChannelPlugin<any>; dock: ChannelDock }) => void;
registerHttpHandler: (handler: (req: any, res: any) => Promise<boolean> | boolean) => void;
runtime: PluginRuntime;
[key: string]: unknown;
}
export interface ClawdbotConfig {
plugins?: {
entries?: Record<string, { config?: any }>;
};
channels?: Record<string, any>;
[key: string]: unknown;
}
export interface PluginRuntime {
channel: {
routing?: {
resolveAgentRoute?: (params: any) => { sessionKey: string; agentId?: string; accountId?: string };
};
reply?: {
resolveEnvelopeFormatOptions?: (cfg: any) => any;
formatAgentEnvelope?: (params: any) => string;
finalizeInboundContext?: (params: any) => any;
dispatchReplyWithBufferedBlockDispatcher?: (params: {
ctx: any;
cfg: any;
dispatcherOptions: {
deliver: (payload: { text?: string }) => Promise<void>;
onError: (err: unknown, info: { kind: string }) => void;
};
}) => Promise<void>;
};
session?: {
readSessionUpdatedAt?: (params: any) => number | undefined;
resolveStorePath?: (store: any, params: { agentId?: string }) => string;
recordInboundSession?: (params: any) => Promise<void>;
};
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface ChannelPlugin<T> {
id: string;
meta: any;
configSchema: any;
capabilities: any;
config: any;
reload?: any;
[key: string]: unknown;
}
export interface ChannelDock {
id: string;
capabilities: any;
config: any;
[key: string]: unknown;
}
export type MarkdownTableMode = 'code' | 'text' | 'image';
export const emptyPluginConfigSchema: () => any;
export const buildChannelConfigSchema: (schema: any) => any;
export const normalizeAccountId: (id: string | undefined) => string;
export const DEFAULT_ACCOUNT_ID: string;
}

View File

@ -0,0 +1,353 @@
import type {
ClawdbotPluginApi,
ChannelPlugin,
ChannelDock,
ClawdbotConfig,
PluginRuntime,
MarkdownTableMode
} from 'clawdbot/plugin-sdk';
import {
emptyPluginConfigSchema,
buildChannelConfigSchema,
normalizeAccountId,
DEFAULT_ACCOUNT_ID
} from 'clawdbot/plugin-sdk';
import axios from 'axios';
import { IncomingMessage, ServerResponse } from 'http';
import { setRuntime, getRuntime } from './runtime.js';
import { z } from 'zod';
// --- Types ---
interface WeChatConfig {
authToken?: string;
callbackUrl?: string; // Optional default callback URL
allowFrom?: string[];
dmPolicy?: 'open' | 'pairing' | 'disabled';
}
interface WebhookPayload {
task: string;
callback_url?: string; // The bridge might send this
metadata?: {
openid?: string;
msg_type?: string;
msg_id?: string;
timestamp?: number;
nickname?: string;
[key: string]: unknown;
};
}
// --- Runtime Helper ---
type CoreRuntime = PluginRuntime;
// --- Webhook Handler ---
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf8');
resolve(raw ? JSON.parse(raw) : {});
} catch (e) {
reject(e);
}
});
req.on('error', reject);
});
}
// Global map to store callback URLs for sessions if needed,
// though we prefer to pass it through the pipeline via context or assume configuration.
// For this refactor, we'll try to extract it from the context during delivery.
async function handleWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
// Only handle POST /webhook (or configured path)
// The bridge might send to /, so we check method.
if (req.method !== 'POST') return false;
// Simple path check - in a real plugin we might want configurable paths
if (req.url && !req.url.endsWith('/webhook') && req.url !== '/') return false;
try {
const body = await readJsonBody(req) as WebhookPayload;
// Basic Validation
if (!body.task) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing task' }));
return true;
}
// We accept the request immediately
res.statusCode = 202;
res.end(JSON.stringify({ status: 'accepted' }));
// Process in background
processMessageWithPipeline(body).catch(err => {
console.error('Pipeline processing error:', err);
});
return true;
} catch (err) {
console.error('Webhook handler error:', err);
res.statusCode = 500;
res.end('Internal Server Error');
return true;
}
}
// --- Pipeline ---
async function processMessageWithPipeline(payload: WebhookPayload) {
const core = getRuntime();
// Assuming single-tenant/default account for now as per original code
// In a full implementation, we'd resolve the account based on the request (e.g. auth token)
const accountId = DEFAULT_ACCOUNT_ID;
// We need access to the config.
// Since we don't have the full config object passed in, we might need to fetch it from runtime or similar.
// However, `dispatchReplyWithBufferedBlockDispatcher` takes `cfg`.
// In the Zalo example, `processUpdate` receives `config`.
// Here, we'll assume we can get the global config or pass a minimal one.
// For now, we'll try to read it from the core if possible or construct a placeholder.
// NOTE: In the original code, `pluginApi.config` was available.
// In the new SDK structure, we should be careful.
// `core.config` might not be directly exposed.
// BUT! `api.registerChannel` passes `cfg` to methods. `handleWebhookRequest` is outside that flow.
// We strictly need the config.
// WORKAROUND: We will store the latest config in a global variable when the plugin is loaded/reloaded.
const config = _globalConfig || {};
const senderId = payload.metadata?.openid || 'unknown_user';
const senderName = payload.metadata?.nickname || `User ${senderId.slice(0, 4)}`;
const text = payload.task;
const chatId = senderId; // For DM, chat ID is usually user ID
// Construct IDs
const fromLabel = `wechat:${senderId}`;
// Authorization / Pairing Logic
// Simplified: Check allowFrom list
// In a full implementation, follow Zalo's `isSenderAllowed` and `pairing` logic
// Ensure config is not null
const safeConfig = config || {};
const route = core.channel.routing?.resolveAgentRoute?.({
cfg: safeConfig,
channel: 'wechat',
accountId: accountId,
peer: { kind: 'dm', id: chatId }
}) || {
// Fallback if routing fails (e.g. config not updated)
agentId: (safeConfig as any)?.plugins?.entries?.['webhook-server']?.config?.agentId || 'default',
accountId: accountId,
sessionKey: `wechat:${senderId}`
};
if (!route) {
// Should not happen with fallback, but TS check
console.error('Failed to resolve agent route');
return;
}
const sessionKey = route.sessionKey || `wechat:${senderId}`;
// Construct Context
// We need to pass the callback_url through to the delivery phase.
// We can use the 'Ctx' fields or `Originating...` fields if they allow custom data,
// or rely on `InboundContext` having flexible fields.
const callbackUrl = payload.callback_url;
// Get Store Path (using config.session?.store if available)
// We try to access config.session from the global config
const storePath = core.channel.session?.resolveStorePath?.((safeConfig as any).session?.store, { agentId: route.agentId });
// Format Envelope
const envelopeOptions = core.channel.reply?.resolveEnvelopeFormatOptions?.(safeConfig);
const previousTimestamp = core.channel.session?.readSessionUpdatedAt?.({
storePath,
sessionKey
});
const timestamp = payload.metadata?.timestamp ? payload.metadata.timestamp * 1000 : Date.now();
const formattedBody = core.channel.reply?.formatAgentEnvelope?.({
channel: 'WeChat',
from: senderName, // User friendly name
timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: text
}) || text; // Fallback to raw text if formatter missing
const ctxPayload = core.channel.reply?.finalizeInboundContext?.({
Body: formattedBody,
RawBody: text,
From: `wechat:${senderId}`,
To: `wechat:bot`,
SessionKey: sessionKey,
AccountId: accountId,
AgentId: route.agentId || accountId, // Fix: Explicitly pass AgentId
ChatType: 'direct',
ConversationLabel: senderName,
SenderName: senderName,
SenderId: senderId,
Provider: 'wechat',
Surface: 'wechat',
// Pass callback_url here so we can retrieve it in deliver
_CallbackUrl: callbackUrl,
});
if (!ctxPayload) return;
// Record Session
await core.channel.session?.recordInboundSession?.({
storePath,
sessionKey,
ctx: ctxPayload
});
// Dispatch
await core.channel.reply?.dispatchReplyWithBufferedBlockDispatcher?.({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (deliverPayload: { text?: string }) => {
await deliverWeChatReply({
text: deliverPayload.text,
callbackUrl: callbackUrl || (_globalConfig?.channels?.wechat?.config?.callbackUrl),
originalPayload: payload
});
},
onError: (err: unknown, info: { kind: string }) => {
console.error(`WeChat dispatch error (${info.kind}):`, err);
}
}
});
}
// --- Delivery ---
async function deliverWeChatReply(params: {
text?: string;
callbackUrl?: string;
originalPayload: WebhookPayload
}) {
const { text, callbackUrl } = params;
if (!text || !callbackUrl) return;
try {
await axios.post(callbackUrl, {
success: true,
result: text,
// Add metadata if needed by Bridge
metadata: {
// model: ... (Not easily available in this callback without extra context)
}
});
} catch (error) {
console.error(`Failed to deliver reply to ${callbackUrl}:`, error);
}
}
// --- Plugin Definition ---
let _globalConfig: ClawdbotConfig | null = null;
const wechatPlugin: ChannelPlugin<any> = {
id: 'wechat',
meta: {
id: 'wechat',
label: 'WeChat',
selectionLabel: 'WeChat (Bridge)',
description: 'WeChat integration via Bridge Webhook',
docsPath: '',
},
configSchema: buildChannelConfigSchema(
z.object({
callbackUrl: z.string().optional().describe('URL to send replies to (e.g. Bridge URL)'),
}).extend({
accounts: z.object({}).catchall(z.object({
callbackUrl: z.string().optional(),
})).optional(),
defaultAccount: z.string().optional()
})
),
capabilities: {
chatTypes: ['direct'], // Webhook acts like DM usually
media: false, // Set to true if supported
blockStreaming: true, // We prefer full blocks for webhook callbacks mostly
},
// Implement other required methods (minimal implementation)
// ...
config: {
// Minimal config helpers
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg: any) => ({
accountId: DEFAULT_ACCOUNT_ID,
name: 'Default',
enabled: true,
config: cfg?.plugins?.entries?.['webhook-server']?.config || {} // Fallback to old config location or new one?
// Ideally we move config to `channels.wechat`
}),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
isConfigured: () => true, // Always considered configured for now
describeAccount: () => ({ accountId: DEFAULT_ACCOUNT_ID, name: 'Default', enabled: true, configured: true }),
},
// We wrap 'reload' to capture config
reload: {
configPrefixes: ['channels.wechat', 'plugins.entries.webhook-server']
}
};
const wechatDock: ChannelDock = {
id: 'wechat',
capabilities: {
chatTypes: ['direct'],
media: false,
blockStreaming: true
},
config: {
// Helpers for UI
resolveAllowFrom: () => [],
formatAllowFrom: () => []
}
};
// Main Export
export default {
id: 'wechat',
name: 'WeChat',
description: 'WeChat Channel Plugin',
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setRuntime(api.runtime);
_globalConfig = api.config;
// Register as a channel
api.registerChannel({
plugin: wechatPlugin,
dock: wechatDock
});
// Register the HTTP handler for webhooks
// Note: The 'webhook-server' service is no longer needed in this pattern
// as registerHttpHandler hooks into the main server.
api.registerHttpHandler(handleWebhookRequest);
// Keep a listener for config changes if API supports it,
// or just rely on the fact that `api.config` is a reference
// (though usually it's a snapshot at register time).
// Ideally we implement the `reload` capability in the plugin definition.
api.logger.info('WeChat Webhook Channel registered');
}
};

View File

@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("WeChat runtime not initialized");
}
return runtime;
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}