feat: add Tencent WeChat channel plugin and update related configurations

- Added the Tencent WeChat channel plugin (`@tencent-weixin/openclaw-weixin`) to the project dependencies.
- Updated the `pnpm-lock.yaml` to include the new plugin version.
- Enhanced the README files to document the WeChat integration process.
- Implemented QR code login functionality for WeChat in the channel management system.
- Updated UI components to support WeChat channel configuration and display.
- Added localization support for WeChat connection messages in English, Japanese, and Chinese.
This commit is contained in:
Haze
2026-03-22 16:20:41 +08:00
parent f16e8062e1
commit 2ab0e7c386
31 changed files with 1602 additions and 152 deletions

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1774160699860" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2554" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
<path d="M676.325 381.17h5.098c-25.972-114.081-144.421-200.37-286.538-200.37-161.17 0-291.877 110.804-291.877 247.581 0 79.493 44.176 150.247 112.867 195.516l-28.884 85.925 101.702-50.486-1.942-0.971c33.497 11.286 69.905 17.598 108.134 17.598 8.617 0 17.234-0.364 25.729-0.971-7.646-21.117-11.893-43.691-11.893-66.992 0-125.125 119.786-226.828 267.605-226.828zM489.184 319.276c21.482 0 38.836 15.292 38.836 34.102 0 14.563-10.437 27.065-25.122 31.918-4.247 1.456-8.86 2.185-13.714 2.185-21.482 0-38.836-15.292-38.836-34.102s17.476-34.102 38.836-34.102zM315.634 385.419c-4.247 1.456-8.86 2.185-13.714 2.185-21.482 0-38.836-15.292-38.836-34.102s17.355-34.102 38.836-34.102 38.836 15.292 38.836 34.102c0 14.563-10.437 27.065-25.122 31.918z" fill="#515151" p-id="2555"></path>
<path d="M922.45 608.241c0-115.295-110.197-208.865-246.124-208.865s-246.124 93.45-246.124 208.865 110.197 208.865 246.124 208.865c30.462 0 59.711-4.733 86.774-13.349l78.279 42.962-21.117-69.177c61.895-37.866 102.188-99.638 102.188-169.301zM610.546 570.254c-3.519 1.214-7.404 1.821-11.408 1.821-17.962 0-32.526-12.865-32.526-28.641s14.563-28.641 32.526-28.641 32.526 12.865 32.526 28.641c0 12.257-8.738 22.695-21.117 26.821zM769.897 570.254c-3.519 1.214-7.404 1.821-11.408 1.821-17.962 0-32.526-12.865-32.526-28.641s14.563-28.641 32.526-28.641 32.526 12.865 32.526 28.641c0 12.257-8.738 22.695-21.117 26.821z" fill="#515151" p-id="2556"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -32,11 +32,13 @@ import {
type ChannelMeta,
type ChannelConfigField,
} from '@/types/channel';
import { buildQrChannelEventName, usesPluginManagedQrAccounts } from '@/lib/channel-alias';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import telegramIcon from '@/assets/channels/telegram.svg';
import discordIcon from '@/assets/channels/discord.svg';
import whatsappIcon from '@/assets/channels/whatsapp.svg';
import wechatIcon from '@/assets/channels/wechat.svg';
import dingtalkIcon from '@/assets/channels/dingtalk.svg';
import feishuIcon from '@/assets/channels/feishu.svg';
import wecomIcon from '@/assets/channels/wecom.svg';
@@ -95,9 +97,13 @@ export function ChannelConfigModal({
const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null;
const shouldUseCredentialValidation = selectedType !== 'feishu';
const resolvedAccountId = allowEditAccountId
? accountIdInput.trim()
: (accountId ?? (agentId ? (agentId === 'main' ? 'default' : agentId) : undefined));
const usesManagedQrAccounts = usesPluginManagedQrAccounts(selectedType);
const showAccountIdEditor = allowEditAccountId && !usesManagedQrAccounts;
const resolvedAccountId = usesManagedQrAccounts
? (accountId ?? undefined)
: showAccountIdEditor
? accountIdInput.trim()
: (accountId ?? (agentId ? (agentId === 'main' ? 'default' : agentId) : undefined));
useEffect(() => {
setSelectedType(initialSelectedType);
@@ -115,7 +121,6 @@ export function ChannelConfigModal({
setValidationResult(null);
setQrCode(null);
setConnecting(false);
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
return;
}
@@ -195,63 +200,102 @@ export function ChannelConfigModal({
await onChannelSaved?.(channelType);
}, [addChannel, channelName, channels, configValues, fetchChannels, meta?.configFields, onChannelSaved, showChannelName]);
const finishSaveRef = useRef(finishSave);
const onCloseRef = useRef(onClose);
const translateRef = useRef(t);
useEffect(() => {
if (selectedType !== 'whatsapp') return;
finishSaveRef.current = finishSave;
}, [finishSave]);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
translateRef.current = t;
}, [t]);
function normalizeQrImageSource(data: { qr?: string; raw?: string }): string | null {
const qr = typeof data.qr === 'string' ? data.qr.trim() : '';
if (qr) {
if (qr.startsWith('data:image') || qr.startsWith('http://') || qr.startsWith('https://')) {
return qr;
}
return `data:image/png;base64,${qr}`;
}
const raw = typeof data.raw === 'string' ? data.raw.trim() : '';
if (!raw) return null;
if (raw.startsWith('data:image') || raw.startsWith('http://') || raw.startsWith('https://')) {
return raw;
}
return null;
}
useEffect(() => {
if (!selectedType || meta?.connectionType !== 'qr') return;
const channelType = selectedType;
const onQr = (...args: unknown[]) => {
const data = args[0] as { qr: string; raw: string };
void data.raw;
setQrCode(`data:image/png;base64,${data.qr}`);
const data = args[0] as { qr?: string; raw?: string };
const nextQr = normalizeQrImageSource(data);
if (!nextQr) return;
setQrCode(nextQr);
setConnecting(false);
};
const onSuccess = async (...args: unknown[]) => {
const data = args[0] as { accountId?: string } | undefined;
void data?.accountId;
toast.success(t('toast.whatsappConnected'));
toast.success(translateRef.current('toast.qrConnected', { name: CHANNEL_NAMES[channelType] }));
try {
const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', {
method: 'POST',
body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true }, accountId: resolvedAccountId }),
});
if (!saveResult?.success) {
throw new Error(saveResult?.error || 'Failed to save WhatsApp config');
if (channelType === 'whatsapp') {
const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', {
method: 'POST',
body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true }, accountId: resolvedAccountId }),
});
if (!saveResult?.success) {
throw new Error(saveResult?.error || 'Failed to save WhatsApp config');
}
}
try {
await finishSave('whatsapp');
await finishSaveRef.current(channelType);
} catch (postSaveError) {
toast.warning(t('toast.savedButRefreshFailed'));
toast.warning(translateRef.current('toast.savedButRefreshFailed'));
console.warn('Channel saved but post-save refresh failed:', postSaveError);
}
// Gateway restart is already triggered by scheduleGatewayChannelRestart
// in the POST /api/channels/config route handler (debounced). Calling
// restart() here directly races with that debounced restart and the
// config write, which can cause openclaw.json overwrites.
onClose();
onCloseRef.current();
} catch (error) {
toast.error(t('toast.configFailed', { error: String(error) }));
toast.error(translateRef.current('toast.configFailed', { error: String(error) }));
setConnecting(false);
}
};
const onError = (...args: unknown[]) => {
const err = args[0] as string;
toast.error(t('toast.whatsappFailed', { error: err }));
const err = typeof args[0] === 'string'
? args[0]
: String((args[0] as { message?: string } | undefined)?.message || args[0]);
toast.error(translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], error: err }));
setQrCode(null);
setConnecting(false);
};
const removeQrListener = subscribeHostEvent('channel:whatsapp-qr', onQr);
const removeSuccessListener = subscribeHostEvent('channel:whatsapp-success', onSuccess);
const removeErrorListener = subscribeHostEvent('channel:whatsapp-error', onError);
const removeQrListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'qr'), onQr);
const removeSuccessListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'success'), onSuccess);
const removeErrorListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'error'), onError);
return () => {
removeQrListener();
removeSuccessListener();
removeErrorListener();
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
hostApiFetch(`/api/channels/${encodeURIComponent(channelType)}/cancel`, {
method: 'POST',
body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}),
}).catch(() => { });
};
}, [finishSave, onClose, resolvedAccountId, selectedType, t]);
}, [meta?.connectionType, resolvedAccountId, selectedType]);
const handleValidate = async () => {
if (!selectedType || !shouldUseCredentialValidation) return;
@@ -302,7 +346,7 @@ export function ChannelConfigModal({
setValidationResult(null);
try {
if (allowEditAccountId) {
if (showAccountIdEditor) {
const nextAccountId = accountIdInput.trim();
if (!nextAccountId) {
toast.error(t('account.invalidId'));
@@ -318,9 +362,9 @@ export function ChannelConfigModal({
}
if (meta.connectionType === 'qr') {
await hostApiFetch('/api/channels/whatsapp/start', {
await hostApiFetch(`/api/channels/${encodeURIComponent(selectedType)}/start`, {
method: 'POST',
body: JSON.stringify({ accountId: resolvedAccountId || 'default' }),
body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}),
});
return;
}
@@ -513,7 +557,7 @@ export function ChannelConfigModal({
) : qrCode ? (
<div className="text-center space-y-6">
<div className="bg-[#eeece3] dark:bg-muted p-4 rounded-3xl inline-block shadow-sm border border-black/10 dark:border-white/10">
{qrCode.startsWith('data:image') ? (
{qrCode.startsWith('data:image') || qrCode.startsWith('http://') || qrCode.startsWith('https://') ? (
<img src={qrCode} alt="Scan QR Code" className="w-64 h-64 object-contain rounded-2xl" />
) : (
<div className="w-64 h-64 bg-white dark:bg-background rounded-2xl flex items-center justify-center">
@@ -590,7 +634,7 @@ export function ChannelConfigModal({
</div>
)}
{allowEditAccountId && (
{showAccountIdEditor && (
<div className="space-y-2.5">
<Label htmlFor="account-id" className={labelClasses}>{t('account.customIdLabel')}</Label>
<Input
@@ -693,7 +737,7 @@ export function ChannelConfigModal({
onClick={() => {
void handleConnect();
}}
disabled={connecting || !isFormValid() || (allowEditAccountId && !accountIdInput.trim())}
disabled={connecting || !isFormValid() || (showAccountIdEditor && !accountIdInput.trim())}
className={primaryButtonClasses}
>
{connecting ? (
@@ -736,6 +780,8 @@ function ChannelLogo({ type }: { type: ChannelType }) {
return <img src={discordIcon} alt="Discord" className="w-[22px] h-[22px] dark:invert" />;
case 'whatsapp':
return <img src={whatsappIcon} alt="WhatsApp" className="w-[22px] h-[22px] dark:invert" />;
case 'wechat':
return <img src={wechatIcon} alt="WeChat" className="w-[22px] h-[22px] dark:invert" />;
case 'dingtalk':
return <img src={dingtalkIcon} alt="DingTalk" className="w-[22px] h-[22px] dark:invert" />;
case 'feishu':

View File

@@ -22,6 +22,8 @@
"toast": {
"whatsappConnected": "WhatsApp connected successfully",
"whatsappFailed": "WhatsApp connection failed: {{error}}",
"qrConnected": "{{name}} connected successfully",
"qrFailed": "{{name}} connection failed: {{error}}",
"channelSaved": "Channel {{name}} saved",
"channelConnecting": "Connecting to {{name}}...",
"savedButRefreshFailed": "Configuration was saved, but refreshing page data failed. Please refresh manually.",
@@ -157,6 +159,16 @@
"The system will automatically identify your phone number"
]
},
"wechat": {
"description": "Connect personal WeChat with Tencent's official OpenClaw plugin by scanning a QR code",
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GHYZwuPCriF0gWkXwkFcJ9zon3b",
"instructions": [
"Click Generate QR Code to install and enable the official WeChat plugin inside OpenClaw",
"Scan the QR code below with WeChat and confirm the connection on your phone",
"After linking succeeds, a new WeChat ClawBot chat will appear in WeChat automatically",
"You can repeat the QR flow later to add another WeChat account or reconnect an existing one"
]
},
"dingtalk": {
"description": "Connect DingTalk via OpenClaw channel plugin (Stream mode)",
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/Y5eNwiSiZidkLskrwtJc1rUln0b#doxcnDgA78n43DbkiQjI1OqUA7b",

View File

@@ -22,6 +22,8 @@
"toast": {
"whatsappConnected": "WhatsApp が正常に接続されました",
"whatsappFailed": "WhatsApp 接続に失敗しました: {{error}}",
"qrConnected": "{{name}} が正常に接続されました",
"qrFailed": "{{name}} の接続に失敗しました: {{error}}",
"channelSaved": "チャンネル {{name}} が保存されました",
"channelConnecting": "{{name}} に接続中...",
"savedButRefreshFailed": "設定は保存されましたが、画面データの更新に失敗しました。手動で再読み込みしてください。",
@@ -157,6 +159,16 @@
"システムが自動的に電話番号を識別します"
]
},
"wechat": {
"description": "Tencent 公式の OpenClaw プラグインを使い、QRコードをスキャンして個人 WeChat に接続します",
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GHYZwuPCriF0gWkXwkFcJ9zon3b",
"instructions": [
"QRコードを生成すると、ClawX が OpenClaw に公式 WeChat プラグインをインストールして有効化します",
"以下の QR コードを WeChat でスキャンし、スマートフォン側で接続を確認します",
"接続が完了すると、WeChat に新しい「WeChat ClawBot」チャットが自動で表示されます",
"後から同じ QR フローを使って、別の WeChat アカウントを追加したり既存アカウントを再接続したりできます"
]
},
"dingtalk": {
"description": "OpenClaw のチャンネルプラグイン経由で DingTalk に接続しますStream モード)",
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/Y5eNwiSiZidkLskrwtJc1rUln0b#doxcnDgA78n43DbkiQjI1OqUA7b",

View File

@@ -22,6 +22,8 @@
"toast": {
"whatsappConnected": "WhatsApp 连接成功",
"whatsappFailed": "WhatsApp 连接失败: {{error}}",
"qrConnected": "{{name}} 连接成功",
"qrFailed": "{{name}} 连接失败: {{error}}",
"channelSaved": "频道 {{name}} 已保存",
"channelConnecting": "正在连接 {{name}}...",
"savedButRefreshFailed": "配置已保存,但刷新页面数据失败,请手动刷新查看最新状态",
@@ -157,6 +159,16 @@
"系统将自动识别您的手机号"
]
},
"wechat": {
"description": "通过腾讯官方 OpenClaw 插件扫码连接个人微信",
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GHYZwuPCriF0gWkXwkFcJ9zon3b",
"instructions": [
"点击生成二维码ClawX 会在 OpenClaw 中安装并启用官方微信插件",
"使用微信扫描下方二维码,并在手机上确认连接",
"连接成功后,微信里会自动出现新的「微信 ClawBot」对话",
"之后可再次通过扫码流程添加更多微信账号,或重新连接已有账号"
]
},
"dingtalk": {
"description": "通过 OpenClaw 渠道插件连接钉钉Stream 模式)",
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/Y5eNwiSiZidkLskrwtJc1rUln0b#doxcnr8KfaA2mNPeQUeHO83eDPh",

50
src/lib/channel-alias.ts Normal file
View File

@@ -0,0 +1,50 @@
const BLOCKED_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
export const UI_WECHAT_CHANNEL_TYPE = 'wechat';
export const OPENCLAW_WECHAT_CHANNEL_TYPE = 'openclaw-weixin';
export type QrChannelEvent = 'qr' | 'success' | 'error';
export function toOpenClawChannelType(channelType: string): string {
return channelType === UI_WECHAT_CHANNEL_TYPE ? OPENCLAW_WECHAT_CHANNEL_TYPE : channelType;
}
export function toUiChannelType(channelType: string): string {
return channelType === OPENCLAW_WECHAT_CHANNEL_TYPE ? UI_WECHAT_CHANNEL_TYPE : channelType;
}
export function isWechatChannelType(channelType: string | null | undefined): boolean {
return channelType === UI_WECHAT_CHANNEL_TYPE || channelType === OPENCLAW_WECHAT_CHANNEL_TYPE;
}
export function usesPluginManagedQrAccounts(channelType: string | null | undefined): boolean {
return isWechatChannelType(channelType);
}
export function buildQrChannelEventName(channelType: string, event: QrChannelEvent): string {
return `channel:${toUiChannelType(channelType)}-${event}`;
}
function canonicalizeAccountId(value: string): string {
if (VALID_ID_RE.test(value)) return value.toLowerCase();
return value
.toLowerCase()
.replace(INVALID_CHARS_RE, '-')
.replace(LEADING_DASH_RE, '')
.replace(TRAILING_DASH_RE, '')
.slice(0, 64);
}
export function normalizeOpenClawAccountId(value: string | null | undefined, fallback = 'default'): string {
const trimmed = (value ?? '').trim();
if (!trimmed) return fallback;
const normalized = canonicalizeAccountId(trimmed);
if (!normalized || BLOCKED_OBJECT_KEYS.has(normalized)) {
return fallback;
}
return normalized;
}

View File

@@ -15,6 +15,9 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record<string, string> = {
'channel:whatsapp-qr': 'channel:whatsapp-qr',
'channel:whatsapp-success': 'channel:whatsapp-success',
'channel:whatsapp-error': 'channel:whatsapp-error',
'channel:wechat-qr': 'channel:wechat-qr',
'channel:wechat-success': 'channel:wechat-success',
'channel:wechat-error': 'channel:wechat-error',
};
function getEventSource(): EventSource {

View File

@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
import telegramIcon from '@/assets/channels/telegram.svg';
import discordIcon from '@/assets/channels/discord.svg';
import whatsappIcon from '@/assets/channels/whatsapp.svg';
import wechatIcon from '@/assets/channels/wechat.svg';
import dingtalkIcon from '@/assets/channels/dingtalk.svg';
import feishuIcon from '@/assets/channels/feishu.svg';
import wecomIcon from '@/assets/channels/wecom.svg';
@@ -324,6 +325,8 @@ function ChannelLogo({ type }: { type: ChannelType }) {
return <img src={discordIcon} alt="Discord" className="w-[20px] h-[20px] dark:invert" />;
case 'whatsapp':
return <img src={whatsappIcon} alt="WhatsApp" className="w-[20px] h-[20px] dark:invert" />;
case 'wechat':
return <img src={wechatIcon} alt="WeChat" className="w-[20px] h-[20px] dark:invert" />;
case 'dingtalk':
return <img src={dingtalkIcon} alt="DingTalk" className="w-[20px] h-[20px] dark:invert" />;
case 'feishu':

View File

@@ -16,12 +16,14 @@ import {
getPrimaryChannels,
type ChannelType,
} from '@/types/channel';
import { usesPluginManagedQrAccounts } from '@/lib/channel-alias';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import telegramIcon from '@/assets/channels/telegram.svg';
import discordIcon from '@/assets/channels/discord.svg';
import whatsappIcon from '@/assets/channels/whatsapp.svg';
import wechatIcon from '@/assets/channels/wechat.svg';
import dingtalkIcon from '@/assets/channels/dingtalk.svg';
import feishuIcon from '@/assets/channels/feishu.svg';
import wecomIcon from '@/assets/channels/wecom.svg';
@@ -307,14 +309,17 @@ export function Channels() {
variant="outline"
className="h-8 text-xs rounded-full"
onClick={() => {
const nextAccountId = createNewAccountId(
group.channelType,
group.accounts.map((item) => item.accountId),
);
const shouldUseGeneratedAccountId = !usesPluginManagedQrAccounts(group.channelType);
const nextAccountId = shouldUseGeneratedAccountId
? createNewAccountId(
group.channelType,
group.accounts.map((item) => item.accountId),
)
: undefined;
setSelectedChannelType(group.channelType as ChannelType);
setSelectedAccountId(nextAccountId);
setAllowExistingConfigInModal(false);
setAllowEditAccountIdInModal(true);
setAllowEditAccountIdInModal(shouldUseGeneratedAccountId);
setExistingAccountIdsForModal(group.accounts.map((item) => item.accountId));
setInitialConfigValuesForModal(undefined);
setShowConfigModal(true);
@@ -519,6 +524,8 @@ function ChannelLogo({ type }: { type: ChannelType }) {
return <img src={discordIcon} alt="Discord" className="w-[22px] h-[22px] dark:invert" />;
case 'whatsapp':
return <img src={whatsappIcon} alt="WhatsApp" className="w-[22px] h-[22px] dark:invert" />;
case 'wechat':
return <img src={wechatIcon} alt="WeChat" className="w-[22px] h-[22px] dark:invert" />;
case 'dingtalk':
return <img src={dingtalkIcon} alt="DingTalk" className="w-[22px] h-[22px] dark:invert" />;
case 'feishu':

View File

@@ -11,6 +11,7 @@ import {
} from '@/lib/channel-status';
import { useGatewayStore } from './gateway';
import { CHANNEL_NAMES, type Channel, type ChannelType } from '../types/channel';
import { toOpenClawChannelType, toUiChannelType } from '@/lib/channel-alias';
interface AddChannelParams {
type: ChannelType;
@@ -40,6 +41,17 @@ interface ChannelsState {
const reconnectTimers = new Map<string, NodeJS.Timeout>();
const reconnectAttempts = new Map<string, number>();
function splitChannelId(channelId: string): { channelType: string; accountId?: string } {
const separatorIndex = channelId.indexOf('-');
if (separatorIndex === -1) {
return { channelType: channelId };
}
return {
channelType: channelId.slice(0, separatorIndex),
accountId: channelId.slice(separatorIndex + 1),
};
}
export const useChannelsStore = create<ChannelsState>((set, get) => ({
channels: [],
loading: false,
@@ -75,6 +87,8 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
// Parse the complex channels.status response into simple Channel objects
const channelOrder = data.channelOrder || Object.keys(data.channels || {});
for (const channelId of channelOrder) {
const uiChannelId = toUiChannelType(channelId) as ChannelType;
const gatewayChannelId = toOpenClawChannelType(channelId);
const summary = (data.channels as Record<string, unknown> | undefined)?.[channelId] as Record<string, unknown> | undefined;
const configured =
typeof summary?.configured === 'boolean'
@@ -101,14 +115,17 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
: undefined;
channels.push({
id: `${channelId}-${primaryAccount?.accountId || 'default'}`,
type: channelId as ChannelType,
name: primaryAccount?.name || CHANNEL_NAMES[channelId as ChannelType] || channelId,
id: `${uiChannelId}-${primaryAccount?.accountId || 'default'}`,
type: uiChannelId,
name: primaryAccount?.name || CHANNEL_NAMES[uiChannelId] || uiChannelId,
status,
accountId: primaryAccount?.accountId,
error:
(typeof primaryAccount?.lastError === 'string' ? primaryAccount.lastError : undefined) ||
(typeof summaryError === 'string' ? summaryError : undefined),
metadata: {
gatewayChannelId,
},
});
}
@@ -162,7 +179,8 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
deleteChannel: async (channelId) => {
// Extract channel type from the channelId (format: "channelType-accountId")
const channelType = channelId.split('-')[0];
const { channelType } = splitChannelId(channelId);
const gatewayChannelType = toOpenClawChannelType(channelType);
try {
// Delete the channel configuration from openclaw.json
@@ -174,7 +192,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
}
try {
await useGatewayStore.getState().rpc('channels.delete', { channelId: channelType });
await useGatewayStore.getState().rpc('channels.delete', { channelId: gatewayChannelType });
} catch (error) {
// Continue with local deletion even if gateway fails
console.error('Failed to delete channel from gateway:', error);
@@ -191,7 +209,10 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
updateChannel(channelId, { status: 'connecting', error: undefined });
try {
await useGatewayStore.getState().rpc('channels.connect', { channelId });
const { channelType, accountId } = splitChannelId(channelId);
await useGatewayStore.getState().rpc('channels.connect', {
channelId: `${toOpenClawChannelType(channelType)}${accountId ? `-${accountId}` : ''}`,
});
updateChannel(channelId, { status: 'connected' });
} catch (error) {
updateChannel(channelId, { status: 'error', error: String(error) });
@@ -203,7 +224,10 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
clearAutoReconnect(channelId);
try {
await useGatewayStore.getState().rpc('channels.disconnect', { channelId });
const { channelType, accountId } = splitChannelId(channelId);
await useGatewayStore.getState().rpc('channels.disconnect', {
channelId: `${toOpenClawChannelType(channelType)}${accountId ? `-${accountId}` : ''}`,
});
} catch (error) {
console.error('Failed to disconnect channel:', error);
}
@@ -214,7 +238,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
requestQrCode: async (channelType) => {
return await useGatewayStore.getState().rpc<{ qrCode: string; sessionId: string }>(
'channels.requestQr',
{ type: channelType },
{ type: toOpenClawChannelType(channelType) },
);
},

View File

@@ -8,6 +8,7 @@
*/
export type ChannelType =
| 'whatsapp'
| 'wechat'
| 'dingtalk'
| 'telegram'
| 'discord'
@@ -81,6 +82,7 @@ export interface ChannelMeta {
*/
export const CHANNEL_ICONS: Record<ChannelType, string> = {
whatsapp: '📱',
wechat: '💬',
dingtalk: '💬',
telegram: '✈️',
discord: '🎮',
@@ -101,6 +103,7 @@ export const CHANNEL_ICONS: Record<ChannelType, string> = {
*/
export const CHANNEL_NAMES: Record<ChannelType, string> = {
whatsapp: 'WhatsApp',
wechat: 'WeChat',
dingtalk: 'DingTalk',
telegram: 'Telegram',
discord: 'Discord',
@@ -323,6 +326,22 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
'channels:meta.whatsapp.instructions.3',
],
},
wechat: {
id: 'wechat',
name: 'WeChat',
icon: '💬',
description: 'channels:meta.wechat.description',
connectionType: 'qr',
docsUrl: 'channels:meta.wechat.docsUrl',
configFields: [],
instructions: [
'channels:meta.wechat.instructions.0',
'channels:meta.wechat.instructions.1',
'channels:meta.wechat.instructions.2',
'channels:meta.wechat.instructions.3',
],
isPlugin: true,
},
signal: {
id: 'signal',
name: 'Signal',
@@ -561,7 +580,7 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
* Get primary supported channels (non-plugin, commonly used)
*/
export function getPrimaryChannels(): ChannelType[] {
return ['telegram', 'discord', 'whatsapp', 'dingtalk', 'feishu', 'wecom', 'qqbot'];
return ['telegram', 'discord', 'whatsapp', 'wechat', 'dingtalk', 'feishu', 'wecom', 'qqbot'];
}
/**