【QQ Bot】网页版测试qq机器人

QQ Bot 接入测试:用浏览器就能调试你的机器人

基于 QQ Bot WebSocket 协议,通过一个单文件 Node.js 服务即可在浏览器中完成机器人的连接、收发消息全流程测试。


1. 前言

QQ Bot 开发过程中,最繁琐的环节就是调试------传统的命令行脚本需要不断敲命令,消息收发不可视化,多人测试时 OpenID 难以管理。

qq_bot_client.js 是一个零配置、内嵌 UI 的调试客户端,启动后在浏览器中就能完成所有操作。


2. 如何启动

前提条件

  • Node.js 18+
  • 已在 QQ 开放平台 创建机器人,获取 APP_IDAPP_SECRET

启动步骤

bash 复制代码
# 1. 进入项目目录(您的实际项目的目录)
cd test_qq_bot

# 2. 安装依赖(仅需 ws,浏览器端无需安装)
npm install ws

# 3. 启动服务
node qq_bot_client.js

启动后会输出:

复制代码
浏览器打开: http://localhost:3000

用浏览器访问 http://localhost:3000 即可看到调试面板。


3. 面板UI 介绍

整个面板分为三个区域:

3.1 连接配置区(顶部)

字段 说明
APP_ID QQ 开放平台分配的机器人 AppID
APP_SECRET 机器人秘钥,默认隐藏,点击 👁 按钮可切换明文/密文

操作按钮:

  • 连接:点击后依次完成 获取 Access Token → 获取网关地址 → 建立 WebSocket 连接
  • 断开:手动断开 WebSocket 连接
  • 右侧状态标识:未连接 / 连接中... / 已连接 / 已断开

3.2 消息收发区(中部)

字段 说明
目标 OpenID 发送消息的目标用户。收到消息后会自动填充,也可以从下方「已知用户」点击选取
消息内容 要发送的消息文本,右侧的 发送 按钮直接点击或按回车键快捷键发送

发送成功后,消息内容输入框会自动恢复为 $ 符号,方便快速输入下一条消息。

下方 已知用户 栏会显示所有给机器人发过消息的用户,点击即可填充到目标 OpenID。

3.3 实时日志区(底部)

采用暗色终端风格,以不同颜色区分日志类型:

颜色 含义
🟢 绿色 一般信息(连接成功、消息收发摘要)
🟡 黄色 警告(连接断开等)
🔴 红色 错误(Token 失败、Invalid Session 等)
🔵 蓝色 服务端下发的数据
🟣 紫色 客户端发出的数据

每条日志带时间戳,完整记录 WebSocket 协议的 op 码交互过程。


4. 核心工作流程

复制代码
  浏览器                          Node.js 服务端                    QQ 服务器
    │                                  │                              │
    │── 点击"连接" ──────────────────→ │                              │
    │                                  │── POST /getAppAccessToken ──→│
    │                                  │←── access_token ─────────────│
    │                                  │── GET /gateway/bot ─────────→│
    │                                  │←── wsUrl ────────────────────│
    │←── 页面直连 WebSocket ───────────│                              │
    │═══ WebSocket(浏览器直连网关)═══════════════════════════════════│
    │ ← op=10 Hello / → op=2 Identify                                │
    │ ← op=0 Dispatch (C2C_MESSAGE_CREATE)                           │
    │ → op=1 Heartbeat (每 40s+)                                     │

关键设计

  1. 浏览器无法直接调用 QQ API(CORS 限制),因此 Node.js 服务端充当 API 代理
  2. 但 WebSocket 连接是浏览器直连 QQ 网关,不经过 Node.js 中转
  3. 所有消息收发、协议交互在浏览器端实时可见

5. 自动回复功能

当 QQ 用户首次给机器人发消息时,机器人会自动回复带时间戳的欢迎消息:

复制代码
[08:36]++++++WELCOME++++++

此功能可以方便地验证端到端消息通道是否正常工作。


6. 代码(qq_bot_client.js)

bash 复制代码
const http = require('http');
const https = require('https');
const url = require('url');

const PORT = 3000;

// ── HTTPS 代理 ──
function proxyHttps(method, hostname, reqPath, headers, body) {
  return new Promise((resolve, reject) => {
    const req = https.request({ hostname, port: 443, path: reqPath, method, headers, timeout: 10000 }, (res) => {
      let data = '';
      res.on('data', c => data += c);
      res.on('end', () => resolve({ status: res.statusCode, body: data }));
    });
    req.on('error', reject);
    if (body) req.write(body);
    req.end();
  });
}

// ── 内嵌 HTML ──
const HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QQ Bot WebSocket</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Consolas,"Microsoft YaHei",monospace;background:#1e1e1e;color:#d4d4d4;padding:20px;max-width:900px;margin:0 auto}
h2{color:#569cd6;margin-bottom:12px;font-size:18px}
.box{background:#252526;border:1px solid #3e3e42;border-radius:6px;padding:16px;margin-bottom:12px}
label{font-size:13px;color:#9cdcfe;margin-bottom:3px;display:block}
input{width:100%;padding:7px 10px;background:#3c3c3c;color:#d4d4d4;border:1px solid #555;border-radius:4px;font-family:Consolas,monospace;font-size:13px;outline:none}
input:focus{border-color:#569cd6}
.row{display:flex;gap:12px}
.row>div{flex:1}
.btns{margin:12px 0 0;display:flex;gap:10px;align-items:center}
button{padding:8px 18px;border:none;border-radius:4px;cursor:pointer;font-size:14px;font-weight:bold}
.btn-connect{background:#0e639c;color:#fff}.btn-connect:hover{background:#1177bb}.btn-connect:disabled{background:#555;cursor:not-allowed}
.btn-disconnect{background:#a33;color:#fff}.btn-disconnect:hover{background:#c44}.btn-disconnect:disabled{background:#555;cursor:not-allowed}
.btn-send{background:#0e639c;color:#fff}.btn-send:hover{background:#1177bb}.btn-send:disabled{background:#555;cursor:not-allowed}
.status{display:inline-block;padding:6px 14px;border-radius:4px;font-size:13px;margin-left:auto}
.status.connected{background:#1b3a1b;color:#4ec94e}
.status.disconnected{background:#3a1b1b;color:#f44}
.status.connecting{background:#3a3a1b;color:#cc0}
.log{background:#1a1a1a;border:1px solid #3e3e42;border-radius:6px;padding:12px;height:400px;overflow-y:auto;font-size:13px;line-height:1.6}
.log .info{color:#4ec94e}.log .warn{color:#cca700}.log .error{color:#f44747}.log .recv{color:#569cd6}.log .send{color:#c586c0}.log .divider{color:#666}
.users{margin-top:6px;font-size:12px}
.users span{display:inline-block;background:#3c3c3c;padding:2px 8px;border-radius:3px;margin:2px;cursor:pointer}
.users span:hover{background:#0e639c}
.users .label{background:transparent;cursor:default}
</style>
</head>
<body>
<h2>QQ Bot WebSocket 客户端</h2>
<div class="box">
  <div class="row">
    <div><label>APP_ID</label><input id="appId" value="1904287171"></div>
    <div style="position:relative"><label>APP_SECRET</label><input id="clientSecret" value="OjsnUyEH6h5FDxUn" type="password" style="padding-right:40px"><button id="eyeBtn" onclick="toggleSecret()" style="position:absolute;right:2px;bottom:2px;background:none;border:none;color:#888;font-size:18px;cursor:pointer;padding:4px 8px" title="显示/隐藏秘钥">👁</button></div>
  </div>
  <div class="btns">
    <button class="btn-connect" id="btnConnect" onclick="connect()">连接</button>
    <button class="btn-disconnect" id="btnDisconnect" onclick="disconnect()" disabled>断开</button>
    <span id="statusBar" class="status disconnected">未连接</span>
  </div>
</div>
<div class="box">
  <h3 style="color:#569cd6;margin-bottom:8px">发送消息</h3>
  <div class="row">
    <div><label>目标 OpenID</label><input id="targetOpenId" placeholder="收到消息后自动获取"></div>
    <div>
      <label>消息内容</label>
      <div style="display:flex;gap:6px">
        <input id="msgContent" value="hello-world" style="flex:1">
        <button class="btn-send" id="btnSend" onclick="sendMsg()" disabled>发送</button>
      </div>
    </div>
  </div>
  <div class="btns" style="margin-top:8px">
    <div class="users" id="knownUsers"><span class="label">已知用户 (点击填充):</span></div>
  </div>
</div>
<div class="log" id="log">[页面就绪] 填入凭证后点"连接",具体查看:https://bot.q.qq.com/wiki/ <br><br></div>
<script>
let ws=null,token=null,seq=null,ht=null,knownOpenIds={};
const INTENTS=1107296256;
function log(m,c='info'){let t=new Date().toLocaleTimeString(),el=document.getElementById('log');el.innerHTML+='<div class="'+c+'">['+t+'] '+m+'</div>';el.scrollTop=el.scrollHeight}
function setStatus(t,c){let b=document.getElementById('statusBar');b.textContent=t;b.className='status '+c}
function setConnected(o){document.getElementById('btnConnect').disabled=o;document.getElementById('btnDisconnect').disabled=!o;document.getElementById('btnSend').disabled=!o}
async function api(p,b){let r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});return r.json()}
function updateUsers(){let i=Object.keys(knownOpenIds),h='<span class="label">已知用户 (点击填充):</span> ';i.forEach(id=>h+='<span onclick="document.getElementById(\\'targetOpenId\\').value=\\''+id+'\\'">'+id.substring(0,12)+'...</span>');document.getElementById('knownUsers').innerHTML=h}
async function connect(){
  let appId=document.getElementById('appId').value.trim(),secret=document.getElementById('clientSecret').value.trim();
  if(!appId||!secret){log('请填写 APP_ID 和 CLIENT_SECRET','warn');return}
  setStatus('获取 Token...','connecting');log('获取 Access Token...','info');
  let data=await api('/api/token',{appId,clientSecret:secret});
  if(!data.access_token){log('Token 失败: '+JSON.stringify(data),'error');setStatus('Token 失败','disconnected');return}
  token=data.access_token;log('✔ Token: '+token.substring(0,20)+'...','info');
  log('获取网关地址...','info');
  data=await api('/api/gateway',{token});
  if(!data.url){log('网关失败: '+JSON.stringify(data),'error');setStatus('网关失败','disconnected');return}
  let wsUrl=data.url;log('✔ 网关: '+wsUrl,'info');
  setStatus('连接中...','connecting');log('连接 WebSocket: '+wsUrl,'info');
  ws=new WebSocket(wsUrl);
  ws.onopen=()=>log('✔ WebSocket 已连接,等待 Hello...','info');
  ws.onmessage=e=>{
    let p;try{p=JSON.parse(e.data)}catch{return}
    let{op,d,s,t}=p;if(s!=null)seq=s;
    switch(op){
    case 10:
      log('← Hello (op=10) 心跳: '+d.heartbeat_interval+'ms','recv');
      ws.send(JSON.stringify({op:2,d:{token:'QQBot '+token,intents:INTENTS,shard:[0,1],properties:{}}}));
      log('→ Identify (op=2)','send');
      ht=setInterval(()=>{if(ws&&ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({op:1,d:seq}))},d.heartbeat_interval);
      break;
    case 0:
      log('← Dispatch event='+t,'recv');
      log('  '+JSON.stringify(d).substring(0,250),'recv');
      if(t==='C2C_MESSAGE_CREATE'){
        let oid=d?.author?.id||d?.author?.user_openid,content=d?.content;
        log('  → 私聊: OpenID='+oid+' 内容="'+content+'"','info');
        let isNew=oid&&!knownOpenIds[oid];
        if(isNew){knownOpenIds[oid]=true;document.getElementById('targetOpenId').value=oid;updateUsers();log('  📋 新 OpenID: '+oid,'info');api('/api/send',{openId:oid,content:(()=>{let d=new Date();return'['+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+']++++++WELCOME++++++'})(),token}).then(r=>log('  → WELCOME: '+JSON.stringify(r),'info'));}
      }
      if(t==='READY'){log('✔ READY! session_id='+d?.session_id,'info');log('--- 请在 QQ 上给机器人发消息 ---','divider');setStatus('已连接','connected');setConnected(true)}
      break;
    case 9:log('← Invalid Session: '+JSON.stringify(d),'error');break;
    case 7:log('← Reconnect (op=7)','warn');break;
    }
  };
  ws.onclose=e=>{log('WebSocket 断开: code='+e.code,'warn');clearInterval(ht);setStatus('已断开','disconnected');setConnected(false);ws=null};
  ws.onerror=()=>log('WebSocket 错误','error');
}
function disconnect(){clearInterval(ht);if(ws){ws.close(1000);ws=null}setStatus('手动断开','disconnected');setConnected(false);log('已断开','warn')}
async function sendMsg(){
  let oid=document.getElementById('targetOpenId').value.trim(),content=document.getElementById('msgContent').value.trim();
  if(!oid||!content){log('请填写 OpenID 和消息内容','warn');return}
  log('→ 发送 "'+content+'" 到 '+oid,'send');
  let r=await api('/api/send',{openId:oid,content,token});
  log('  结果: '+JSON.stringify(r),r.id||r.ret===0?'info':'error');
  document.getElementById('msgContent').value='$';
}
document.getElementById('msgContent').addEventListener('keydown',e=>{if(e.key==='Enter')sendMsg()});
function toggleSecret(){let i=document.getElementById('clientSecret'),b=document.getElementById('eyeBtn');if(i.type==='password'){i.type='text';b.textContent='🙈'}else{i.type='password';b.textContent='👁'}}
</script>
</body>
</html>`;

// ── HTTP 服务器 ──
const server = http.createServer(async (req, res) => {
  const reqPath = url.parse(req.url).pathname;

  // 首页 → 返回内嵌 HTML
  if (req.method === 'GET' && reqPath === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(HTML);
    return;
  }

  // API 代理
  if (req.method === 'POST' && reqPath.startsWith('/api/')) {
    let body = '';
    req.on('data', c => body += c);
    req.on('end', async () => {
      let result;
      try {
        const params = JSON.parse(body);
        if (reqPath === '/api/token') {
          result = await proxyHttps('POST', 'bots.qq.com', '/app/getAppAccessToken',
            { 'Content-Type': 'application/json' },
            JSON.stringify({ appId: params.appId, clientSecret: params.clientSecret }));
        } else if (reqPath === '/api/gateway') {
          result = await proxyHttps('GET', 'api.sgroup.qq.com', '/gateway/bot',
            { 'Authorization': `QQBot ${params.token}` });
        } else if (reqPath === '/api/send') {
          result = await proxyHttps('POST', 'api.sgroup.qq.com', `/v2/users/${params.openId}/messages`,
            { 'Content-Type': 'application/json', 'Authorization': `QQBot ${params.token}` },
            JSON.stringify({ msg_type: 0, content: params.content, msg_seq: Math.floor(Math.random() * 100000) + 1 }));
        } else { res.writeHead(404); res.end('Not found'); return; }
      } catch (e) { result = { status: 500, body: JSON.stringify({ error: e.message }) }; }
      res.writeHead(result.status, { 'Content-Type': 'application/json' });
      res.end(result.body);
    });
    return;
  }

  res.writeHead(404); res.end('Not found');
});

server.listen(PORT, () => console.log(`浏览器打开: http://localhost:${PORT}`));

7. 总结

qq_bot_client.js不到 200 行代码实现了一个完整的 QQ Bot 调试面板,核心亮点:

  • 单文件,无需任何前端构建
  • API 代理 解决浏览器 CORS 问题
  • 实时日志 完整记录 WebSocket 协议交互
  • 快捷键 支持回车发送
  • 眼睛按钮 方便查看/隐藏秘钥

适合 Bot 开发初期的联调测试,也可以作为 QQ Bot WebSocket 协议的学习参考。