前言:在上一篇教程中,我们实现了 OpenClaw 通过 NapCat 接入 QQ 个人号的基础文字聊天功能。但仅仅能发文字显然不够——我们希望 AI 能发送图片、文件、表情包,能理解用户发来的图片,甚至能主动给我们发消息(定时问候、服务器告警、任务进度通知等)。本篇教程将带你一步步实现这些高级功能。


目录

  1. 功能概览
  2. 前置条件
  3. 功能一:文件发送(文档、PDF等)
  4. 功能二:图片发送与接收
  5. 功能三:多媒体消息全类型支持
  6. 功能四:主动发送消息
  7. 功能五:定时任务与随机问候
  8. 功能六:服务器事件监听与告警
  9. 踩坑记录与解决方案
  10. 完整代码汇总
  11. 总结

1. 功能概览

在基础文字聊天的基础上,本教程将实现以下功能:

功能 说明 难度
文件发送 AI 可以发送 txt、pdf、docx 等文件给你 ★★☆
图片发送 AI 可以发送图片(包括生成的图表等) ★★☆
多媒体支持 语音、视频、表情等全类型消息支持 ★★★
主动发消息 绕过 Agent 限制,直接调用 QQ API 发送消息 ★★☆
定时任务 定时发送问候、提醒等(支持 cron 表达式) ★★☆
随机问候 每天随机时间发送问候,模拟真人行为 ★★★
事件告警 服务器端口被访问、磁盘满等自动通知 ★★★

最终效果:你的 QQ 机器人不仅能聊天,还能发文件、发图片、定时问候你、在服务器出问题时主动通知你——就像一个真正的 AI 助手。


2. 前置条件

本教程假设你已经完成了以下准备工作:

  • OpenClaw 已安装并运行(宿主机部署或 Docker 部署均可)
  • NapCat 已安装并运行(推荐 Docker 部署)
  • QQ 个人号已登录 NapCat
  • OpenClaw 已通过 OneBot11 WebSocket 连接到 NapCat
  • 基础文字聊天功能正常

关键信息(后续代码中会用到,请替换为你自己的值):

OpenClaw Gateway 端口:18789
NapCat WebSocket 端口:3001
NapCat WebSocket Token:your_token_here
你的 QQ 号:替换为你自己的
OpenClaw 工作目录:/root/openclaw/work

架构回顾

+------------+    WebSocket     +----------+    QQ协议    +----------+
|  OpenClaw  | <--------------> |  NapCat  | <----------> | QQ 服务器 |
|  (Agent)   |   ws://...:3001  | (Docker) |              |          |
+------------+                  +----------+              +----------+
     |
     | HTTP :18790
     v
+------------+
| 文件服务器  |  <-- 为 NapCat 提供文件下载
+------------+

3. 功能一:文件发送(文档、PDF等)

3.1 问题分析

OpenClaw 的 Agent 在执行任务时会生成文件(如 txt、pdf 等),这些文件保存在 Agent 的工作目录中(如 /root/openclaw/work/)。但 NapCat 运行在 Docker 容器中,无法直接访问宿主机的文件系统。

核心思路:在 OpenClaw 的 QQ 插件中内置一个 HTTP 文件服务器,将工作目录中的文件通过 HTTP 暴露出来,NapCat 就可以通过 URL 下载并发送给用户。

3.2 实现文件服务器

创建 ~/.openclaw/extensions/qq/src/file-server.ts

import * as http from "http";
import * as fs from "fs";
import * as path from "path";

let server: http.Server | null = null;

export function startFileServer(port: number = 18790): void {
  if (server) {
    console.log("[QQ FileServer] Already running");
    return;
  }

  server = http.createServer((req, res) => {
    try {
      // 解码 URL(处理中文文件名等)
      const decodedUrl = decodeURIComponent(req.url || "/");
      const filePath = path.join("/root/openclaw/work", decodedUrl);
      
      console.log("[QQ FileServer] Request: " + decodedUrl);

      // 安全检查:防止路径穿越攻击
      const realPath = fs.realpathSync(filePath);
      if (!realPath.startsWith("/root/openclaw/work")) {
        res.writeHead(403);
        res.end("Forbidden");
        return;
      }

      if (fs.existsSync(realPath) && fs.statSync(realPath).isFile()) {
        const stat = fs.statSync(realPath);
        const ext = path.extname(realPath).toLowerCase();
        
        // MIME 类型映射
        const mimeTypes: Record<string, string> = {
          ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
          ".png": "image/png", ".gif": "image/gif",
          ".webp": "image/webp", ".txt": "text/plain",
          ".pdf": "application/pdf",
          ".mp3": "audio/mpeg", ".mp4": "video/mp4",
        };
        
        const contentType = mimeTypes[ext] || "application/octet-stream";
        
        res.writeHead(200, {
          "Content-Type": contentType,
          "Content-Length": stat.size,
          "Access-Control-Allow-Origin": "*",
        });
        
        fs.createReadStream(realPath).pipe(res);
      } else {
        res.writeHead(404);
        res.end("Not Found");
      }
    } catch (err) {
      console.error("[QQ FileServer] Error:", err);
      res.writeHead(500);
      res.end("Error");
    }
  });

  server.listen(port, "0.0.0.0", () => {
    console.log("[QQ FileServer] Started on port " + port);
  });
}

export function stopFileServer(): void {
  if (server) {
    server.close();
    server = null;
    console.log("[QQ FileServer] Stopped");
  }
}

3.3 路径转 URL 函数

channel.ts 中添加路径转换函数。这是整个文件发送功能的核心——将本地文件路径转换为 NapCat 可以访问的 HTTP URL:

const FILE_SERVER_PORT = 18790;

// 如果 NapCat 在 Docker 中,需要使用宿主机的 Docker 网桥 IP
// 查看方法:ip addr show docker0 | grep inet
const FILE_SERVER_BASE_URL = "http://172.17.0.1:" + FILE_SERVER_PORT;

// 如果 NapCat 也在宿主机上,直接用 127.0.0.1 即可:
// const FILE_SERVER_BASE_URL = "http://127.0.0.1:" + FILE_SERVER_PORT;

function convertLocalPathToUrl(filePath: string): string {
  // 已经是 URL 的直接返回
  if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
    return filePath;
  }
  if (filePath.startsWith("base64://")) {
    return filePath;
  }
  
  // 将本地路径转换为 HTTP URL
  if (filePath.startsWith("/root/openclaw/work/")) {
    const relativePath = filePath.substring("/root/openclaw/work".length);
    // 注意:不要对路径使用 encodeURIComponent,否则 / 会被编码为 %2F
    const url = FILE_SERVER_BASE_URL + relativePath;
    console.log("[QQ] Converted: " + filePath + " -> " + url);
    return url;
  }
  
  return filePath;
}

踩坑提醒:如果你对 relativePath 使用了 encodeURIComponent(),会导致路径中的 / 被编码为 %2F,最终生成类似 http://172.17.0.1:18790%2Ftest.txt 的错误 URL,NapCat 会报 Invalid URL 错误。

3.4 文件上传接口

client.ts 中封装 OneBot11 的文件上传 API:

// 上传私聊文件
async uploadPrivateFile(userId: number, file: string, name: string) {
  return this.callApi("upload_private_file", { user_id: userId, file, name });
}

// 上传群文件
async uploadGroupFile(groupId: number, file: string, name: string, folder = "") {
  return this.callApi("upload_group_file", { group_id: groupId, file, name, folder });
}

3.5 发送文件的完整流程

channel.ts 中实现文件发送逻辑:

async function sendFileToTarget(
  client: OneBotClient, to: string, fileUrl: string, fileName: string
): Promise<boolean> {
  const target = normalizeTarget(to);
  const processedUrl = convertLocalPathToUrl(fileUrl);
  
  if (target.startsWith("group:")) {
    const groupId = parseInt(target.replace("group:", ""), 10);
    if (isNaN(groupId)) return false;
    await client.uploadGroupFile(groupId, processedUrl, fileName);
    return true;
  } else if (/^\d+$/.test(target)) {
    const userId = parseInt(target, 10);
    await client.uploadPrivateFile(userId, processedUrl, fileName);
    return true;
  }
  return false;
}

3.6 验证文件发送

重启 OpenClaw 后测试:

# 检查文件服务器是否启动
curl -I http://127.0.0.1:18790/

# 创建测试文件
echo "Hello World" > /root/openclaw/work/test.txt

# 检查文件是否可访问
curl http://127.0.0.1:18790/test.txt

然后在 QQ 中对机器人说:“帮我创建一个 txt 文件并发送给我”。


4. 功能二:图片发送与接收

4.1 图片发送

图片发送使用 OneBot11 的 image 消息段,原理和文件类似,但不需要走文件上传接口:

// 发送图片消息段
[{ type: "image", data: { file: "http://your-server:18790/path/to/image.png" } }]

关键是根据文件扩展名判断媒体类型:

function detectMediaType(url: string): "image" | "audio" | "video" | "file" {
  const ext = path.extname(url).toLowerCase();
  if ([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext)) return "image";
  if ([".mp3", ".wav", ".amr", ".silk", ".ogg"].includes(ext)) return "audio";
  if ([".mp4", ".avi", ".mov", ".mkv"].includes(ext)) return "video";
  return "file";
}

4.2 接收用户发送的图片

当用户发送图片时,raw_message 可能为空。我们需要为媒体消息添加描述性文本,否则 OpenClaw 会回复"没收到文本":

client.on("message", async (event) => {
  if (event.post_type !== "message") return;

  let text = event.raw_message || "";
  
  // 如果消息为空但包含媒体,添加描述性文本
  if (!text && event.message && Array.isArray(event.message)) {
    const mediaTypes = event.message
      .map((seg: any) => seg.type)
      .filter((t: string) => t !== "text");
    if (mediaTypes.length > 0) {
      const descriptions = mediaTypes.map((type: string) => {
        if (type === "image") return "[图片]";
        if (type === "file") return "[文件]";
        if (type === "video") return "[视频]";
        if (type === "record") return "[语音]";
        return "[" + type + "]";
      });
      text = descriptions.join(" ");
    }
  }
  
  // 忽略空消息(系统消息、回执等)
  if (!text || text.trim() === "") {
    console.log("[QQ] Ignoring empty message event");
    return;
  }
  // ... 后续处理
});

4.3 在 sendMedia 中统一处理

outbound.sendMedia 是 OpenClaw 发送媒体的统一出口,需要根据媒体类型选择不同的发送方式:

sendMedia: async ({ to, text, mediaUrl, accountId }) => {
  const client = getClientForAccount(accountId || DEFAULT_ACCOUNT_ID);
  if (!client) return { channel: "qq", sent: false, error: "Client not connected" };

  try {
    // 拒绝 data URL(base64 编码的内容用户看不懂)
    if (mediaUrl.startsWith("data:")) {
      return { channel: "qq", sent: false, 
        error: "Data URLs not supported. Save file and use HTTP URL." };
    }
    
    const mediaType = detectMediaType(mediaUrl);
    
    if (mediaType === "image" || mediaType === "audio" || mediaType === "video") {
      const processedUrl = convertLocalPathToUrl(mediaUrl);
      const message: OneBotMessage = [];
      if (text) message.push({ type: "text", data: { text } });
      
      if (mediaType === "image") {
        message.push({ type: "image", data: { file: processedUrl } });
      } else if (mediaType === "audio") {
        message.push({ type: "record", data: { file: processedUrl } });
      } else if (mediaType === "video") {
        message.push({ type: "video", data: { file: processedUrl } });
      }
      
      const success = await sendToTarget(client, to, message);
      return { channel: "qq", sent: success };
    }
    
    // 其他文件类型用文件上传接口
    const fileName = path.basename(mediaUrl);
    const success = await sendFileToTarget(client, to, mediaUrl, fileName);
    if (success && text) {
      await sendToTarget(client, to, [{ type: "text", data: { text } }]);
    }
    return { channel: "qq", sent: success };
  } catch (err) {
    console.error("[QQ] sendMedia error:", err);
    return { channel: "qq", sent: false, error: String(err) };
  }
}

5. 功能三:多媒体消息全类型支持

5.1 完整的类型定义

types.ts 中定义所有 OneBot11 消息段类型(文本、图片、表情、语音、视频、@、回复、JSON卡片、文件、戳一戳、骰子、猜拳、音乐分享、合并转发等),完整代码见第10节。

5.2 完整的 Client API

client.ts 中封装所有 OneBot11 API,包括:

  • 消息接口:发送私聊/群聊消息、撤回消息、获取消息、合并转发
  • 好友操作:好友列表、好友赞、处理好友请求
  • 群操作:群信息、群成员、踢人、禁言、设置管理员等
  • 文件操作:上传群文件、上传私聊文件、获取图片/语音/文件信息
  • 系统接口:登录信息、版本信息、运行状态
  • NapCat 扩展:戳一戳、设置头像、设置签名、消息表情回复、标记已读、聊天历史、AI语音等

完整代码见第10节。


6. 功能四:主动发送消息

6.1 为什么需要主动发送?

OpenClaw 的 Agent 是被动响应的——只有收到用户消息时才会回复。但很多场景需要主动发送消息:

  • 定时问候(早安、晚安)
  • 服务器告警(端口被扫描、磁盘满)
  • 新邮件通知
  • 任务进度报告

6.2 实现原理

NapCat 的 OneBot11 WebSocket 接口是双向的——不仅可以接收消息事件,还可以主动调用 API 发送消息。我们只需要建立一个独立的 WebSocket 连接,发送 send_private_msg 请求即可。

6.3 创建发送工具

首先初始化项目:

mkdir -p /root/qq-tools
cd /root/qq-tools
npm init -y
npm install ws

创建 /root/qq-tools/send-message.js

#!/usr/bin/env node

const WebSocket = require('ws');

const NAPCAT_WS_URL = 'ws://127.0.0.1:3001';
const ACCESS_TOKEN = 'your_token_here';  // 替换为你的 NapCat token

function sendQQMessage(target, message) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(NAPCAT_WS_URL, {
      headers: { 'Authorization': 'Bearer ' + ACCESS_TOKEN }
    });

    const timeout = setTimeout(() => {
      ws.close();
      reject(new Error('发送超时'));
    }, 10000);

    ws.on('open', () => {
      let action, params;
      
      if (target.startsWith('group:')) {
        const groupId = parseInt(target.replace('group:', ''), 10);
        action = 'send_group_msg';
        params = {
          group_id: groupId,
          message: [{ type: 'text', data: { text: message } }]
        };
      } else {
        const userId = parseInt(target, 10);
        action = 'send_private_msg';
        params = {
          user_id: userId,
          message: [{ type: 'text', data: { text: message } }]
        };
      }

      ws.send(JSON.stringify({
        action: action,
        params: params,
        echo: 'send_' + Date.now()
      }));
    });

    ws.on('message', (data) => {
      const response = JSON.parse(data.toString());
      if (response.echo && response.echo.startsWith('send_')) {
        clearTimeout(timeout);
        if (response.status === 'ok') {
          resolve(response.data);
        } else {
          reject(new Error(response.message || '发送失败'));
        }
        ws.close();
      }
    });

    ws.on('error', (error) => {
      clearTimeout(timeout);
      reject(error);
    });
  });
}

// CLI 入口
if (require.main === module) {
  const args = process.argv.slice(2);
  if (args.length < 2) {
    console.error('用法: node send-message.js <QQ号或group:群号> <消息内容>');
    process.exit(1);
  }
  const [target, ...messageParts] = args;
  sendQQMessage(target, messageParts.join(' '))
    .then(() => { console.log('发送成功'); process.exit(0); })
    .catch((err) => { console.error('发送失败:', err.message); process.exit(1); });
}

module.exports = { sendQQMessage };

6.4 测试主动发送

# 发送私聊消息
node /root/qq-tools/send-message.js 你的QQ号 "Hello! 这是一条主动发送的消息"

# 发送群消息
node /root/qq-tools/send-message.js group:群号 "这是一条群消息"

如果一切正常,你会立即在 QQ 上收到消息。这条消息完全绕过了 OpenClaw Agent,直接通过 NapCat API 发送。


7. 功能五:定时任务与随机问候

7.1 方案选择

定时任务有两种实现方式:

方案 优点 缺点
Linux crontab 简单可靠,不依赖 OpenClaw 功能单一
OpenClaw Cron API 可以调用 Agent 生成动态内容 依赖 Gateway 运行

推荐使用 Linux crontab,因为更稳定可靠。

7.2 简化版定时任务工具

创建 /root/qq-tools/simple-cron.js

#!/usr/bin/env node

const SEND_SCRIPT = '/root/qq-tools/send-message.js';
const QQ_USER = '你的QQ号';

function execSync(command) {
  return require('child_process').execSync(command, { encoding: 'utf-8' });
}

// 添加每日定时任务
function addDailyTask(hour, minute, message) {
  try {
    const cronExpr = hour + ' ' + minute + ' * * *';
    const jobId = 'daily_' + hour + '_' + minute + '_' + Date.now();
    
    let currentCrontab = '';
    try {
      currentCrontab = execSync('crontab -l 2>/dev/null').toString().trim();
    } catch (e) {
      currentCrontab = '# QQ定时任务';
    }
    
    const taskLine = cronExpr + ' node ' + SEND_SCRIPT + ' ' + QQ_USER 
      + " '" + message + "' # " + jobId;
    const newCrontab = currentCrontab + '\n' + taskLine;
    
    execSync('echo "' + newCrontab.replace(/"/g, '\\"') + '" > /tmp/new_cron.txt');
    execSync('crontab /tmp/new_cron.txt');
    
    console.log('每日任务创建成功: ' + hour + ':' + minute + ' -> ' + message);
    return { success: true, jobId };
  } catch (error) {
    console.error('创建失败:', error.message);
    return { success: false };
  }
}

// 列出所有任务
function listTasks() {
  try {
    console.log('=== 当前定时任务 ===');
    const result = execSync('crontab -l 2>/dev/null').toString();
    const lines = result.trim().split('\n').filter(
      line => line.trim() && !line.startsWith('#')
    );
    if (lines.length === 0) {
      console.log('(无任务)');
    } else {
      lines.forEach((line, i) => console.log((i+1) + '. ' + line));
    }
  } catch (e) {
    console.log('(无任务)');
  }
}

// 删除任务
function removeTask(jobId) {
  const result = execSync('crontab -l').toString();
  const lines = result.split('\n').filter(line => !line.includes(jobId));
  execSync('echo "' + lines.join('\n') + '" | crontab -');
  console.log('任务已删除: ' + jobId);
}

// CLI
const args = process.argv.slice(2);
const cmd = args[0];
if (cmd === 'add-daily') {
  addDailyTask(parseInt(args[1]), parseInt(args[2]), args.slice(3).join(' '));
} else if (cmd === 'list') {
  listTasks();
} else if (cmd === 'remove') {
  removeTask(args[1]);
} else if (cmd === 'examples') {
  addDailyTask(8, 0, '早上好!新的一天开始了');
  addDailyTask(12, 0, '该吃午饭了!记得休息一下');
  addDailyTask(22, 0, '晚安!早点休息哦');
  console.log('示例任务创建完成');
} else {
  console.log('用法:');
  console.log('  node simple-cron.js add-daily <时> <分> <消息>');
  console.log('  node simple-cron.js list');
  console.log('  node simple-cron.js remove <jobId>');
  console.log('  node simple-cron.js examples');
}

module.exports = { addDailyTask, listTasks, removeTask };

7.3 使用示例

# 创建每天早上 8:00 的问候
node /root/qq-tools/simple-cron.js add-daily 8 0 "早上好!新的一天开始了"

# 创建每天晚上 22:00 的晚安
node /root/qq-tools/simple-cron.js add-daily 22 0 "晚安!早点休息"

# 一键创建示例任务(早安、午餐、晚安)
node /root/qq-tools/simple-cron.js examples

# 查看所有任务
node /root/qq-tools/simple-cron.js list

# 删除指定任务
node /root/qq-tools/simple-cron.js remove daily_8_0_1234567890

7.4 高级:随机问候(模拟真人)

固定时间的问候太机械了,真人不会每天准时 8:00 说早安。我们可以实现随机问候——每天生成 15-25 个随机时间点,发送不同的问候语。

创建 /root/qq-tools/random-greetings.js

#!/usr/bin/env node

const SEND_SCRIPT = '/root/qq-tools/send-message.js';
const QQ_USER = '你的QQ号';
const MIN_GREETINGS = 15;
const MAX_GREETINGS = 25;
const START_HOUR = 7;
const END_HOUR = 23;

// 问候语库(按时间段分类)
const greetingTemplates = {
  morning: [
    "早安!新的一天开始了,记得吃早餐哦",
    "早上好呀!今天也要元气满满!",
    "起床啦!美好的一天从现在开始!",
  ],
  noon: [
    "中午好!吃饭了吗?",
    "午休时间到了,记得休息一下",
    "午餐时间到!美食在向你招手",
  ],
  afternoon: [
    "下午好!工作累了吗?休息一下",
    "下午茶时间到!",
    "下午好!记得多喝水",
  ],
  evening: [
    "晚上好呀!今天过得怎么样?",
    "晚饭吃了吗?不要饿肚子哦",
    "晚上好!放松一下自己吧",
  ],
  random: [
    "突然想你了,就给你发个消息",
    "在干嘛呀?",
    "嘿!今天一切都好吗?",
    "今天也要开心哦!",
  ]
};

// 根据当前时间生成问候语
function generateGreeting() {
  const hour = new Date().getHours();
  let category;
  if (hour >= 7 && hour < 10) category = 'morning';
  else if (hour >= 10 && hour < 14) category = 'noon';
  else if (hour >= 14 && hour < 18) category = 'afternoon';
  else category = 'evening';
  
  // 30% 概率使用随机问候
  if (Math.random() < 0.3) category = 'random';
  
  const templates = greetingTemplates[category];
  return templates[Math.floor(Math.random() * templates.length)];
}

// 生成随机时间点
function generateRandomTimes(count) {
  const times = [];
  const startMin = START_HOUR * 60;
  const endMin = END_HOUR * 60 + 59;
  
  while (times.length < count) {
    const randomMin = Math.floor(Math.random() * (endMin - startMin + 1)) + startMin;
    const h = Math.floor(randomMin / 60);
    const m = randomMin % 60;
    const timeStr = h + ':' + m.toString().padStart(2, '0');
    if (!times.includes(timeStr)) times.push(timeStr);
  }
  
  return times.sort((a, b) => {
    const [ha, ma] = a.split(':').map(Number);
    const [hb, mb] = b.split(':').map(Number);
    return ha * 60 + ma - (hb * 60 + mb);
  });
}

// 生成今天的问候任务并写入 crontab
function createGreetingTasks() {
  const count = Math.floor(Math.random() * (MAX_GREETINGS - MIN_GREETINGS + 1)) + MIN_GREETINGS;
  const times = generateRandomTimes(count);
  const { execSync } = require('child_process');
  
  // 获取当前 crontab,移除旧的问候任务
  let currentCrontab = '';
  try {
    currentCrontab = execSync('crontab -l 2>/dev/null').toString();
  } catch (e) {
    currentCrontab = '# 定时任务';
  }
  
  const lines = currentCrontab.split('\n').filter(line => 
    !line.includes('# greeting_') && line.trim() && !line.startsWith('#')
  );
  
  // 添加新的问候任务
  const jobId = 'greeting_' + Date.now();
  times.forEach(time => {
    const [hour, minute] = time.split(':').map(Number);
    const message = generateGreeting();
    const cronJob = minute + ' ' + hour + ' * * * node ' + SEND_SCRIPT 
      + ' ' + QQ_USER + " '" + message.replace(/'/g, "\\'") + "' # " + jobId;
    lines.push(cronJob);
  });
  
  // 写入 crontab
  const newCrontab = lines.join('\n');
  execSync('echo "' + newCrontab.replace(/"/g, '\\"') + '" | crontab -');
  
  console.log('随机问候任务创建成功!共 ' + times.length + ' 个时间点');
  console.log('时间点: ' + times.join(', '));
}

// CLI
const cmd = process.argv[2];
if (cmd === 'generate') {
  createGreetingTasks();
} else if (cmd === 'test') {
  const msg = generateGreeting();
  console.log('测试问候: ' + msg);
  require('child_process').execSync(
    'node ' + SEND_SCRIPT + ' ' + QQ_USER + " '" + msg + "'"
  );
} else if (cmd === 'preview') {
  const count = Math.floor(Math.random() * (MAX_GREETINGS - MIN_GREETINGS + 1)) + MIN_GREETINGS;
  const times = generateRandomTimes(count);
  console.log('预览今天的 ' + times.length + ' 个问候时间点:');
  times.forEach(t => console.log('  ' + t));
} else {
  console.log('用法:');
  console.log('  node random-greetings.js generate  # 生成今天的问候任务');
  console.log('  node random-greetings.js test       # 测试发送一条问候');
  console.log('  node random-greetings.js preview    # 预览随机时间');
}

7.5 每天自动刷新随机问候

将随机问候的生成也加入 crontab,每天凌晨自动刷新:

# 每天凌晨 0:05 重新生成随机问候时间
echo "5 0 * * * node /root/qq-tools/random-greetings.js generate" | crontab -

# 或者手动执行一次
node /root/qq-tools/random-greetings.js generate

8. 功能六:服务器事件监听与告警

8.1 功能说明

创建一个后台服务,持续监控服务器状态,当检测到异常时自动通过 QQ 发送告警:

  • 端口监控:检测 SSH、HTTP 等端口的活动连接
  • 磁盘监控:磁盘使用率超过阈值时告警
  • 内存监控:内存使用率超过阈值时告警
  • 文件监控:监听指定文件变化(如新邮件)

8.2 实现代码

创建 /root/qq-tools/event-monitor.js

#!/usr/bin/env node

const fs = require('fs');
const { exec } = require('child_process');
const { sendQQMessage } = require('./send-message.js');

// 配置
const CONFIG = {
  qq_user: '你的QQ号',
  check_interval: 60000,          // 检查间隔 60 秒
  monitored_ports: [22, 80, 443], // 监听的端口
  disk_warning_threshold: 90,     // 磁盘使用率告警阈值 %
  memory_warning_threshold: 90,   // 内存使用率告警阈值 %
};

// 状态记录(避免重复告警)
const state = {
  last_port_access: {},
  last_disk_warning: 0,
  last_memory_warning: 0,
};

// 检查端口访问
function checkPortAccess() {
  CONFIG.monitored_ports.forEach(port => {
    exec('ss -tn | grep :' + port + ' | grep ESTAB', (error, stdout) => {
      if (stdout && stdout.trim()) {
        const connections = stdout.trim().split('\n');
        const now = Date.now();
        
        // 每 5 分钟最多通知一次
        if (!state.last_port_access[port] || now - state.last_port_access[port] > 300000) {
          const ips = [...new Set(connections.map(line => {
            const match = line.match(/(\d+\.\d+\.\d+\.\d+)/);
            return match ? match[1] : 'unknown';
          }))];
          
          sendQQMessage(CONFIG.qq_user, 
            '端口 ' + port + ' 检测到 ' + connections.length + ' 个活动连接\n来源: ' + ips.join(', ')
          ).catch(err => console.error('发送失败:', err));
          
          state.last_port_access[port] = now;
        }
      }
    });
  });
}

// 检查磁盘空间
function checkDiskSpace() {
  exec("df -h / | tail -1 | awk '{print $5}' | sed 's/%//'", (error, stdout) => {
    if (!error && stdout) {
      const usage = parseInt(stdout.trim(), 10);
      const now = Date.now();
      if (usage >= CONFIG.disk_warning_threshold && now - state.last_disk_warning > 3600000) {
        sendQQMessage(CONFIG.qq_user, '磁盘空间警告!使用率: ' + usage + '%')
          .catch(err => console.error('发送失败:', err));
        state.last_disk_warning = now;
      }
    }
  });
}

// 检查内存使用
function checkMemoryUsage() {
  exec("free | grep Mem | awk '{print ($3/$2) * 100.0}'", (error, stdout) => {
    if (!error && stdout) {
      const usage = parseFloat(stdout.trim());
      const now = Date.now();
      if (usage >= CONFIG.memory_warning_threshold && now - state.last_memory_warning > 3600000) {
        sendQQMessage(CONFIG.qq_user, '内存使用警告!使用率: ' + usage.toFixed(1) + '%')
          .catch(err => console.error('发送失败:', err));
        state.last_memory_warning = now;
      }
    }
  });
}

// 启动监控
function startMonitoring() {
  console.log('[事件监听] 服务启动');
  console.log('[事件监听] 监听端口:', CONFIG.monitored_ports.join(', '));
  
  sendQQMessage(CONFIG.qq_user, '事件监听服务已启动,正在监控系统状态...')
    .catch(err => console.error('启动通知发送失败:', err));
  
  setInterval(() => {
    checkPortAccess();
    checkDiskSpace();
    checkMemoryUsage();
  }, CONFIG.check_interval);
}

// 优雅退出
process.on('SIGINT', () => {
  sendQQMessage(CONFIG.qq_user, '事件监听服务已停止')
    .then(() => process.exit(0))
    .catch(() => process.exit(0));
});

if (require.main === module) {
  startMonitoring();
}

8.3 部署为系统服务

创建 systemd 服务文件 /etc/systemd/system/qq-event-monitor.service

[Unit]
Description=QQ Event Monitor Service
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/root/qq-tools
ExecStart=/usr/bin/node /root/qq-tools/event-monitor.js
Restart=always
RestartSec=10
StandardOutput=append:/var/log/qq-event-monitor.log
StandardError=append:/var/log/qq-event-monitor.log

[Install]
WantedBy=multi-user.target

启用服务:

sudo systemctl daemon-reload
sudo systemctl enable qq-event-monitor.service
sudo systemctl start qq-event-monitor.service
sudo systemctl status qq-event-monitor.service

9. 踩坑记录与解决方案

9.1 文件发送报 Invalid URL

现象:OpenClaw 日志中出现 Error: Invalid URL,URL 类似 http://127.0.0.1:18790%2Ftest.txt

原因:对文件路径使用了 encodeURIComponent(),导致 / 被编码为 %2F

解决:直接拼接路径,不要编码:

// 错误写法
const url = FILE_SERVER_BASE_URL + "/" + encodeURIComponent(relativePath);

// 正确写法
const url = FILE_SERVER_BASE_URL + relativePath;

9.2 NapCat 无法访问文件服务器

现象:NapCat 日志报连接 127.0.0.1:18790 失败

原因:NapCat 在 Docker 容器中,127.0.0.1 指向容器自身,不是宿主机

解决:使用宿主机的 Docker 网桥 IP:

# 查看 Docker 网桥 IP
ip addr show docker0 | grep inet
# 输出类似:inet 172.17.0.1/16

# 在 channel.ts 中使用这个 IP
const FILE_SERVER_BASE_URL = "http://172.17.0.1:18790";

9.3 发送文件时附带多余消息

现象:AI 发送文件后,还会额外发一条 “I didn’t receive any text in your message”

原因:NapCat 发送文件后会产生一个空的消息回执事件,OpenClaw 将其当作用户消息处理

解决:在消息接收处添加空消息过滤:

// 忽略空消息(系统消息、回执等)
if (!text || text.trim() === "") {
  console.log("[QQ] Ignoring empty message event");
  return;
}

9.4 图片消息被当作空消息

现象:用户发送图片后,AI 回复"没收到文本"

原因:图片消息的 raw_message 为空,OpenClaw 认为没有收到内容

解决:为媒体消息添加描述性文本(见第4.2节)

9.5 base64 编码的文件用户看不懂

现象:AI 发送的文件内容是一堆 base64 编码的字符

原因:使用了 data: URL 发送文件

解决:在 sendMedia 中拒绝 data: URL,强制使用 HTTP URL:

if (mediaUrl.startsWith("data:")) {
  return { channel: "qq", sent: false, 
    error: "Data URLs not supported. Save file and use HTTP URL." };
}

10. 完整代码汇总

10.1 QQ 插件文件结构

~/.openclaw/extensions/qq/src/
  |- channel.ts      # 主逻辑:消息收发、文件服务器集成
  |- client.ts       # OneBot11 WebSocket 客户端
  |- config.ts       # 配置 Schema
  |- file-server.ts  # HTTP 文件服务器
  |- runtime.ts      # 运行时管理
  |- types.ts        # OneBot11 类型定义

10.2 工具脚本文件结构

/root/qq-tools/
  |- package.json          # Node.js 依赖(ws)
  |- send-message.js       # 主动发送 QQ 消息
  |- simple-cron.js        # 定时任务管理
  |- random-greetings.js   # 随机问候生成
  |- event-monitor.js      # 事件监听与告警

10.3 OpenClaw 配置

~/.openclaw/openclaw.json 中的关键配置:

{
  "plugins": {
    "entries": {
      "qq": { "enabled": true }
    }
  },
  "channels": {
    "qq": {
      "enabled": true,
      "wsUrl": "ws://127.0.0.1:3001",
      "accessToken": "your_napcat_token"
    }
  },
  "gateway": {
    "port": 18789,
    "controlUi": {
      "allowInsecureAuth": true
    }
  }
}

完整的源代码文件内容较长,建议直接参考本文各章节中的代码片段进行实现。如需完整文件,可以在评论区留言。


11. 总结

通过本教程,我们在基础文字聊天的基础上,实现了以下高级功能:

功能 状态 关键技术
文件发送 已实现 HTTP 文件服务器 + OneBot11 upload_private_file
图片发送 已实现 image 消息段 + 文件服务器 URL
多媒体支持 已实现 完整的 OneBot11 消息段类型定义
接收图片 已实现 媒体消息描述性文本 + 空消息过滤
主动发消息 已实现 独立 WebSocket 连接 + send_private_msg
定时任务 已实现 Linux crontab + send-message.js
随机问候 已实现 随机时间生成 + 分时段问候语库
事件告警 已实现 系统状态轮询 + 频率控制 + systemd 服务

核心架构要点

  1. 文件发送的关键是内置 HTTP 文件服务器,解决 Docker 容器无法访问宿主机文件的问题
  2. 主动发送的关键是直接连接 NapCat 的 WebSocket API,绕过 OpenClaw Agent 的被动响应限制
  3. 定时任务的关键是利用 Linux crontab 的稳定性,配合 send-message.js 实现定时发送
  4. 事件告警的关键是后台轮询 + 频率控制,避免消息轰炸

后续可以继续扩展的方向

  • 接入邮件通知(IMAP 监听新邮件)
  • 接入 GitHub Webhooks(代码提交通知)
  • 接入天气 API(每天早上发送天气预报)
  • 接入 RSS 订阅(新闻推送)
  • AI 生成动态问候内容(结合 OpenClaw Agent)

作者说:从最初只能发文字,到现在能发文件、发图片、主动问候、服务器告警……一步步把 QQ 机器人打造成了真正的 AI 助手。希望这篇教程对你有帮助!如果有问题欢迎评论区交流。

相关文章

  • 上一篇:《【全网首发!】OpenClaw 接入 QQ 个人号完整教程》
  • OpenClaw 官方文档
  • NapCat OneBot11 API 文档
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐