feat(channel): support weichat channel (#620)

This commit is contained in:
Haze
2026-03-22 17:08:02 +08:00
committed by GitHub
Unverified
parent f12f4a74df
commit 1e7b40a486
32 changed files with 1610 additions and 156 deletions

View File

@@ -0,0 +1 @@
<?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="1774167686596" class="icon" viewBox="0 0 1309 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2533" xmlns:xlink="http://www.w3.org/1999/xlink" width="255.6640625" height="200"><path d="M1147.26896 912.681417l34.90165 111.318583-127.165111-66.823891a604.787313 604.787313 0 0 1-139.082747 22.263717c-220.607239 0-394.296969-144.615936-394.296969-322.758409s173.526026-322.889372 394.296969-322.889372C1124.219465 333.661082 1309.630388 478.669907 1309.630388 656.550454c0 100.284947-69.344929 189.143369-162.361428 256.130963zM788.070086 511.869037a49.11114 49.11114 0 0 0-46.360916 44.494692 48.783732 48.783732 0 0 0 46.360916 44.494693 52.090549 52.090549 0 0 0 57.983885-44.494693 52.385216 52.385216 0 0 0-57.983885-44.494692z m254.985036 0a48.881954 48.881954 0 0 0-46.09899 44.494692 48.620028 48.620028 0 0 0 46.09899 44.494693 52.385216 52.385216 0 0 0 57.983886-44.494693 52.58166 52.58166 0 0 0-57.951145-44.494692z m-550.568615 150.018161a318.567592 318.567592 0 0 0 14.307712 93.212943c-14.307712 1.080445-28.746387 1.768001-43.283284 1.768001a827.293516 827.293516 0 0 1-162.394168-22.296458l-162.001279 77.955749 46.328175-133.811485C69.410411 600.858422 0 500.507993 0 378.38496 0 166.683208 208.689602 0 463.510935 0c227.908428 0 427.594322 133.18941 467.701752 312.379588a427.463358 427.463358 0 0 0-44.625655-2.619261c-220.24709 0-394.100524 157.74498-394.100525 352.126871zM312.90344 189.143369a64.270111 64.270111 0 0 0-69.803299 55.659291 64.532037 64.532037 0 0 0 69.803299 55.659292 53.694846 53.694846 0 0 0 57.852923-55.659292 53.465661 53.465661 0 0 0-57.852923-55.659291z m324.428188 0a64.040926 64.040926 0 0 0-69.574114 55.659291 64.302852 64.302852 0 0 0 69.574114 55.659292 53.694846 53.694846 0 0 0 57.951145-55.659292 53.465661 53.465661 0 0 0-57.951145-55.659291z" p-id="2534" fill="#515151"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 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'];
}
/**