feat(clawbot): add native WeChat ClawBot protocol engine (clawbot.py) and WechatClawModal UI
This commit is contained in:
1 parent
07d2050c5f
commit
8fa46a2b8d
14 files changed
+1551
-201
No files matched your search
@@ -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)' }}>> 你好</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in new issue
Block a user