chore: 项目已迁移至 Gitee/GitHub,仅保留迁移说明 (tombstone)
This commit is contained in:
1 parent
339df4f491
commit
e94ad2328e
10 files changed
+1
-1947
No files matched your search
@@ -1,8 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
.env
|
|
||||||
.venv/
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 shuiyu
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
你好呀~这个仓库已经搬到更好用的地方啦。
|
你好呀~这个仓库已经搬到更好用的地方啦。
|
||||||
|
|
||||||
为了让代码管理更清爽、多平台同步更省心,我们把原先分散的镜像统一收拢到
|
为了让代码管理更清爽、多平台同步更省心,我们把原先分散的镜像统一收拢到主仓维护,这里就只保留这张"迁移小纸条"咯。
|
||||||
**Gitee** 与 **GitHub** 主仓维护,这里就只保留这张"迁移小纸条"咯。
|
|
||||||
|
|
||||||
📦 新家在这里:
|
📦 新家在这里:
|
||||||
- Gitee:https://gitee.com/shuiyu1123/xiaoli-cli-plugins
|
- Gitee:https://gitee.com/shuiyu1123/xiaoli-cli-plugins
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
# DG-Lab 郊狼 WebSocket 控制插件
|
|
||||||
|
|
||||||
> 基于 [DG-LAB-OPENSOURCE](https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE) 官方 Socket V2 协议
|
|
||||||
> 支持郊狼 3.0 脉冲主机的远程强度控制与波形发送
|
|
||||||
|
|
||||||
## 前置准备
|
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install websocket-client # 必须
|
|
||||||
pip install qrcode # 可选,终端显示ASCII二维码
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 安装插件
|
|
||||||
|
|
||||||
将 `dglab_ws.py` 复制到 xiaoli-cli 项目的 `plugins/` 目录下:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp dglab_ws.py /path/to/xiaoli-cli/plugins/
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `plugins_config.py` 中添加:
|
|
||||||
|
|
||||||
```python
|
|
||||||
ENABLE_DGLAB_WS = True
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 启动 WebSocket 服务端
|
|
||||||
|
|
||||||
你需要一个 DG-LAB Socket V2 后端服务。官方参考实现位于 [socket/v2/backend](https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE/tree/main/socket/v2/backend):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd socket/v2/backend
|
|
||||||
npm install
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
默认监听 `ws://0.0.0.0:9999`。
|
|
||||||
|
|
||||||
## 使用教程
|
|
||||||
|
|
||||||
### 连接与配对
|
|
||||||
|
|
||||||
```
|
|
||||||
# 第一步:连接到 WebSocket 服务端
|
|
||||||
/dglab connect ws://192.168.1.100:9999
|
|
||||||
|
|
||||||
# 第二步:生成二维码,用 DG-LAB APP 扫码
|
|
||||||
/dglab qr
|
|
||||||
|
|
||||||
# 查看连接状态
|
|
||||||
/dglab status
|
|
||||||
```
|
|
||||||
|
|
||||||
**配对流程:**
|
|
||||||
1. 本插件连接 WebSocket 服务端,获得 `clientId`
|
|
||||||
2. 生成二维码(含 `clientId`)
|
|
||||||
3. DG-LAB APP 扫码连接服务端,获得 `targetId`
|
|
||||||
4. 服务端自动完成绑定,双方即可通信
|
|
||||||
|
|
||||||
> 💡 APP 端操作:打开 DG-LAB APP → SOCKET 功能 → 扫描二维码
|
|
||||||
|
|
||||||
### 强度控制
|
|
||||||
|
|
||||||
控制 A/B 两个通道的脉冲强度(范围 0-200):
|
|
||||||
|
|
||||||
```
|
|
||||||
# 通道A 增加 10
|
|
||||||
/dglab strength a + 10
|
|
||||||
|
|
||||||
# 通道B 减少 5
|
|
||||||
/dglab strength b - 5
|
|
||||||
|
|
||||||
# 通道A 设为 50
|
|
||||||
/dglab strength a = 50
|
|
||||||
```
|
|
||||||
|
|
||||||
| 操作符 | 说明 | 示例 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `+` | 增加 | `strength a + 10` |
|
|
||||||
| `-` | 减少 | `strength b - 5` |
|
|
||||||
| `=` | 设为指定值 | `strength a = 50` |
|
|
||||||
|
|
||||||
### 波形发送
|
|
||||||
|
|
||||||
发送参数化波形脉冲:
|
|
||||||
|
|
||||||
```
|
|
||||||
# 通道A: 频率30, 强度50, 持续5秒
|
|
||||||
/dglab pulse a 30 50 5
|
|
||||||
|
|
||||||
# 通道B: 频率100, 强度80, 持续10秒
|
|
||||||
/dglab pulse b 100 80 10
|
|
||||||
```
|
|
||||||
|
|
||||||
**参数说明:**
|
|
||||||
|
|
||||||
| 参数 | 范围 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| 频率 | 10-1000 | 脉冲频率,内部自动映射到 10-240 |
|
|
||||||
| 强度 | 0-100 | 波形强度 |
|
|
||||||
| 持续时间 | 1-60 | 秒数,默认5秒 |
|
|
||||||
|
|
||||||
### 原始波形数据
|
|
||||||
|
|
||||||
如果你有现成的波形 HEX 数据,可以直接发送:
|
|
||||||
|
|
||||||
```
|
|
||||||
# 每条波形 = 8字节 = 16个HEX字符
|
|
||||||
# 格式: 频率x4 + 强度x4
|
|
||||||
/dglab wave a 0A0A0A0A64646464
|
|
||||||
```
|
|
||||||
|
|
||||||
### 清空波形队列
|
|
||||||
|
|
||||||
```
|
|
||||||
/dglab clear a # 清空通道A
|
|
||||||
/dglab clear b # 清空通道B
|
|
||||||
/dglab clear both # 清空双通道
|
|
||||||
```
|
|
||||||
|
|
||||||
### 断开连接
|
|
||||||
|
|
||||||
```
|
|
||||||
/dglab disconnect
|
|
||||||
```
|
|
||||||
|
|
||||||
## 命令速查
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `/dglab connect <ws_url>` | 连接 WebSocket 服务器 |
|
|
||||||
| `/dglab qr` | 生成配对二维码 |
|
|
||||||
| `/dglab status` | 查看状态 |
|
|
||||||
| `/dglab strength <a/b> <+/−/=> <值>` | 控制强度 |
|
|
||||||
| `/dglab pulse <a/b> <频率> <强度> [秒数]` | 发送波形 |
|
|
||||||
| `/dglab wave <a/b> <HEX数据>` | 发送原始波形 |
|
|
||||||
| `/dglab clear <a/b/both>` | 清空波形队列 |
|
|
||||||
| `/dglab disconnect` | 断开连接 |
|
|
||||||
| `/dglab help` | 显示帮助 |
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**Q: 连接超时怎么办?**
|
|
||||||
A: 检查 WebSocket 服务端是否启动,防火墙是否放行端口,地址格式是否为 `ws://ip:port`。
|
|
||||||
|
|
||||||
**Q: 配对失败 (400)?**
|
|
||||||
A: 该 clientId 已被其他 APP 绑定。断开重连会生成新的 clientId。
|
|
||||||
|
|
||||||
**Q: 强度设为软上限以上会怎样?**
|
|
||||||
A: 实际强度不会超过软上限。比如软上限 100,设为 150 实际只会到 100。
|
|
||||||
|
|
||||||
**Q: 波形数据格式是什么?**
|
|
||||||
A: 每条 8 字节 HEX,前 4 字节是频率,后 4 字节是强度。详见 [官方波形说明](https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE/blob/main/coyote/extra/README.md)。
|
|
||||||
|
|
||||||
## 测试(无需真机)
|
|
||||||
|
|
||||||
本插件附带一个 **APP 模拟器**,可以模拟 DG-LAB APP 端行为,无需真实设备即可测试。
|
|
||||||
|
|
||||||
### 测试步骤
|
|
||||||
|
|
||||||
```
|
|
||||||
终端1: 启动 WebSocket 服务端
|
|
||||||
cd socket/v2/backend && npm start
|
|
||||||
|
|
||||||
终端2: 启动 APP 模拟器
|
|
||||||
python dglab_app_simulator.py ws://127.0.0.1:9999
|
|
||||||
|
|
||||||
终端3: 在 xiaoli-cli 中测试
|
|
||||||
/dglab connect ws://127.0.0.1:9999
|
|
||||||
/dglab qr
|
|
||||||
/dglab strength a + 10
|
|
||||||
/dglab pulse a 30 50 5
|
|
||||||
/dglab status
|
|
||||||
```
|
|
||||||
|
|
||||||
模拟器会:
|
|
||||||
- 模拟 APP 连接 WebSocket 服务端
|
|
||||||
- 自动接受配对请求
|
|
||||||
- 实时显示收到的强度/波形指令
|
|
||||||
- 回传强度状态(和真机行为一致)
|
|
||||||
|
|
||||||
## 参考资料
|
|
||||||
|
|
||||||
- [DG-LAB 官方开源仓库](https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE)
|
|
||||||
- [Socket V2 协议文档](https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE/blob/main/socket/v2/README.md)
|
|
||||||
- [V3 蓝牙协议](https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE/blob/main/coyote/v3/README_V3.md)
|
|
||||||
- [PyDG-Lab-WS (Python库)](https://pypi.org/project/pydglab-ws/)
|
|
||||||
- [XToys.app 集成](https://xtoys.app)
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
"""
|
|
||||||
DG-Lab 郊狼 APP 模拟器
|
|
||||||
模拟 DG-LAB APP 端连接 WebSocket 服务端,用于测试 dglab_ws 插件
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python dglab_app_simulator.py [ws_url]
|
|
||||||
|
|
||||||
ws_url: WebSocket 服务端地址,默认 ws://127.0.0.1:9999
|
|
||||||
|
|
||||||
流程:
|
|
||||||
1. 先启动 WebSocket 服务端 (socket/v2/backend)
|
|
||||||
2. 运行本模拟器,它会自动连接并等待配对
|
|
||||||
3. 在 xiaoli-cli 中用 /dglab connect 连接同一服务端
|
|
||||||
4. 用 /dglab qr 生成二维码,本模拟器会自动响应绑定请求
|
|
||||||
5. 开始测试各种命令
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
import threading
|
|
||||||
|
|
||||||
try:
|
|
||||||
import websocket
|
|
||||||
except ImportError:
|
|
||||||
print("❌ 需要安装 websocket-client: pip install websocket-client")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
class DGLabAppSimulator:
|
|
||||||
"""模拟 DG-LAB APP"""
|
|
||||||
|
|
||||||
def __init__(self, ws_url="ws://127.0.0.1:9999"):
|
|
||||||
self.ws_url = ws_url
|
|
||||||
self.ws = None
|
|
||||||
self.target_id = ""
|
|
||||||
self.client_id = "" # 绑定的第三方终端 ID
|
|
||||||
self.connected = False
|
|
||||||
self.paired = False
|
|
||||||
|
|
||||||
# 模拟设备状态
|
|
||||||
self.strength_a = 0
|
|
||||||
self.strength_b = 0
|
|
||||||
self.limit_a = 100
|
|
||||||
self.limit_b = 100
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""启动模拟器"""
|
|
||||||
self.target_id = str(uuid.uuid4())
|
|
||||||
connect_url = f"{self.ws_url}/{self.target_id}"
|
|
||||||
|
|
||||||
print(f"🐺 DG-Lab APP 模拟器")
|
|
||||||
print(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
|
||||||
print(f" 服务端: {self.ws_url}")
|
|
||||||
print(f" targetId: {self.target_id}")
|
|
||||||
print(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
|
||||||
print(f"⏳ 正在连接...")
|
|
||||||
|
|
||||||
self.ws = websocket.WebSocketApp(
|
|
||||||
connect_url,
|
|
||||||
on_message=self._on_message,
|
|
||||||
on_error=self._on_error,
|
|
||||||
on_close=self._on_close,
|
|
||||||
on_open=self._on_open
|
|
||||||
)
|
|
||||||
|
|
||||||
# 心跳线程
|
|
||||||
threading.Thread(target=self._heartbeat, daemon=True).start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.ws.run_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n👋 已退出")
|
|
||||||
|
|
||||||
def _on_open(self, ws):
|
|
||||||
self.connected = True
|
|
||||||
print(f"✅ 已连接到 WebSocket 服务端")
|
|
||||||
print(f"📱 等待第三方终端发起配对...")
|
|
||||||
print(f" (在 xiaoli-cli 中执行: /dglab connect {self.ws_url})")
|
|
||||||
print()
|
|
||||||
|
|
||||||
def _on_close(self, ws, code, reason):
|
|
||||||
self.connected = False
|
|
||||||
self.paired = False
|
|
||||||
print(f"🔌 连接已断开 (code={code})")
|
|
||||||
|
|
||||||
def _on_error(self, ws, error):
|
|
||||||
print(f"❌ 错误: {error}")
|
|
||||||
|
|
||||||
def _on_message(self, ws, raw):
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return
|
|
||||||
|
|
||||||
msg_type = data.get("type", "")
|
|
||||||
msg = data.get("message", "")
|
|
||||||
cid = data.get("clientId", "")
|
|
||||||
tid = data.get("targetId", "")
|
|
||||||
|
|
||||||
if msg_type == "bind":
|
|
||||||
self._handle_bind(data)
|
|
||||||
|
|
||||||
elif msg_type == "msg":
|
|
||||||
self._handle_msg(data)
|
|
||||||
|
|
||||||
elif msg_type == "heartbeat":
|
|
||||||
# 回复心跳
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif msg_type == "error":
|
|
||||||
print(f" ❌ 服务端错误: {msg}")
|
|
||||||
|
|
||||||
elif msg_type == "break":
|
|
||||||
self.paired = False
|
|
||||||
self.client_id = ""
|
|
||||||
print(f" ⚠️ 第三方终端已断开")
|
|
||||||
|
|
||||||
def _handle_bind(self, data):
|
|
||||||
"""处理绑定请求"""
|
|
||||||
msg = data.get("message", "")
|
|
||||||
cid = data.get("clientId", "")
|
|
||||||
tid = data.get("targetId", "")
|
|
||||||
|
|
||||||
if msg == "200":
|
|
||||||
self.paired = True
|
|
||||||
self.client_id = cid
|
|
||||||
print(f" ✅ 配对成功! 与终端 {cid[:8]}... 绑定")
|
|
||||||
print(f" 现在可以在 xiaoli-cli 中使用 /dglab 命令了")
|
|
||||||
print()
|
|
||||||
self._print_status()
|
|
||||||
elif msg == "400":
|
|
||||||
print(f" ❌ 配对失败: ID 已被绑定")
|
|
||||||
else:
|
|
||||||
# 新的绑定请求,APP 端自动接受
|
|
||||||
print(f" 📨 收到绑定请求: clientId={cid[:8]}...")
|
|
||||||
# 回复绑定成功
|
|
||||||
bind_ok = {
|
|
||||||
"type": "bind",
|
|
||||||
"clientId": cid,
|
|
||||||
"targetId": self.target_id,
|
|
||||||
"message": "200"
|
|
||||||
}
|
|
||||||
ws.send(json.dumps(bind_ok))
|
|
||||||
self.paired = True
|
|
||||||
self.client_id = cid
|
|
||||||
print(f" ✅ 已自动接受配对")
|
|
||||||
|
|
||||||
def _handle_msg(self, data):
|
|
||||||
"""处理来自第三方终端的指令"""
|
|
||||||
msg = data.get("message", "")
|
|
||||||
|
|
||||||
if msg.startswith("strength-"):
|
|
||||||
self._handle_strength(msg)
|
|
||||||
elif msg.startswith("pulse-"):
|
|
||||||
self._handle_pulse(msg)
|
|
||||||
elif msg.startswith("clear-"):
|
|
||||||
self._handle_clear(msg)
|
|
||||||
else:
|
|
||||||
print(f" 📨 收到指令: {msg}")
|
|
||||||
|
|
||||||
def _handle_strength(self, msg):
|
|
||||||
"""处理强度指令: strength-通道+模式+数值"""
|
|
||||||
try:
|
|
||||||
parts = msg.replace("strength-", "").split("+")
|
|
||||||
channel = int(parts[0]) # 1=A, 2=B
|
|
||||||
mode = int(parts[1]) # 0=减少, 1=增加, 2=设为
|
|
||||||
value = int(parts[2])
|
|
||||||
|
|
||||||
ch_name = "A" if channel == 1 else "B"
|
|
||||||
|
|
||||||
if mode == 0: # 减少
|
|
||||||
if channel == 1:
|
|
||||||
self.strength_a = max(0, self.strength_a - value)
|
|
||||||
else:
|
|
||||||
self.strength_b = max(0, self.strength_b - value)
|
|
||||||
print(f" 📉 通道{ch_name} 强度 -{value}")
|
|
||||||
|
|
||||||
elif mode == 1: # 增加
|
|
||||||
if channel == 1:
|
|
||||||
self.strength_a = min(self.limit_a, self.strength_a + value)
|
|
||||||
else:
|
|
||||||
self.strength_b = min(self.limit_b, self.strength_b + value)
|
|
||||||
print(f" 📈 通道{ch_name} 强度 +{value}")
|
|
||||||
|
|
||||||
elif mode == 2: # 设为
|
|
||||||
if channel == 1:
|
|
||||||
self.strength_a = min(self.limit_a, value)
|
|
||||||
else:
|
|
||||||
self.strength_b = min(self.limit_b, value)
|
|
||||||
print(f" 🎯 通道{ch_name} 强度设为 {value}")
|
|
||||||
|
|
||||||
# 回传当前强度
|
|
||||||
self._send_strength_feedback()
|
|
||||||
|
|
||||||
except (ValueError, IndexError) as e:
|
|
||||||
print(f" ❌ 强度指令解析失败: {msg} ({e})")
|
|
||||||
|
|
||||||
def _handle_pulse(self, msg):
|
|
||||||
"""处理波形指令: pulse-A:[hex_data,...]"""
|
|
||||||
try:
|
|
||||||
content = msg.replace("pulse-", "")
|
|
||||||
ch = content[0] # A or B
|
|
||||||
json_str = content[2:] # 跳过 "A:"
|
|
||||||
data = json.loads(json_str)
|
|
||||||
count = len(data)
|
|
||||||
print(f" 🌊 通道{ch} 收到 {count} 条波形数据")
|
|
||||||
if count > 0:
|
|
||||||
sample = data[0]
|
|
||||||
print(f" 示例: {sample}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 波形指令解析失败: {msg} ({e})")
|
|
||||||
|
|
||||||
def _handle_clear(self, msg):
|
|
||||||
"""处理清空指令: clear-1 / clear-2"""
|
|
||||||
channel = msg.replace("clear-", "")
|
|
||||||
if channel == "1":
|
|
||||||
print(f" 🗑️ 通道A 波形队列已清空")
|
|
||||||
elif channel == "2":
|
|
||||||
print(f" 🗑️ 通道B 波形队列已清空")
|
|
||||||
|
|
||||||
def _send_strength_feedback(self):
|
|
||||||
"""回传当前强度状态"""
|
|
||||||
if not self.paired or not self.ws:
|
|
||||||
return
|
|
||||||
feedback = {
|
|
||||||
"type": "msg",
|
|
||||||
"clientId": self.client_id,
|
|
||||||
"targetId": self.target_id,
|
|
||||||
"message": f"strength-{self.strength_a}+{self.strength_b}+{self.limit_a}+{self.limit_b}"
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
self.ws.send(json.dumps(feedback))
|
|
||||||
self._print_status()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _print_status(self):
|
|
||||||
"""打印当前状态"""
|
|
||||||
print(f" ┌─────────────────────────┐")
|
|
||||||
print(f" │ 通道A: {self.strength_a:>3}/{self.limit_a:<3} │")
|
|
||||||
print(f" │ 通道B: {self.strength_b:>3}/{self.limit_b:<3} │")
|
|
||||||
print(f" └─────────────────────────┘")
|
|
||||||
|
|
||||||
def _heartbeat(self):
|
|
||||||
"""心跳"""
|
|
||||||
while self.connected:
|
|
||||||
time.sleep(60)
|
|
||||||
if self.ws and self.connected:
|
|
||||||
try:
|
|
||||||
hb = {
|
|
||||||
"type": "heartbeat",
|
|
||||||
"clientId": self.client_id,
|
|
||||||
"targetId": self.target_id,
|
|
||||||
"message": "ping"
|
|
||||||
}
|
|
||||||
self.ws.send(json.dumps(hb))
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
url = sys.argv[1] if len(sys.argv) > 1 else "ws://127.0.0.1:9999"
|
|
||||||
sim = DGLabAppSimulator(url)
|
|
||||||
sim.start()
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
"""
|
|
||||||
DG-Lab Coyote WebSocket 控制插件
|
|
||||||
基于 DG-LAB-OPENSOURCE 官方 Socket V2 协议
|
|
||||||
支持郊狼 3.0 脉冲主机的 WebSocket 远程控制
|
|
||||||
|
|
||||||
参考文档:
|
|
||||||
- https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE
|
|
||||||
- Socket V2 协议: socket/v2/README.md
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
import uuid
|
|
||||||
import os
|
|
||||||
|
|
||||||
try:
|
|
||||||
import websocket
|
|
||||||
HAS_WS = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_WS = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
import qrcode
|
|
||||||
HAS_QRCODE = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_QRCODE = False
|
|
||||||
|
|
||||||
|
|
||||||
class Liugin:
|
|
||||||
"""DG-Lab 郊狼 WebSocket 控制插件"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.ws = None
|
|
||||||
self.client_id = ""
|
|
||||||
self.target_id = ""
|
|
||||||
self.connected = False
|
|
||||||
self.paired = False
|
|
||||||
self.ws_url = ""
|
|
||||||
self.strength_a = 0
|
|
||||||
self.strength_b = 0
|
|
||||||
self.limit_a = 100
|
|
||||||
self.limit_b = 100
|
|
||||||
self._heartbeat_thread = None
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
self.usage = """DG-Lab 郊狼控制插件 使用说明:
|
|
||||||
|
|
||||||
/dglab connect <ws_url> - 连接到 WebSocket 服务器
|
|
||||||
/dglab qr - 生成配对二维码 (需APP扫码)
|
|
||||||
/dglab status - 查看连接状态和通道强度
|
|
||||||
/dglab strength <ch> <+|-|=|set> <val> - 控制通道强度
|
|
||||||
ch: a/b (通道A/B)
|
|
||||||
操作: +增加, -减少, =设置为指定值
|
|
||||||
示例: /dglab strength a + 10
|
|
||||||
/dglab strength b = 50
|
|
||||||
/dglab pulse <ch> <freq> <intensity> <duration> - 发送波形
|
|
||||||
ch: a/b
|
|
||||||
freq: 频率 (10-240)
|
|
||||||
intensity: 强度 (0-100)
|
|
||||||
duration: 持续秒数
|
|
||||||
示例: /dglab pulse a 30 50 5
|
|
||||||
/dglab wave <ch> <hex_data> - 发送原始波形HEX数据
|
|
||||||
示例: /dglab wave a 0A0A0A0A64646464
|
|
||||||
/dglab clear <ch> - 清空通道波形队列 (a/b/both)
|
|
||||||
/dglab limit <ch> <val> - 设置通道强度软上限 (0-200)
|
|
||||||
/dglab disconnect - 断开连接
|
|
||||||
"""
|
|
||||||
|
|
||||||
def set_cli(self, cli):
|
|
||||||
"""注册 CLI 命令"""
|
|
||||||
self.cli = cli
|
|
||||||
if hasattr(cli, 'register_liugin_command'):
|
|
||||||
cli.register_liugin_command('dglab', self._cmd_handler)
|
|
||||||
|
|
||||||
def _cmd_handler(self, args):
|
|
||||||
"""处理 /dglab 命令"""
|
|
||||||
parts = args.strip().split(maxsplit=1)
|
|
||||||
if not parts:
|
|
||||||
return self.handle("help")
|
|
||||||
return self.handle(args)
|
|
||||||
|
|
||||||
def get_tool_info(self):
|
|
||||||
return {
|
|
||||||
"name": "dglab_ws",
|
|
||||||
"description": "DG-Lab 郊狼 3.0 WebSocket 控制插件,支持连接、配对、强度控制、波形发送",
|
|
||||||
"keywords": [
|
|
||||||
"dg-lab", "dglab", "郊狼", "coyote", "websocket",
|
|
||||||
"脉冲", "强度", "波形", "蓝牙", "控制", "情趣"
|
|
||||||
],
|
|
||||||
"usage": self.usage
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_mcp_definition(self):
|
|
||||||
return {
|
|
||||||
"name": "dglab_ws",
|
|
||||||
"description": "DG-Lab Coyote 3.0 WebSocket controller plugin",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"connect", "disconnect", "status",
|
|
||||||
"strength", "pulse", "wave",
|
|
||||||
"clear", "limit", "qr"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"args": {"type": "string", "description": "命令参数"}
|
|
||||||
},
|
|
||||||
"required": ["action"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def convert_mcp_args(self, arguments):
|
|
||||||
action = arguments.get("action", "")
|
|
||||||
args = arguments.get("args", "")
|
|
||||||
return f"{action} {args}"
|
|
||||||
|
|
||||||
# ── 核心协议方法 ──────────────────────────────────────
|
|
||||||
|
|
||||||
def _on_message(self, ws, raw):
|
|
||||||
"""处理服务端消息"""
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return
|
|
||||||
|
|
||||||
msg_type = data.get("type", "")
|
|
||||||
msg = data.get("message", "")
|
|
||||||
cid = data.get("clientId", "")
|
|
||||||
tid = data.get("targetId", "")
|
|
||||||
|
|
||||||
if msg_type == "bind":
|
|
||||||
if cid and not self.client_id:
|
|
||||||
self.client_id = cid
|
|
||||||
if msg == "200":
|
|
||||||
self.paired = True
|
|
||||||
self.target_id = tid
|
|
||||||
print(f"\n[DG-Lab] ✅ 配对成功! targetId={tid}")
|
|
||||||
elif msg == "400":
|
|
||||||
print("\n[DG-Lab] ❌ 配对失败: 该ID已被绑定")
|
|
||||||
else:
|
|
||||||
print(f"\n[DG-Lab] 绑定状态: {msg}")
|
|
||||||
|
|
||||||
elif msg_type == "msg":
|
|
||||||
if msg.startswith("strength-"):
|
|
||||||
self._parse_strength(msg)
|
|
||||||
elif msg.startswith("feedback-"):
|
|
||||||
btn = msg.split("-")[1]
|
|
||||||
print(f"\n[DG-Lab] APP反馈按钮: {btn}")
|
|
||||||
|
|
||||||
elif msg_type == "heartbeat":
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif msg_type == "break":
|
|
||||||
self.paired = False
|
|
||||||
print("\n[DG-Lab] ⚠️ APP端已断开连接")
|
|
||||||
|
|
||||||
elif msg_type == "error":
|
|
||||||
print(f"\n[DG-Lab] ❌ 错误: {msg}")
|
|
||||||
|
|
||||||
def _on_error(self, ws, error):
|
|
||||||
print(f"\n[DG-Lab] WebSocket错误: {error}")
|
|
||||||
|
|
||||||
def _on_close(self, ws, code, reason):
|
|
||||||
self.connected = False
|
|
||||||
self.paired = False
|
|
||||||
self._running = False
|
|
||||||
print(f"\n[DG-Lab] 连接已关闭 (code={code}, reason={reason})")
|
|
||||||
|
|
||||||
def _on_open(self, ws):
|
|
||||||
self.connected = True
|
|
||||||
print("\n[DG-Lab] ✅ 已连接到WebSocket服务器")
|
|
||||||
self._running = True
|
|
||||||
self._heartbeat_thread = threading.Thread(
|
|
||||||
target=self._heartbeat_loop, daemon=True
|
|
||||||
)
|
|
||||||
self._heartbeat_thread.start()
|
|
||||||
|
|
||||||
def _heartbeat_loop(self):
|
|
||||||
"""每60秒发送心跳"""
|
|
||||||
while self._running and self.connected:
|
|
||||||
time.sleep(60)
|
|
||||||
if self.ws and self.connected:
|
|
||||||
try:
|
|
||||||
hb = {
|
|
||||||
"type": "heartbeat",
|
|
||||||
"clientId": self.client_id,
|
|
||||||
"targetId": self.target_id,
|
|
||||||
"message": "ping"
|
|
||||||
}
|
|
||||||
self.ws.send(json.dumps(hb))
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
|
|
||||||
def _parse_strength(self, msg):
|
|
||||||
"""解析APP回传的强度: strength-A+B+A上限+B上限"""
|
|
||||||
try:
|
|
||||||
vals = msg.replace("strength-", "").split("+")
|
|
||||||
if len(vals) >= 4:
|
|
||||||
self.strength_a = int(vals[0])
|
|
||||||
self.strength_b = int(vals[1])
|
|
||||||
self.limit_a = int(vals[2])
|
|
||||||
self.limit_b = int(vals[3])
|
|
||||||
print(
|
|
||||||
f"\n[DG-Lab] 📊 强度状态: "
|
|
||||||
f"A={self.strength_a}/{self.limit_a} "
|
|
||||||
f"B={self.strength_b}/{self.limit_b}"
|
|
||||||
)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _send_to_app(self, msg_type, message):
|
|
||||||
"""发送消息到服务端 (转发给APP)"""
|
|
||||||
if not self.connected or not self.ws:
|
|
||||||
return False, "未连接到服务器"
|
|
||||||
if not self.paired:
|
|
||||||
return False, "尚未与APP配对,请先用APP扫码"
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"type": msg_type,
|
|
||||||
"clientId": self.client_id,
|
|
||||||
"targetId": self.target_id,
|
|
||||||
"message": message
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
self.ws.send(json.dumps(payload))
|
|
||||||
return True, "ok"
|
|
||||||
except Exception as e:
|
|
||||||
return False, str(e)
|
|
||||||
|
|
||||||
def _send_strength(self, channel, mode, value):
|
|
||||||
"""
|
|
||||||
发送强度控制指令
|
|
||||||
channel: 1=A, 2=B
|
|
||||||
mode: 0=减少, 1=增加, 2=设为指定值
|
|
||||||
value: 0-200
|
|
||||||
"""
|
|
||||||
msg = f"strength-{channel}+{mode}+{value}"
|
|
||||||
return self._send_to_app("msg", msg)
|
|
||||||
|
|
||||||
def _send_pulse(self, channel, freq, intensity, duration=5):
|
|
||||||
"""生成并发送波形数据"""
|
|
||||||
ch = "A" if channel.lower() == "a" else "B"
|
|
||||||
|
|
||||||
def map_freq(f):
|
|
||||||
f = max(10, min(1000, f))
|
|
||||||
if f <= 100:
|
|
||||||
return f
|
|
||||||
elif f <= 600:
|
|
||||||
return (f - 100) // 5 + 100
|
|
||||||
else:
|
|
||||||
return (f - 600) // 10 + 200
|
|
||||||
|
|
||||||
freq_byte = map_freq(freq)
|
|
||||||
intensity_byte = max(0, min(100, intensity))
|
|
||||||
|
|
||||||
wave_hex = ""
|
|
||||||
for _ in range(25 * max(1, duration)):
|
|
||||||
wave_hex += f"{freq_byte:02X}{intensity_byte:02X}" * 4
|
|
||||||
|
|
||||||
pulse_data = []
|
|
||||||
for i in range(0, len(wave_hex), 16):
|
|
||||||
chunk = wave_hex[i:i + 16]
|
|
||||||
if len(chunk) == 16:
|
|
||||||
pulse_data.append(chunk)
|
|
||||||
|
|
||||||
if not pulse_data:
|
|
||||||
pulse_data = [f"{freq_byte:02X}" * 4 + f"{intensity_byte:02X}" * 4]
|
|
||||||
|
|
||||||
batch_size = min(len(pulse_data), 100)
|
|
||||||
message = f"{ch}:{json.dumps(pulse_data[:batch_size])}"
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"type": "clientMsg",
|
|
||||||
"channel": ch,
|
|
||||||
"time": duration,
|
|
||||||
"message": message,
|
|
||||||
"clientId": self.client_id,
|
|
||||||
"targetId": self.target_id
|
|
||||||
}
|
|
||||||
|
|
||||||
if not self.connected or not self.ws:
|
|
||||||
return False, "未连接到服务器"
|
|
||||||
if not self.paired:
|
|
||||||
return False, "尚未与APP配对"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.ws.send(json.dumps(payload))
|
|
||||||
return True, f"已发送 {batch_size} 条波形数据到通道{ch},持续{duration}秒"
|
|
||||||
except Exception as e:
|
|
||||||
return False, str(e)
|
|
||||||
|
|
||||||
def _send_raw_wave(self, channel, hex_data):
|
|
||||||
"""发送原始HEX波形数据"""
|
|
||||||
ch = "A" if channel.lower() == "a" else "B"
|
|
||||||
pulse_data = []
|
|
||||||
hex_clean = hex_data.replace(" ", "").replace(",", "")
|
|
||||||
for i in range(0, len(hex_clean), 16):
|
|
||||||
chunk = hex_clean[i:i + 16]
|
|
||||||
if len(chunk) == 16:
|
|
||||||
pulse_data.append(chunk)
|
|
||||||
|
|
||||||
if not pulse_data:
|
|
||||||
return False, "波形数据不足8字节 (需要16个HEX字符)"
|
|
||||||
|
|
||||||
message = f"{ch}:{json.dumps(pulse_data[:100])}"
|
|
||||||
payload = {
|
|
||||||
"type": "clientMsg",
|
|
||||||
"channel": ch,
|
|
||||||
"time": len(pulse_data) // 25 + 1,
|
|
||||||
"message": message,
|
|
||||||
"clientId": self.client_id,
|
|
||||||
"targetId": self.target_id
|
|
||||||
}
|
|
||||||
|
|
||||||
if not self.connected or not self.ws:
|
|
||||||
return False, "未连接到服务器"
|
|
||||||
if not self.paired:
|
|
||||||
return False, "尚未与APP配对"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.ws.send(json.dumps(payload))
|
|
||||||
return True, f"已发送 {len(pulse_data)} 条波形数据到通道{ch}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, str(e)
|
|
||||||
|
|
||||||
# ── 命令入口 ──────────────────────────────────────────
|
|
||||||
|
|
||||||
def handle(self, args):
|
|
||||||
"""处理工具请求"""
|
|
||||||
parts = args.strip().split(maxsplit=1)
|
|
||||||
if not parts:
|
|
||||||
return self.usage
|
|
||||||
|
|
||||||
action = parts[0].lower()
|
|
||||||
rest = parts[1] if len(parts) > 1 else ""
|
|
||||||
|
|
||||||
if action == "help":
|
|
||||||
return self.usage
|
|
||||||
|
|
||||||
elif action == "connect":
|
|
||||||
return self._do_connect(rest.strip())
|
|
||||||
|
|
||||||
elif action == "disconnect":
|
|
||||||
return self._do_disconnect()
|
|
||||||
|
|
||||||
elif action == "qr":
|
|
||||||
return self._do_qr()
|
|
||||||
|
|
||||||
elif action == "status":
|
|
||||||
return self._do_status()
|
|
||||||
|
|
||||||
elif action == "strength":
|
|
||||||
return self._do_strength(rest)
|
|
||||||
|
|
||||||
elif action == "pulse":
|
|
||||||
return self._do_pulse(rest)
|
|
||||||
|
|
||||||
elif action == "wave":
|
|
||||||
return self._do_wave(rest)
|
|
||||||
|
|
||||||
elif action == "clear":
|
|
||||||
return self._do_clear(rest)
|
|
||||||
|
|
||||||
elif action == "limit":
|
|
||||||
return self._do_limit(rest)
|
|
||||||
|
|
||||||
else:
|
|
||||||
return f"未知操作: {action}\n" + self.usage
|
|
||||||
|
|
||||||
def _do_connect(self, url):
|
|
||||||
"""连接WebSocket服务器"""
|
|
||||||
if not HAS_WS:
|
|
||||||
return "❌ 缺少 websocket-client 库,请运行: pip install websocket-client"
|
|
||||||
|
|
||||||
if self.connected:
|
|
||||||
return f"已连接到 {self.ws_url},请先 disconnect"
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
return "请提供WebSocket地址,如: ws://192.168.1.100:9999"
|
|
||||||
|
|
||||||
self.ws_url = url
|
|
||||||
self.client_id = str(uuid.uuid4())
|
|
||||||
connect_url = f"{url}/{self.client_id}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.ws = websocket.WebSocketApp(
|
|
||||||
connect_url,
|
|
||||||
on_message=self._on_message,
|
|
||||||
on_error=self._on_error,
|
|
||||||
on_close=self._on_close,
|
|
||||||
on_open=self._on_open
|
|
||||||
)
|
|
||||||
t = threading.Thread(target=self.ws.run_forever, daemon=True)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
for _ in range(30):
|
|
||||||
if self.connected:
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
if self.connected:
|
|
||||||
return (
|
|
||||||
f"✅ 已连接到 {url}\n"
|
|
||||||
f"clientId: {self.client_id}\n"
|
|
||||||
f"请使用 /dglab qr 生成二维码供APP扫码配对"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return f"❌ 连接超时,请检查地址是否正确: {url}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"❌ 连接失败: {e}"
|
|
||||||
|
|
||||||
def _do_disconnect(self):
|
|
||||||
"""断开连接"""
|
|
||||||
if self.ws:
|
|
||||||
self._running = False
|
|
||||||
try:
|
|
||||||
self.ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.connected = False
|
|
||||||
self.paired = False
|
|
||||||
return "已断开连接"
|
|
||||||
return "当前没有连接"
|
|
||||||
|
|
||||||
def _do_qr(self):
|
|
||||||
"""生成配对二维码"""
|
|
||||||
if not self.connected or not self.client_id:
|
|
||||||
return "❌ 请先连接到WebSocket服务器"
|
|
||||||
|
|
||||||
qr_content = (
|
|
||||||
f"https://www.dungeon-lab.com/app-download.php"
|
|
||||||
f"#DGLAB-SOCKET#{self.ws_url}/{self.client_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = f"📱 配对二维码内容:\n{qr_content}\n\n"
|
|
||||||
result += "请用 DG-LAB APP 扫描此二维码进行配对\n"
|
|
||||||
|
|
||||||
if HAS_QRCODE:
|
|
||||||
try:
|
|
||||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
|
||||||
qr.add_data(qr_content)
|
|
||||||
qr.make(fit=True)
|
|
||||||
qr.print_ascii(invert=True)
|
|
||||||
result += "\n(已在终端显示ASCII二维码)"
|
|
||||||
except Exception as e:
|
|
||||||
result += f"\n(二维码生成失败: {e})"
|
|
||||||
else:
|
|
||||||
result += "\n💡 安装 qrcode 库可在终端显示ASCII二维码: pip install qrcode"
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _do_status(self):
|
|
||||||
"""查看状态"""
|
|
||||||
lines = ["📊 DG-Lab 连接状态:"]
|
|
||||||
lines.append(f" 连接: {'✅ 已连接' if self.connected else '❌ 未连接'}")
|
|
||||||
lines.append(f" 配对: {'✅ 已配对' if self.paired else '❌ 未配对'}")
|
|
||||||
if self.connected:
|
|
||||||
lines.append(f" 服务器: {self.ws_url}")
|
|
||||||
lines.append(f" clientId: {self.client_id}")
|
|
||||||
if self.paired:
|
|
||||||
lines.append(f" targetId: {self.target_id}")
|
|
||||||
lines.append(f" 通道A强度: {self.strength_a}/{self.limit_a}")
|
|
||||||
lines.append(f" 通道B强度: {self.strength_b}/{self.limit_b}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def _do_strength(self, args):
|
|
||||||
"""控制强度: /dglab strength <ch> <op> <val>"""
|
|
||||||
parts = args.strip().split()
|
|
||||||
if len(parts) < 3:
|
|
||||||
return "格式: strength <a/b> <+|-|=> <值>\n示例: strength a + 10"
|
|
||||||
|
|
||||||
ch = parts[0].lower()
|
|
||||||
op = parts[1]
|
|
||||||
try:
|
|
||||||
val = int(parts[2])
|
|
||||||
except ValueError:
|
|
||||||
return "强度值必须是整数"
|
|
||||||
|
|
||||||
channel = 1 if ch == "a" else 2
|
|
||||||
|
|
||||||
if op == "+":
|
|
||||||
ok, msg = self._send_strength(channel, 1, val)
|
|
||||||
return f"{'✅' if ok else '❌'} 通道{ch.upper()} 强度+{val}" if ok else f"❌ {msg}"
|
|
||||||
elif op == "-":
|
|
||||||
ok, msg = self._send_strength(channel, 0, val)
|
|
||||||
return f"{'✅' if ok else '❌'} 通道{ch.upper()} 强度-{val}" if ok else f"❌ {msg}"
|
|
||||||
elif op in ("=", "set"):
|
|
||||||
val = max(0, min(200, val))
|
|
||||||
ok, msg = self._send_strength(channel, 2, val)
|
|
||||||
return f"{'✅' if ok else '❌'} 通道{ch.upper()} 强度设为 {val}" if ok else f"❌ {msg}"
|
|
||||||
else:
|
|
||||||
return f"未知操作: {op},支持 +, -, ="
|
|
||||||
|
|
||||||
def _do_pulse(self, args):
|
|
||||||
"""发送波形: /dglab pulse <ch> <freq> <intensity> <duration>"""
|
|
||||||
parts = args.strip().split()
|
|
||||||
if len(parts) < 3:
|
|
||||||
return "格式: pulse <a/b> <频率10-240> <强度0-100> [持续秒数]"
|
|
||||||
|
|
||||||
ch = parts[0].lower()
|
|
||||||
try:
|
|
||||||
freq = int(parts[1])
|
|
||||||
intensity = int(parts[2])
|
|
||||||
duration = int(parts[3]) if len(parts) > 3 else 5
|
|
||||||
except ValueError:
|
|
||||||
return "参数必须是整数"
|
|
||||||
|
|
||||||
ok, msg = self._send_pulse(ch, freq, intensity, duration)
|
|
||||||
return f"✅ {msg}" if ok else f"❌ {msg}"
|
|
||||||
|
|
||||||
def _do_wave(self, args):
|
|
||||||
"""发送原始波形HEX: /dglab wave <ch> <hex>"""
|
|
||||||
parts = args.strip().split(maxsplit=1)
|
|
||||||
if len(parts) < 2:
|
|
||||||
return "格式: wave <a/b> <HEX数据>\n示例: wave a 0A0A0A0A64646464"
|
|
||||||
|
|
||||||
ch = parts[0].lower()
|
|
||||||
hex_data = parts[1].strip()
|
|
||||||
|
|
||||||
ok, msg = self._send_raw_wave(ch, hex_data)
|
|
||||||
return f"✅ {msg}" if ok else f"❌ {msg}"
|
|
||||||
|
|
||||||
def _do_clear(self, args):
|
|
||||||
"""清空波形队列"""
|
|
||||||
ch = args.strip().lower()
|
|
||||||
if ch in ("a", "1"):
|
|
||||||
ok, msg = self._send_to_app("msg", "clear-1")
|
|
||||||
return "✅ 已清空通道A波形队列" if ok else f"❌ {msg}"
|
|
||||||
elif ch in ("b", "2"):
|
|
||||||
ok, msg = self._send_to_app("msg", "clear-2")
|
|
||||||
return "✅ 已清空通道B波形队列" if ok else f"❌ {msg}"
|
|
||||||
elif ch == "both":
|
|
||||||
self._send_to_app("msg", "clear-1")
|
|
||||||
self._send_to_app("msg", "clear-2")
|
|
||||||
return "✅ 已清空AB双通道波形队列"
|
|
||||||
else:
|
|
||||||
return "格式: clear <a/b/both>"
|
|
||||||
|
|
||||||
def _do_limit(self, args):
|
|
||||||
"""设置软上限"""
|
|
||||||
parts = args.strip().split()
|
|
||||||
if len(parts) < 2:
|
|
||||||
return "格式: limit <a/b> <0-200>"
|
|
||||||
|
|
||||||
ch = parts[0].lower()
|
|
||||||
try:
|
|
||||||
val = max(0, min(200, int(parts[1])))
|
|
||||||
except ValueError:
|
|
||||||
return "上限值必须是0-200的整数"
|
|
||||||
|
|
||||||
if ch in ("a", "1"):
|
|
||||||
return (
|
|
||||||
f"⚠️ 软上限需要通过蓝牙BF指令设置\n"
|
|
||||||
f" 当前通道A上限: {self.limit_a}\n"
|
|
||||||
f" 请在DG-LAB APP中调整软上限"
|
|
||||||
)
|
|
||||||
elif ch in ("b", "2"):
|
|
||||||
return (
|
|
||||||
f"⚠️ 软上限需要通过蓝牙BF指令设置\n"
|
|
||||||
f" 当前通道B上限: {self.limit_b}\n"
|
|
||||||
f" 请在DG-LAB APP中调整软上限"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return "格式: limit <a/b> <0-200>"
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# OneBot v11 QQ 机器人插件
|
|
||||||
|
|
||||||
> 让 xiaoli-cli 成为 QQ 机器人,通过 OneBot 协议连接 QQ 消息平台
|
|
||||||
|
|
||||||
## 什么是 OneBot
|
|
||||||
|
|
||||||
[OneBot](https://github.com/botuniverse/onebot) 是一个聊天机器人应用标准,支持 QQ、微信等平台。常见的 OneBot 实现:
|
|
||||||
|
|
||||||
| 实现 | 语言 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| [go-cqhttp](https://github.com/Mrs4s/go-cqhttp) | Go | 最流行,功能全 |
|
|
||||||
| [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core) | C# | 轻量,NTQQ 协议 |
|
|
||||||
| [LLOneBot](https://github.com/LLOneBot/LLOneBot) | TypeScript | QQNT 插件形式 |
|
|
||||||
| [NapCat](https://github.com/NapNeko/NapCatQQ) | TypeScript | 另一个 QQNT 方案 |
|
|
||||||
|
|
||||||
## 前置准备
|
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install websocket-client
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 安装 OneBot 实现
|
|
||||||
|
|
||||||
以 Lagrange 为例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 下载 Lagrange
|
|
||||||
# https://github.com/LagrangeDev/Lagrange.Core/releases
|
|
||||||
|
|
||||||
# 编辑配置 appsettings.json,设置正向 WebSocket
|
|
||||||
{
|
|
||||||
"Implementations": [{
|
|
||||||
"Type": "ForwardWebSocket",
|
|
||||||
"Host": "0.0.0.0",
|
|
||||||
"Port": 8080
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
# 启动,扫码登录
|
|
||||||
./Lagrange.Core
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 安装插件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式1: 通过插件码安装
|
|
||||||
/plugin install onebot
|
|
||||||
|
|
||||||
# 方式2: 手动复制
|
|
||||||
cp onebot/onebot.py /path/to/xiaoli-cli/plugins/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用教程
|
|
||||||
|
|
||||||
### 方式一:正向 WebSocket(推荐)
|
|
||||||
|
|
||||||
xiaoli-cli 主动连接 OneBot 服务:
|
|
||||||
|
|
||||||
```
|
|
||||||
# 连接
|
|
||||||
/onebot connect ws://127.0.0.1:8080
|
|
||||||
|
|
||||||
# 查看状态
|
|
||||||
/onebot status
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式二:反向 WebSocket
|
|
||||||
|
|
||||||
xiaoli-cli 监听端口,等 OneBot 主动连接:
|
|
||||||
|
|
||||||
```
|
|
||||||
# 启动监听(默认 8765 端口)
|
|
||||||
/onebot listen 8765
|
|
||||||
```
|
|
||||||
|
|
||||||
然后在 OneBot 配置中设置反向 WebSocket 地址为 `ws://你的IP:8765`。
|
|
||||||
|
|
||||||
### 配置触发方式
|
|
||||||
|
|
||||||
```
|
|
||||||
# 只响应 @机器人 的消息(默认)
|
|
||||||
/onebot config trigger_mode at
|
|
||||||
|
|
||||||
# 响应所有消息
|
|
||||||
/onebot config trigger_mode all
|
|
||||||
|
|
||||||
# 响应以特定前缀开头的消息
|
|
||||||
/onebot config trigger_mode prefix
|
|
||||||
/onebot config trigger_prefix !
|
|
||||||
```
|
|
||||||
|
|
||||||
### 群白名单
|
|
||||||
|
|
||||||
```
|
|
||||||
# 只在指定群响应
|
|
||||||
/onebot config group_whitelist 123456789
|
|
||||||
/onebot config group_whitelist 987654321
|
|
||||||
```
|
|
||||||
|
|
||||||
### 手动发消息
|
|
||||||
|
|
||||||
```
|
|
||||||
# 发私聊
|
|
||||||
/onebot send private 123456 你好
|
|
||||||
|
|
||||||
# 发群聊
|
|
||||||
/onebot send group 123456789 大家好
|
|
||||||
```
|
|
||||||
|
|
||||||
## 命令速查
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `/onebot connect <ws_url>` | 正向 WebSocket 连接 |
|
|
||||||
| `/onebot listen [port]` | 反向 WebSocket 监听 |
|
|
||||||
| `/onebot disconnect` | 断开连接 |
|
|
||||||
| `/onebot status` | 查看状态 |
|
|
||||||
| `/onebot config <key> <val>` | 配置参数 |
|
|
||||||
| `/onebot send <type> <id> <msg>` | 手动发消息 |
|
|
||||||
| `/onebot help` | 显示帮助 |
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
```
|
|
||||||
QQ 用户发消息
|
|
||||||
↓
|
|
||||||
OneBot 实现 (Lagrange/go-cqhttp)
|
|
||||||
↓ WebSocket 推送
|
|
||||||
onebot 插件 (接收消息)
|
|
||||||
↓ 注入上下文
|
|
||||||
xiaoli-cli AI 引擎 (处理消息)
|
|
||||||
↓ 生成回复
|
|
||||||
onebot 插件 (发送回复)
|
|
||||||
↓ OneBot API
|
|
||||||
QQ 用户收到回复
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**Q: 连接不上怎么办?**
|
|
||||||
A: 检查 OneBot 实现是否启动,端口是否正确,防火墙是否放行。
|
|
||||||
|
|
||||||
**Q: 群里不回复?**
|
|
||||||
A: 检查 trigger_mode 设置,以及是否在群白名单中。
|
|
||||||
|
|
||||||
**Q: 支持图片/语音吗?**
|
|
||||||
A: 当前版本只处理文本消息。图片消息会提取文本部分处理。
|
|
||||||
|
|
||||||
**Q: 安全性如何?**
|
|
||||||
A: 建议设置群白名单,不要在公开群暴露。管理员功能可后续扩展。
|
|
||||||
|
|
||||||
## 参考
|
|
||||||
|
|
||||||
- [OneBot 标准文档](https://github.com/botuniverse/onebot)
|
|
||||||
- [OneBot v11 接口文档](https://github.com/botuniverse/onebot-11)
|
|
||||||
- [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core)
|
|
||||||
- [go-cqhttp](https://github.com/Mrs4s/go-cqhttp)
|
|
||||||
@@ -1,541 +0,0 @@
|
|||||||
"""
|
|
||||||
OneBot v11 插件 — 让 xiaoli-cli 成为 QQ 机器人
|
|
||||||
通过 WebSocket 连接 OneBot 实现(go-cqhttp / Lagrange / LLOneBot 等)
|
|
||||||
|
|
||||||
功能:
|
|
||||||
- WebSocket 正向/反向连接
|
|
||||||
- 接收私聊和群消息,转发给 AI 处理
|
|
||||||
- AI 回复自动发回 QQ
|
|
||||||
- 支持 @机器人 触发 / 全部消息 / 前缀触发
|
|
||||||
- 群消息可配置是否响应
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
|
|
||||||
try:
|
|
||||||
import websocket
|
|
||||||
HAS_WS = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_WS = False
|
|
||||||
|
|
||||||
|
|
||||||
class Liugin:
|
|
||||||
"""OneBot v11 QQ 机器人插件"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.ws = None
|
|
||||||
self.connected = False
|
|
||||||
self.running = False
|
|
||||||
self.ws_url = ""
|
|
||||||
self.mode = "" # "forward" 正向 / "reverse" 反向
|
|
||||||
|
|
||||||
# 配置
|
|
||||||
self.trigger_mode = "at" # at: 只响应@ / prefix: 前缀触发 / all: 全部消息
|
|
||||||
self.trigger_prefix = "/"
|
|
||||||
self.group_whitelist = [] # 群白名单,空=全部响应
|
|
||||||
self.admin_users = [] # 管理员 QQ 号
|
|
||||||
self.listen_port = 8765 # 反向 WebSocket 监听端口
|
|
||||||
self.self_id = "" # 机器人自身 QQ 号
|
|
||||||
|
|
||||||
# 消息队列 (用于 AI 回复)
|
|
||||||
self._pending_replies = {} # {message_id: {"context": ..., "reply": ...}}
|
|
||||||
self._msg_counter = 0
|
|
||||||
|
|
||||||
self.cli = None
|
|
||||||
self._listener_thread = None
|
|
||||||
|
|
||||||
self.usage = """OneBot v11 QQ 机器人插件 使用说明:
|
|
||||||
|
|
||||||
/onebot connect <ws_url> 连接 OneBot (正向WebSocket)
|
|
||||||
/onebot listen [port] 启动反向WebSocket监听 (默认8765)
|
|
||||||
/onebot disconnect 断开连接
|
|
||||||
/onebot status 查看连接状态
|
|
||||||
/onebot config <key> <val> 配置:
|
|
||||||
trigger_mode at|prefix|all 触发模式
|
|
||||||
trigger_prefix <前缀> 前缀触发的前缀
|
|
||||||
group_whitelist <群号> 添加群白名单
|
|
||||||
admin <QQ号> 添加管理员
|
|
||||||
self_id <QQ号> 设置机器人QQ号
|
|
||||||
/onebot send <type> <id> <消息> 手动发消息
|
|
||||||
type: private/group
|
|
||||||
id: QQ号/群号
|
|
||||||
/onebot help 显示帮助
|
|
||||||
"""
|
|
||||||
|
|
||||||
def set_cli(self, cli):
|
|
||||||
self.cli = cli
|
|
||||||
if hasattr(cli, 'register_liugin_command'):
|
|
||||||
cli.register_liugin_command('onebot', self._cmd_handler)
|
|
||||||
|
|
||||||
def _cmd_handler(self, args):
|
|
||||||
parts = args.strip().split(maxsplit=1)
|
|
||||||
if not parts:
|
|
||||||
return self.handle("help")
|
|
||||||
return self.handle(args)
|
|
||||||
|
|
||||||
def get_tool_info(self):
|
|
||||||
return {
|
|
||||||
"name": "onebot",
|
|
||||||
"description": "OneBot v11 QQ 机器人插件,连接 go-cqhttp/Lagrange/LLOneBot 等,让 AI 接管 QQ 消息",
|
|
||||||
"keywords": [
|
|
||||||
"onebot", "qq", "机器人", "bot", "go-cqhttp",
|
|
||||||
"lagrange", "消息", "群聊", "私聊", "cqhttp"
|
|
||||||
],
|
|
||||||
"usage": self.usage
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_mcp_definition(self):
|
|
||||||
return {
|
|
||||||
"name": "onebot",
|
|
||||||
"description": "OneBot v11 QQ bot plugin",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["connect", "listen", "disconnect", "status", "config", "send"]
|
|
||||||
},
|
|
||||||
"args": {"type": "string"}
|
|
||||||
},
|
|
||||||
"required": ["action"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def convert_mcp_args(self, arguments):
|
|
||||||
return f"{arguments.get('action', '')} {arguments.get('args', '')}"
|
|
||||||
|
|
||||||
# ── WebSocket 连接 ──
|
|
||||||
|
|
||||||
def _on_message(self, ws, raw):
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return
|
|
||||||
self._dispatch(data)
|
|
||||||
|
|
||||||
def _on_error(self, ws, error):
|
|
||||||
print(f"\n[OneBot] ❌ WebSocket 错误: {error}")
|
|
||||||
|
|
||||||
def _on_close(self, ws, code, reason):
|
|
||||||
self.connected = False
|
|
||||||
self.running = False
|
|
||||||
print(f"\n[OneBot] 🔌 连接断开 (code={code})")
|
|
||||||
|
|
||||||
def _on_open(self, ws):
|
|
||||||
self.connected = True
|
|
||||||
self.running = True
|
|
||||||
print(f"\n[OneBot] ✅ 已连接到 OneBot 服务")
|
|
||||||
|
|
||||||
def _connect_forward(self, url):
|
|
||||||
"""正向 WebSocket 连接"""
|
|
||||||
if not HAS_WS:
|
|
||||||
return "❌ 需要 websocket-client: pip install websocket-client"
|
|
||||||
|
|
||||||
self.ws_url = url
|
|
||||||
self.mode = "forward"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.ws = websocket.WebSocketApp(
|
|
||||||
url,
|
|
||||||
on_message=self._on_message,
|
|
||||||
on_error=self._on_error,
|
|
||||||
on_close=self._on_close,
|
|
||||||
on_open=self._on_open
|
|
||||||
)
|
|
||||||
self._listener_thread = threading.Thread(target=self.ws.run_forever, daemon=True)
|
|
||||||
self._listener_thread.start()
|
|
||||||
|
|
||||||
for _ in range(30):
|
|
||||||
if self.connected:
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
if self.connected:
|
|
||||||
return f"✅ 已连接到 {url}"
|
|
||||||
return f"❌ 连接超时: {url}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"❌ 连接失败: {e}"
|
|
||||||
|
|
||||||
def _start_reverse_listener(self, port):
|
|
||||||
"""反向 WebSocket 监听"""
|
|
||||||
try:
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
import hashlib
|
|
||||||
import struct
|
|
||||||
except ImportError:
|
|
||||||
return "❌ 缺少标准库组件"
|
|
||||||
|
|
||||||
self.listen_port = port
|
|
||||||
self.mode = "reverse"
|
|
||||||
plugin_ref = self
|
|
||||||
|
|
||||||
class ReverseWSHandler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
# WebSocket 握手
|
|
||||||
if self.headers.get('Upgrade', '').lower() != 'websocket':
|
|
||||||
self.send_error(400)
|
|
||||||
return
|
|
||||||
|
|
||||||
key = self.headers.get('Sec-WebSocket-Key', '')
|
|
||||||
accept = hashlib.sha1(
|
|
||||||
(key + "258EAFA5-E914-47DA-95CA-5AB5DC65C740").encode()
|
|
||||||
).digest()
|
|
||||||
|
|
||||||
self.send_response(101, 'Switching Protocols')
|
|
||||||
self.send_header('Upgrade', 'websocket')
|
|
||||||
self.send_header('Connection', 'Upgrade')
|
|
||||||
self.send_header('Sec-WebSocket-Accept',
|
|
||||||
__import__('base64').b64encode(accept).decode())
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
# 简化的 WebSocket 帧处理
|
|
||||||
plugin_ref._handle_reverse_ws(self.rfile, self.wfile, self)
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass # 静默日志
|
|
||||||
|
|
||||||
class ReverseHTTPServer(HTTPServer):
|
|
||||||
allow_reuse_address = True
|
|
||||||
|
|
||||||
def _run_server():
|
|
||||||
try:
|
|
||||||
server = ReverseHTTPServer(('0.0.0.0', port), ReverseWSHandler)
|
|
||||||
self.connected = True
|
|
||||||
self.running = True
|
|
||||||
print(f"\n[OneBot] ✅ 反向 WebSocket 监听中: ws://0.0.0.0:{port}")
|
|
||||||
server.serve_forever()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n[OneBot] ❌ 监听失败: {e}")
|
|
||||||
|
|
||||||
self._listener_thread = threading.Thread(target=_run_server, daemon=True)
|
|
||||||
self._listener_thread.start()
|
|
||||||
return f"✅ 反向 WebSocket 已启动: ws://0.0.0.0:{port}"
|
|
||||||
|
|
||||||
def _handle_reverse_ws(self, rfile, wfile, handler):
|
|
||||||
"""处理反向 WebSocket 连接"""
|
|
||||||
self.connected = True
|
|
||||||
self.running = True
|
|
||||||
print(f"\n[OneBot] ✅ OneBot 已连接")
|
|
||||||
|
|
||||||
try:
|
|
||||||
while self.running:
|
|
||||||
# 读取 WebSocket 帧
|
|
||||||
header = rfile.read(2)
|
|
||||||
if len(header) < 2:
|
|
||||||
break
|
|
||||||
|
|
||||||
opcode = header[0] & 0x0F
|
|
||||||
masked = bool(header[1] & 0x80)
|
|
||||||
length = header[1] & 0x7F
|
|
||||||
|
|
||||||
if length == 126:
|
|
||||||
length = struct.unpack('>H', rfile.read(2))[0]
|
|
||||||
elif length == 127:
|
|
||||||
length = struct.unpack('>Q', rfile.read(8))[0]
|
|
||||||
|
|
||||||
mask_key = rfile.read(4) if masked else b''
|
|
||||||
payload = rfile.read(length)
|
|
||||||
|
|
||||||
if masked:
|
|
||||||
payload = bytes(
|
|
||||||
b ^ mask_key[i % 4]
|
|
||||||
for i, b in enumerate(payload)
|
|
||||||
)
|
|
||||||
|
|
||||||
if opcode == 1: # Text
|
|
||||||
try:
|
|
||||||
data = json.loads(payload.decode('utf-8'))
|
|
||||||
self._dispatch(data)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
elif opcode == 8: # Close
|
|
||||||
break
|
|
||||||
elif opcode == 9: # Ping
|
|
||||||
# 发 Pong
|
|
||||||
self._ws_send_pong(wfile, payload)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n[OneBot] 连接断开: {e}")
|
|
||||||
finally:
|
|
||||||
self.connected = False
|
|
||||||
|
|
||||||
def _ws_send_pong(self, wfile, payload):
|
|
||||||
"""发送 Pong 帧"""
|
|
||||||
try:
|
|
||||||
frame = bytes([0x8A, len(payload)]) + payload
|
|
||||||
wfile.write(frame)
|
|
||||||
wfile.flush()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ── 消息分发 ──
|
|
||||||
|
|
||||||
def _dispatch(self, data):
|
|
||||||
"""分发 OneBot 事件"""
|
|
||||||
post_type = data.get("post_type", "")
|
|
||||||
|
|
||||||
if post_type == "message":
|
|
||||||
self._handle_message(data)
|
|
||||||
elif post_type == "meta_event":
|
|
||||||
sub_type = data.get("meta_event_type", "")
|
|
||||||
if sub_type == "heartbeat":
|
|
||||||
pass
|
|
||||||
elif sub_type == "lifecycle":
|
|
||||||
if data.get("sub_type") == "connect":
|
|
||||||
self.self_id = str(data.get("self_id", ""))
|
|
||||||
print(f"\n[OneBot] 🤖 机器人已上线: {self.self_id}")
|
|
||||||
elif post_type == "request":
|
|
||||||
self._handle_request(data)
|
|
||||||
|
|
||||||
def _handle_message(self, data):
|
|
||||||
"""处理收到的消息"""
|
|
||||||
message_type = data.get("message_type", "")
|
|
||||||
raw_msg = data.get("raw_message", data.get("message", ""))
|
|
||||||
user_id = str(data.get("user_id", ""))
|
|
||||||
group_id = str(data.get("group_id", ""))
|
|
||||||
message_id = data.get("message_id", 0)
|
|
||||||
sender = data.get("sender", {})
|
|
||||||
nickname = sender.get("nickname", user_id)
|
|
||||||
|
|
||||||
# 群白名单检查
|
|
||||||
if message_type == "group" and self.group_whitelist:
|
|
||||||
if group_id not in self.group_whitelist:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 解析纯文本
|
|
||||||
text = self._extract_text(raw_msg)
|
|
||||||
|
|
||||||
# 判断是否需要响应
|
|
||||||
should_respond = False
|
|
||||||
|
|
||||||
if message_type == "private":
|
|
||||||
should_respond = True
|
|
||||||
elif message_type == "group":
|
|
||||||
if self.trigger_mode == "all":
|
|
||||||
should_respond = True
|
|
||||||
elif self.trigger_mode == "at":
|
|
||||||
if f"[CQ:at,qq={self.self_id}]" in raw_msg:
|
|
||||||
should_respond = True
|
|
||||||
text = text.replace(f"@{self.self_id}", "").strip()
|
|
||||||
elif self.trigger_mode == "prefix":
|
|
||||||
if text.startswith(self.trigger_prefix):
|
|
||||||
should_respond = True
|
|
||||||
text = text[len(self.trigger_prefix):].strip()
|
|
||||||
|
|
||||||
if not should_respond or not text:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 打印收到的消息
|
|
||||||
if message_type == "group":
|
|
||||||
print(f"\n[OneBot] 💬 [{group_id}] {nickname}: {text[:80]}")
|
|
||||||
else:
|
|
||||||
print(f"\n[OneBot] 💬 {nickname}: {text[:80]}")
|
|
||||||
|
|
||||||
# 交给 AI 处理
|
|
||||||
self._process_with_ai(text, message_type, user_id, group_id, message_id, nickname)
|
|
||||||
|
|
||||||
def _extract_text(self, raw_msg):
|
|
||||||
"""从 CQ 码消息中提取纯文本"""
|
|
||||||
# 移除 CQ 码
|
|
||||||
text = re.sub(r'\[CQ:[^\]]+\]', '', raw_msg)
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
def _handle_request(self, data):
|
|
||||||
"""处理请求事件(好友申请、群邀请等)"""
|
|
||||||
request_type = data.get("request_type", "")
|
|
||||||
if request_type == "friend":
|
|
||||||
# 自动同意好友请求
|
|
||||||
self._send_api("set_friend_add_request", {
|
|
||||||
"flag": data.get("flag", ""),
|
|
||||||
"approve": True
|
|
||||||
})
|
|
||||||
print(f"\n[OneBot] ✅ 已自动同意好友请求: {data.get('user_id')}")
|
|
||||||
|
|
||||||
def _process_with_ai(self, text, msg_type, user_id, group_id, msg_id, nickname):
|
|
||||||
"""将消息交给 AI 处理并发回回复"""
|
|
||||||
if not self.cli:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 构建上下文
|
|
||||||
if msg_type == "group":
|
|
||||||
context = f"[QQ群消息] 群{group_id} 用户{nickname}({user_id}): {text}"
|
|
||||||
else:
|
|
||||||
context = f"[QQ私聊] 用户{nickname}({user_id}): {text}"
|
|
||||||
|
|
||||||
def _ai_worker():
|
|
||||||
try:
|
|
||||||
# 调用 AI 处理
|
|
||||||
response = self.cli._generate_response_with_animation(context)
|
|
||||||
if response:
|
|
||||||
# 清理回复
|
|
||||||
reply = self._clean_reply(response)
|
|
||||||
if reply:
|
|
||||||
if msg_type == "group":
|
|
||||||
self._send_group_msg(group_id, reply, at_user=user_id)
|
|
||||||
else:
|
|
||||||
self._send_private_msg(user_id, reply)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n[OneBot] ❌ AI 处理失败: {e}")
|
|
||||||
|
|
||||||
threading.Thread(target=_ai_worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _clean_reply(self, response):
|
|
||||||
"""清理 AI 回复,去掉 JSON 指令等"""
|
|
||||||
# 去掉工具调用 JSON
|
|
||||||
response = re.sub(r'\{"action":\s*"use_tool"[^}]*\}', '', response)
|
|
||||||
response = re.sub(r'\{"action":\s*"continue"[^}]*\}', '', response)
|
|
||||||
response = re.sub(r'<tool_call>[^<]*', '', response)
|
|
||||||
response = response.strip()
|
|
||||||
|
|
||||||
# 限制长度
|
|
||||||
if len(response) > 2000:
|
|
||||||
response = response[:1997] + "..."
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
# ── OneBot API 调用 ──
|
|
||||||
|
|
||||||
def _send_api(self, action, params=None):
|
|
||||||
"""调用 OneBot API"""
|
|
||||||
if not self.connected or not self.ws:
|
|
||||||
return None
|
|
||||||
|
|
||||||
self._msg_counter += 1
|
|
||||||
payload = {
|
|
||||||
"action": action,
|
|
||||||
"params": params or {},
|
|
||||||
"echo": self._msg_counter
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
if self.mode == "forward":
|
|
||||||
self.ws.send(json.dumps(payload))
|
|
||||||
return self._msg_counter
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n[OneBot] ❌ API 调用失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _send_private_msg(self, user_id, message):
|
|
||||||
"""发送私聊消息"""
|
|
||||||
return self._send_api("send_private_msg", {
|
|
||||||
"user_id": int(user_id),
|
|
||||||
"message": message
|
|
||||||
})
|
|
||||||
|
|
||||||
def _send_group_msg(self, group_id, message, at_user=None):
|
|
||||||
"""发送群消息"""
|
|
||||||
if at_user:
|
|
||||||
message = f"[CQ:at,qq={at_user}] {message}"
|
|
||||||
return self._send_api("send_group_msg", {
|
|
||||||
"group_id": int(group_id),
|
|
||||||
"message": message
|
|
||||||
})
|
|
||||||
|
|
||||||
# ── 命令入口 ──
|
|
||||||
|
|
||||||
def handle(self, args):
|
|
||||||
parts = args.strip().split(maxsplit=1)
|
|
||||||
if not parts:
|
|
||||||
return self.usage
|
|
||||||
|
|
||||||
action = parts[0].lower()
|
|
||||||
rest = parts[1] if len(parts) > 1 else ""
|
|
||||||
|
|
||||||
if action == "help":
|
|
||||||
return self.usage
|
|
||||||
elif action == "connect":
|
|
||||||
return self._do_connect(rest.strip())
|
|
||||||
elif action == "listen":
|
|
||||||
port = int(rest.strip()) if rest.strip().isdigit() else 8765
|
|
||||||
return self._start_reverse_listener(port)
|
|
||||||
elif action == "disconnect":
|
|
||||||
return self._do_disconnect()
|
|
||||||
elif action == "status":
|
|
||||||
return self._do_status()
|
|
||||||
elif action == "config":
|
|
||||||
return self._do_config(rest)
|
|
||||||
elif action == "send":
|
|
||||||
return self._do_send(rest)
|
|
||||||
else:
|
|
||||||
return f"未知命令: {action}\n{self.usage}"
|
|
||||||
|
|
||||||
def _do_connect(self, url):
|
|
||||||
if not url:
|
|
||||||
return "请提供 WebSocket 地址: /onebot connect ws://127.0.0.1:6700"
|
|
||||||
return self._connect_forward(url)
|
|
||||||
|
|
||||||
def _do_disconnect(self):
|
|
||||||
self.running = False
|
|
||||||
if self.ws:
|
|
||||||
try:
|
|
||||||
self.ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.connected = False
|
|
||||||
return "已断开连接"
|
|
||||||
|
|
||||||
def _do_status(self):
|
|
||||||
lines = ["🤖 OneBot 状态:"]
|
|
||||||
lines.append(f" 连接: {'✅ 已连接' if self.connected else '❌ 未连接'}")
|
|
||||||
lines.append(f" 模式: {self.mode or '未设置'}")
|
|
||||||
if self.ws_url:
|
|
||||||
lines.append(f" 地址: {self.ws_url}")
|
|
||||||
lines.append(f" 端口: {self.listen_port}")
|
|
||||||
lines.append(f" 机器人QQ: {self.self_id or '未知'}")
|
|
||||||
lines.append(f" 触发模式: {self.trigger_mode}")
|
|
||||||
if self.trigger_mode == "prefix":
|
|
||||||
lines.append(f" 触发前缀: {self.trigger_prefix}")
|
|
||||||
if self.group_whitelist:
|
|
||||||
lines.append(f" 群白名单: {', '.join(self.group_whitelist)}")
|
|
||||||
if self.admin_users:
|
|
||||||
lines.append(f" 管理员: {', '.join(self.admin_users)}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def _do_config(self, args):
|
|
||||||
parts = args.split(maxsplit=1)
|
|
||||||
if len(parts) < 2:
|
|
||||||
return "用法: /onebot config <key> <value>\n可用: trigger_mode, trigger_prefix, group_whitelist, admin, self_id"
|
|
||||||
|
|
||||||
key, val = parts[0].lower(), parts[1].strip()
|
|
||||||
|
|
||||||
if key == "trigger_mode":
|
|
||||||
if val not in ("at", "prefix", "all"):
|
|
||||||
return "触发模式: at / prefix / all"
|
|
||||||
self.trigger_mode = val
|
|
||||||
return f"✅ 触发模式: {val}"
|
|
||||||
elif key == "trigger_prefix":
|
|
||||||
self.trigger_prefix = val
|
|
||||||
return f"✅ 触发前缀: {val}"
|
|
||||||
elif key == "group_whitelist":
|
|
||||||
if val not in self.group_whitelist:
|
|
||||||
self.group_whitelist.append(val)
|
|
||||||
return f"✅ 群白名单: {self.group_whitelist}"
|
|
||||||
elif key == "admin":
|
|
||||||
if val not in self.admin_users:
|
|
||||||
self.admin_users.append(val)
|
|
||||||
return f"✅ 管理员: {self.admin_users}"
|
|
||||||
elif key == "self_id":
|
|
||||||
self.self_id = val
|
|
||||||
return f"✅ 机器人QQ: {val}"
|
|
||||||
else:
|
|
||||||
return f"未知配置: {key}"
|
|
||||||
|
|
||||||
def _do_send(self, args):
|
|
||||||
parts = args.split(maxsplit=2)
|
|
||||||
if len(parts) < 3:
|
|
||||||
return "用法: /onebot send <private|group> <ID> <消息>"
|
|
||||||
|
|
||||||
msg_type, target_id, message = parts[0].lower(), parts[1], parts[2]
|
|
||||||
|
|
||||||
if msg_type in ("private", "p", "私聊"):
|
|
||||||
self._send_private_msg(target_id, message)
|
|
||||||
return f"✅ 已发送私聊消息给 {target_id}"
|
|
||||||
elif msg_type in ("group", "g", "群"):
|
|
||||||
self._send_group_msg(target_id, message)
|
|
||||||
return f"✅ 已发送群消息到 {target_id}"
|
|
||||||
else:
|
|
||||||
return "类型: private / group"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"description": "小狸 CLI 插件码注册表",
|
|
||||||
"plugins": {
|
|
||||||
"dglab": {
|
|
||||||
"name": "DG-Lab 郊狼 WebSocket 控制",
|
|
||||||
"description": "基于 DG-LAB-OPENSOURCE 官方 Socket V2 协议,支持郊狼 3.0 脉冲主机远程控制",
|
|
||||||
"author": "xiaoli-cli-plugins",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"file": "dglab_ws.py",
|
|
||||||
"url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/dglab_ws/dglab_ws.py",
|
|
||||||
"docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/dglab_ws/README.md",
|
|
||||||
"deps": ["websocket-client"],
|
|
||||||
"optional_deps": ["qrcode"],
|
|
||||||
"keywords": ["dg-lab", "dglab", "郊狼", "coyote", "websocket", "脉冲"]
|
|
||||||
},
|
|
||||||
"onebot": {
|
|
||||||
"name": "OneBot v11 QQ 机器人",
|
|
||||||
"description": "让 xiaoli-cli 成为 QQ 机器人,连接 go-cqhttp/Lagrange/LLOneBot 等 OneBot 实现",
|
|
||||||
"author": "xiaoli-cli-plugins",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"file": "onebot.py",
|
|
||||||
"url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/onebot.py",
|
|
||||||
"docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/README.md",
|
|
||||||
"deps": ["websocket-client"],
|
|
||||||
"optional_deps": [],
|
|
||||||
"keywords": ["onebot", "qq", "机器人", "bot", "go-cqhttp", "lagrange", "群聊", "私聊"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"""
|
|
||||||
启动小狸就启动原神防止用户没原神玩会无聊死插件 (全自动版)
|
|
||||||
======================================================
|
|
||||||
加载即运行,无需任何命令。
|
|
||||||
启动小狸 → 检测原神 → 有游戏就启动 / 有启动器就打开 / 什么都没有就安装启动器。
|
|
||||||
真正的防无聊守护神。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
import requests
|
|
||||||
from pathlib import Path
|
|
||||||
from colorama import Fore, Style
|
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
|
||||||
"""原神自动守护插件"""
|
|
||||||
|
|
||||||
# 官方启动器下载链接(定期更新)
|
|
||||||
LAUNCHER_DOWNLOAD_URL = (
|
|
||||||
"https://download.cg.qq.com/launcher/hoyoplay/HoYoPlay_installer_202503121746.exe"
|
|
||||||
)
|
|
||||||
BACKUP_LAUNCHER_URL = (
|
|
||||||
"https://autopatchcn.yuanshen.com/client_app/download/launcher/202503121746_1.0.0.0.exe"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 常见游戏路径
|
|
||||||
GENSHIN_PATHS = [
|
|
||||||
r"C:\Program Files\Genshin Impact\Genshin Impact Game\YuanShen.exe",
|
|
||||||
r"C:\Program Files\Genshin Impact\Genshin Impact Game\GenshinImpact.exe",
|
|
||||||
r"C:\Program Files (x86)\Genshin Impact\Genshin Impact Game\YuanShen.exe",
|
|
||||||
r"D:\Genshin Impact\Genshin Impact Game\YuanShen.exe",
|
|
||||||
r"E:\Genshin Impact\Genshin Impact Game\YuanShen.exe",
|
|
||||||
]
|
|
||||||
|
|
||||||
LAUNCHER_DIR = r"C:\Program Files\miHoYo Launcher"
|
|
||||||
LAUNCHER_EXE = os.path.join(LAUNCHER_DIR, "launcher.exe")
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
# 插件加载时自动触发,为避免阻塞主线程,新开线程执行
|
|
||||||
print(f"{Fore.MAGENTA}🧸 原神守护插件已激活!正在尝试启动原神...{Style.RESET_ALL}")
|
|
||||||
t = threading.Thread(target=self._auto_launch, daemon=True)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
def set_cli(self, cli):
|
|
||||||
"""保存CLI引用(本插件不需要命令注册)"""
|
|
||||||
self.cli = cli
|
|
||||||
|
|
||||||
def get_tool_info(self):
|
|
||||||
"""提供工具信息(虽然不会主动调用,但会让AI知道这个插件存在)"""
|
|
||||||
return {
|
|
||||||
"name": "auto_genshin_launcher",
|
|
||||||
"description": "启动小狸即自动检测并启动原神/安装米哈游启动器,无需手动操作。",
|
|
||||||
"keywords": ["原神", "启动", "自动", "米哈游"],
|
|
||||||
"usage": "本插件无需手动调用,加载即生效。"
|
|
||||||
}
|
|
||||||
|
|
||||||
def handle(self, args):
|
|
||||||
"""防呆接口,如果有笨笨的AI非要调用,就返回状态"""
|
|
||||||
return "✨ 原神守护已就绪,它在后台默默守护着你~"
|
|
||||||
|
|
||||||
# ---------- 核心自动逻辑 ----------
|
|
||||||
def _auto_launch(self):
|
|
||||||
"""自动检测并启动原神/启动器"""
|
|
||||||
if os.name != 'nt':
|
|
||||||
print(f"{Fore.RED}🛑 原神守护仅支持Windows系统~{Style.RESET_ALL}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 1. 已安装游戏 → 直接启动
|
|
||||||
game_path = self._find_genshin_game()
|
|
||||||
if game_path:
|
|
||||||
print(f"{Fore.GREEN}🎮 检测到原神!启动游戏...{Style.RESET_ALL}")
|
|
||||||
self._launch_genshin(game_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. 已安装启动器 → 打开启动器
|
|
||||||
if self._launcher_exists():
|
|
||||||
print(f"{Fore.GREEN}✅ 米哈游启动器已安装,正在打开...{Style.RESET_ALL}")
|
|
||||||
self._launch_launcher()
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. 什么都没有 → 下载安装启动器
|
|
||||||
print(f"{Fore.YELLOW}📦 未检测到原神和启动器,正在自动安装米哈游启动器(需要管理员权限)...{Style.RESET_ALL}")
|
|
||||||
if self._download_and_install_launcher():
|
|
||||||
print(f"{Fore.GREEN}🎉 启动器安装完成!正在打开...{Style.RESET_ALL}")
|
|
||||||
self._launch_launcher()
|
|
||||||
else:
|
|
||||||
print(f"{Fore.RED}😭 安装失败,请手动从官网下载:https://ys.mihoyo.com{Style.RESET_ALL}")
|
|
||||||
|
|
||||||
def _find_genshin_game(self):
|
|
||||||
for path in self.GENSHIN_PATHS:
|
|
||||||
if os.path.exists(path):
|
|
||||||
return path
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _launcher_exists(self):
|
|
||||||
return os.path.exists(self.LAUNCHER_EXE)
|
|
||||||
|
|
||||||
def _launch_genshin(self, exe_path):
|
|
||||||
try:
|
|
||||||
subprocess.Popen([exe_path], cwd=os.path.dirname(exe_path))
|
|
||||||
print(f"{Fore.CYAN}✨ 原神正在启动,旅行者,提瓦特见!{Style.RESET_ALL}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"{Fore.RED}启动原神失败: {e}{Style.RESET_ALL}")
|
|
||||||
|
|
||||||
def _launch_launcher(self):
|
|
||||||
try:
|
|
||||||
subprocess.Popen([self.LAUNCHER_EXE], cwd=self.LAUNCHER_DIR)
|
|
||||||
print(f"{Fore.CYAN}✅ 启动器已打开,请手动下载原神~{Style.RESET_ALL}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"{Fore.RED}打开启动器失败: {e}{Style.RESET_ALL}")
|
|
||||||
|
|
||||||
def _download_and_install_launcher(self):
|
|
||||||
tmp_dir = tempfile.gettempdir()
|
|
||||||
installer_path = os.path.join(tmp_dir, "HoYoPlay_installer.exe")
|
|
||||||
if os.path.exists(installer_path):
|
|
||||||
os.remove(installer_path)
|
|
||||||
|
|
||||||
url = self.LAUNCHER_DOWNLOAD_URL
|
|
||||||
if not self._download_file(url, installer_path):
|
|
||||||
print(f"{Fore.YELLOW}⚠️ 主链接失败,尝试备用链接...{Style.RESET_ALL}")
|
|
||||||
if not self._download_file(self.BACKUP_LAUNCHER_URL, installer_path):
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"{Fore.YELLOW}⚙️ 正在静默安装(等待约30秒)...{Style.RESET_ALL}")
|
|
||||||
try:
|
|
||||||
# 静默安装,/S 适用于NSIS安装包
|
|
||||||
subprocess.run([installer_path, "/S"], shell=True, capture_output=True)
|
|
||||||
time.sleep(15) # 等待安装完成
|
|
||||||
# 可选尝试其他参数
|
|
||||||
if not self._launcher_exists():
|
|
||||||
subprocess.run([installer_path, "/verysilent", "/norestart"], shell=True)
|
|
||||||
time.sleep(10)
|
|
||||||
if os.path.exists(installer_path):
|
|
||||||
os.remove(installer_path)
|
|
||||||
return self._launcher_exists()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"{Fore.RED}安装异常: {e}{Style.RESET_ALL}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _download_file(self, url, save_path):
|
|
||||||
try:
|
|
||||||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
|
||||||
response = requests.get(url, stream=True, headers=headers, timeout=60)
|
|
||||||
response.raise_for_status()
|
|
||||||
total = int(response.headers.get("content-length", 0))
|
|
||||||
downloaded = 0
|
|
||||||
with open(save_path, "wb") as f:
|
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
|
||||||
f.write(chunk)
|
|
||||||
downloaded += len(chunk)
|
|
||||||
if total > 0:
|
|
||||||
pct = int(downloaded / total * 100)
|
|
||||||
print(f"\r📥 下载启动器: {pct}% ({downloaded//1024}KB/{total//1024}KB)", end="", flush=True)
|
|
||||||
print()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"下载失败: {e}")
|
|
||||||
return False
|
|
||||||
Reference in new issue
Block a user