feat(clawbot): add native WeChat ClawBot protocol engine (clawbot.py) and WechatClawModal UI

Browse Source
This commit is contained in:
Yin Kou committed 2026-11-07 00:51:38 +08:00
1 parent 07d2050c5f
commit 8fa46a2b8d
14 files changed
+1551 -201

No files matched your search

+17 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { BookOpen, LayoutGrid, Moon, Search, Sun, Terminal } from 'lucide-react'; import { BookOpen, LayoutGrid, MessageSquare, Moon, Search, Sun, Terminal } from 'lucide-react';
import { usePortalStore } from '../store/usePortalStore'; import { usePortalStore } from '../store/usePortalStore';
import type { RouteModeId } from '../types/telemetry'; import type { RouteModeId } from '../types/telemetry';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -24,6 +24,7 @@ export const TopRibbon: React.FC = () => {
setRouteMode, setRouteMode,
setViewMode, setViewMode,
setShowAgyModal, setShowAgyModal,
setShowWechatModal,
setShowCmdPalette, setShowCmdPalette,
} = usePortalStore(); } = usePortalStore();
@@ -98,6 +99,21 @@ export const TopRibbon: React.FC = () => {
</kbd> </kbd>
</Button> </Button>
<Button
variant="outline"
onClick={() => setShowWechatModal(true)}
title="连接微信 ClawBot,在微信内直接收发消息与控制 Mac mini"
>
<span
className="w-2 h-2 rounded-full"
style={{
backgroundColor: telemetry.services.clawbot ? 'var(--success)' : 'var(--accent)',
}}
/>
<MessageSquare className="w-3.5 h-3.5" />
<span>微信 ClawBot</span>
</Button>
<Button onClick={() => setShowAgyModal(true)}> <Button onClick={() => setShowAgyModal(true)}>
<Terminal className="w-3.5 h-3.5" /> <Terminal className="w-3.5 h-3.5" />
<span>AGY 终端</span> <span>AGY 终端</span>
@@ -0,0 +1,301 @@
import React, { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import {
CheckCircle2,
MessageSquare,
QrCode,
RefreshCw,
Send,
Smartphone,
Unplug,
} from 'lucide-react';
import { usePortalStore } from '../store/usePortalStore';
import type { ClawbotPublicStatus } from '../types/telemetry';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
export const WechatClawModal: React.FC = () => {
const { showWechatModal, setShowWechatModal, showToast, fetchTelemetry } = usePortalStore();
const [statusData, setStatusData] = useState<ClawbotPublicStatus | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [loadingQr, setLoadingQr] = useState(false);
const [verifyCode, setVerifyCode] = useState('');
const [submittingCode, setSubmittingCode] = useState(false);
const generateNewQr = async () => {
setLoadingQr(true);
try {
const res = await fetch('/api/clawbot/qrcode', { method: 'POST' });
const data = await res.json();
setStatusData(data);
const url = data?.login?.qrcode_url;
if (url) {
const svgUrl = await QRCode.toDataURL(url, {
width: 240,
margin: 2,
color: { dark: '#09090b', light: '#ffffff' },
});
setQrDataUrl(svgUrl);
}
} catch {
showToast('获取微信 ClawBot 二维码失败');
} finally {
setLoadingQr(false);
}
};
// Initial load & polling while modal is open
useEffect(() => {
if (!showWechatModal) return;
let cancelled = false;
const init = async () => {
try {
const r = await fetch('/api/clawbot/status');
const d: ClawbotPublicStatus = await r.json();
if (cancelled) return;
setStatusData(d);
if (!d.connected && (!d.login?.active || !d.login?.qrcode_url)) {
await generateNewQr();
} else if (d.login?.qrcode_url) {
const svgUrl = await QRCode.toDataURL(d.login.qrcode_url, {
width: 240,
margin: 2,
color: { dark: '#09090b', light: '#ffffff' },
});
if (!cancelled) setQrDataUrl(svgUrl);
}
} catch {
// ignore
}
};
init();
const timer = setInterval(async () => {
try {
const r = await fetch('/api/clawbot/status');
if (!r.ok) return;
const d: ClawbotPublicStatus = await r.json();
if (cancelled) return;
setStatusData(d);
if (d.login?.qrcode_url) {
const svgUrl = await QRCode.toDataURL(d.login.qrcode_url, {
width: 240,
margin: 2,
color: { dark: '#09090b', light: '#ffffff' },
});
if (!cancelled) setQrDataUrl(svgUrl);
}
} catch {
// ignore
}
}, 1500);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [showWechatModal]);
const handleVerifySubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!verifyCode.trim()) return;
setSubmittingCode(true);
try {
const res = await fetch('/api/clawbot/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: verifyCode.trim() }),
});
const data = await res.json();
setStatusData(data);
setVerifyCode('');
showToast('配对码已提交,正在等待微信验证...');
} catch {
showToast('配对码提交失败');
} finally {
setSubmittingCode(false);
}
};
const handleDisconnect = async () => {
try {
const res = await fetch('/api/clawbot/disconnect', { method: 'POST' });
const data = await res.json();
setStatusData(data);
setQrDataUrl('');
fetchTelemetry();
showToast('已断开微信 ClawBot 连接');
} catch {
showToast('断开失败');
}
};
const connected = Boolean(statusData?.connected);
const loginState = statusData?.login;
return (
<Dialog open={showWechatModal} onOpenChange={setShowWechatModal}>
<DialogContent className="max-w-lg p-6 space-y-5">
{/* Header */}
<div className="flex items-start justify-between gap-3 border-b hl-border pb-4">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center border hl-border shrink-0"
style={{ backgroundColor: 'rgba(16, 185, 129, 0.14)', color: 'var(--success)' }}
>
<MessageSquare className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<DialogTitle className="text-sm font-bold">
微信 ClawBot · M6 Mac mini 直连通道
</DialogTitle>
<Badge variant={connected ? 'success' : 'accent'}>
{connected ? '已连接在线' : '扫码绑定'}
</Badge>
</div>
<DialogDescription className="text-xs font-mono-num mt-0.5">
@tencent-weixin/openclaw-weixin · ilinkai.weixin.qq.com
</DialogDescription>
</div>
</div>
</div>
{/* Body: Connected vs QR Login */}
{connected ? (
<div className="space-y-4">
<div
className="p-4 rounded-xl border flex items-center justify-between"
style={{
backgroundColor: 'var(--success-soft)',
borderColor: 'rgba(16, 185, 129, 0.3)',
}}
>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 shrink-0" style={{ color: 'var(--success)' }} />
<div>
<div className="text-xs font-bold">微信 ClawBot 长轮询守护线程运行中</div>
<div className="text-[11px] font-mono-num mt-0.5" style={{ color: 'var(--text-muted)' }}>
Bot ID: {statusData?.bot_id} · 累计收发: {statusData?.msg_count ?? 0} 条
</div>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleDisconnect}>
<Unplug className="w-3.5 h-3.5" />
<span>重新绑定</span>
</Button>
</div>
<div className="p-4 rounded-xl hl-muted border hl-border space-y-2.5 text-xs">
<div className="font-semibold">📱 现在打开手机微信「微信 ClawBot」可直接发送:</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 font-mono-num text-[11px]">
<div className="p-2 rounded hl-card">
<span style={{ color: 'var(--accent)' }}>状态</span> · 查看 M6 & 极空间遥测
</div>
<div className="p-2 rounded hl-card">
<span style={{ color: 'var(--accent)' }}>备份</span> · 立即打包备份至 20TB NAS
</div>
<div className="p-2 rounded hl-card">
<span style={{ color: 'var(--accent)' }}>屏幕</span> · 截取 AGY 终端实时画面
</div>
<div className="p-2 rounded hl-card">
<span style={{ color: 'var(--accent)' }}>$ df -h</span> · 执行任意 Mac mini 命令
</div>
<div className="p-2 rounded hl-card sm:col-span-2">
<span style={{ color: 'var(--accent)' }}>&gt; 你好</span> 或直接发任意问题 · 注入 AGY 终端或调用 AI 回答
</div>
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-col items-center justify-center p-5 rounded-xl hl-muted border hl-border space-y-3">
{qrDataUrl ? (
<div className="p-3 bg-white rounded-xl shadow-md border">
<img
src={qrDataUrl}
alt="WeChat ClawBot QR Code"
className="w-48 h-48 block"
/>
</div>
) : (
<div className="w-48 h-48 rounded-xl hl-card flex items-center justify-center text-xs font-mono-num">
{loadingQr ? '正在向微信申请二维码...' : '点击下方按钮生成二维码'}
</div>
)}
<div className="flex items-center gap-2 text-xs font-medium text-center">
<Smartphone className="w-4 h-4 shrink-0" style={{ color: 'var(--accent)' }} />
<span>{loginState?.message || '请使用手机微信扫描上方二维码启用 ClawBot'}</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={generateNewQr}
disabled={loadingQr}
>
<RefreshCw className="w-3.5 h-3.5" />
<span>{loadingQr ? '刷新中...' : '刷新二维码'}</span>
</Button>
{loginState?.qrcode_url && (
<Button variant="ghost" size="sm" asChild>
<a
href={loginState.qrcode_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono-num"
>
<QrCode className="w-3.5 h-3.5" />
<span>打开备用链接 ↗</span>
</a>
</Button>
)}
</div>
</div>
{/* Verify Code Input (if WeChat prompts for pairing digits) */}
{loginState?.status === 'need_verifycode' && (
<form
onSubmit={handleVerifySubmit}
className="p-4 rounded-xl border space-y-2.5"
style={{
backgroundColor: 'var(--accent-soft)',
borderColor: 'var(--accent)',
}}
>
<label className="block text-xs font-bold">
🔢 手机微信上显示了配对数字,请在下方输入:
</label>
<div className="flex items-center gap-2">
<Input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="输入手机微信显示的 6 位数字..."
className="font-mono-num"
autoFocus
/>
<Button type="submit" disabled={submittingCode}>
<Send className="w-3.5 h-3.5" />
<span>验证</span>
</Button>
</div>
</form>
)}
</div>
)}
<div className="flex items-center justify-end pt-1">
<Button variant="outline" onClick={() => setShowWechatModal(false)}>
关闭窗口
</Button>
</div>
</DialogContent>
</Dialog>
);
};