feat(channel): support weichat channel (#620)
This commit is contained in:
1
src/assets/channels/wechat.svg
Normal file
1
src/assets/channels/wechat.svg
Normal 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 |
@@ -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':
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
50
src/lib/channel-alias.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user