📄 proactive.ts

/**
 * QQ Bot 主动发送消息模块
 * 
 * 该模块提供以下能力:
 * 1. 记录已知用户(曾与机器人交互过的用户)
 * 2. 主动发送消息给用户或群组
 * 3. 查询已知用户列表
 */

import * as fs from "node:fs";
import * as path from "node:path";
import type { ResolvedQQBotAccount } from "./types.js";

// ============ 类型定义(本地) ============

/**
 * 已知用户信息
 */
export interface KnownUser {
  type: "c2c" | "group" | "channel";
  openid: string;
  accountId: string;
  nickname?: string;
  firstInteractionAt: number;
  lastInteractionAt: number;
}

/**
 * 主动发送消息选项
 */
export interface ProactiveSendOptions {
  to: string;
  text: string;
  type?: "c2c" | "group" | "channel";
  imageUrl?: string;
  accountId?: string;
}

/**
 * 主动发送消息结果
 */
export interface ProactiveSendResult {
  success: boolean;
  messageId?: string;
  timestamp?: number | string;
  error?: string;
}

/**
 * 列出已知用户选项
 */
export interface ListKnownUsersOptions {
  type?: "c2c" | "group" | "channel";
  accountId?: string;
  sortByLastInteraction?: boolean;
  limit?: number;
}
import {
  getAccessToken,
  sendProactiveC2CMessage,
  sendProactiveGroupMessage,
  sendChannelMessage,
  sendC2CImageMessage,
  sendGroupImageMessage,
} from "./api.js";
import { resolveQQBotAccount } from "./config.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk";

// ============ 用户存储管理 ============

/**
 * 已知用户存储
 * 使用简单的 JSON 文件存储,保存在 .openclaw/qqbot 目录下
 */
const STORAGE_DIR = path.join(process.env.HOME || "/home/ubuntu", ".openclaw", "qqbot", "data");
const KNOWN_USERS_FILE = path.join(STORAGE_DIR, "known-users.json");

// 内存缓存
let knownUsersCache: Map<string, KnownUser> | null = null;
let cacheLastModified = 0;

/**
 * 确保存储目录存在
 */
function ensureStorageDir(): void {
  if (!fs.existsSync(STORAGE_DIR)) {
    fs.mkdirSync(STORAGE_DIR, { recursive: true });
  }
}

/**
 * 生成用户唯一键
 */
function getUserKey(type: string, openid: string, accountId: string): string {
  return `${accountId}:${type}:${openid}`;
}

/**
 * 从文件加载已知用户
 */
function loadKnownUsers(): Map<string, KnownUser> {
  if (knownUsersCache !== null) {
    // 检查文件是否被修改
    try {
      const stat = fs.statSync(KNOWN_USERS_FILE);
      if (stat.mtimeMs <= cacheLastModified) {
        return knownUsersCache;
      }
    } catch {
      // 文件不存在,使用缓存
      return knownUsersCache;
    }
  }

  const users = new Map<string, KnownUser>();
  
  try {
    if (fs.existsSync(KNOWN_USERS_FILE)) {
      const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
      const parsed = JSON.parse(data) as KnownUser[];
      for (const user of parsed) {
        const key = getUserKey(user.type, user.openid, user.accountId);
        users.set(key, user);
      }
      cacheLastModified = fs.statSync(KNOWN_USERS_FILE).mtimeMs;
    }
  } catch (err) {
    console.error(`[qqbot:proactive] Failed to load known users: ${err}`);
  }

  knownUsersCache = users;
  return users;
}

/**
 * 保存已知用户到文件
 */
function saveKnownUsers(users: Map<string, KnownUser>): void {
  try {
    ensureStorageDir();
    const data = Array.from(users.values());
    fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(data, null, 2), "utf-8");
    cacheLastModified = Date.now();
    knownUsersCache = users;
  } catch (err) {
    console.error(`[qqbot:proactive] Failed to save known users: ${err}`);
  }
}

/**
 * 记录一个已知用户(当收到用户消息时调用)
 * 
 * @param user - 用户信息
 */
export function recordKnownUser(user: Omit<KnownUser, "firstInteractionAt">): void {
  const users = loadKnownUsers();
  const key = getUserKey(user.type, user.openid, user.accountId);
  
  const existing = users.get(key);
  const now = user.lastInteractionAt || Date.now();
  
  users.set(key, {
    ...user,
    lastInteractionAt: now,
    firstInteractionAt: existing?.firstInteractionAt ?? now,
    // 更新昵称(如果有新的)
    nickname: user.nickname || existing?.nickname,
  });
  
  saveKnownUsers(users);
  console.log(`[qqbot:proactive] Recorded user: ${key}`);
}

/**
 * 获取一个已知用户
 * 
 * @param type - 用户类型
 * @param openid - 用户 openid
 * @param accountId - 账户 ID
 */
export function getKnownUser(type: string, openid: string, accountId: string): KnownUser | undefined {
  const users = loadKnownUsers();
  const key = getUserKey(type, openid, accountId);
  return users.get(key);
}

/**
 * 列出已知用户
 * 
 * @param options - 过滤选项
 */
export function listKnownUsers(options?: ListKnownUsersOptions): KnownUser[] {
  const users = loadKnownUsers();
  let result = Array.from(users.values());
  
  // 过滤类型
  if (options?.type) {
    result = result.filter(u => u.type === options.type);
  }
  
  // 过滤账户
  if (options?.accountId) {
    result = result.filter(u => u.accountId === options.accountId);
  }
  
  // 排序
  if (options?.sortByLastInteraction !== false) {
    result.sort((a, b) => b.lastInteractionAt - a.lastInteractionAt);
  }
  
  // 限制数量
  if (options?.limit && options.limit > 0) {
    result = result.slice(0, options.limit);
  }
  
  return result;
}

/**
 * 删除一个已知用户
 * 
 * @param type - 用户类型
 * @param openid - 用户 openid
 * @param accountId - 账户 ID
 */
export function removeKnownUser(type: string, openid: string, accountId: string): boolean {
  const users = loadKnownUsers();
  const key = getUserKey(type, openid, accountId);
  const deleted = users.delete(key);
  if (deleted) {
    saveKnownUsers(users);
  }
  return deleted;
}

/**
 * 清除所有已知用户
 * 
 * @param accountId - 可选,只清除指定账户的用户
 */
export function clearKnownUsers(accountId?: string): number {
  const users = loadKnownUsers();
  let count = 0;
  
  if (accountId) {
    for (const [key, user] of users) {
      if (user.accountId === accountId) {
        users.delete(key);
        count++;
      }
    }
  } else {
    count = users.size;
    users.clear();
  }
  
  if (count > 0) {
    saveKnownUsers(users);
  }
  return count;
}

// ============ 主动发送消息 ============

/**
 * 主动发送消息(带配置解析)
 * 注意:与 outbound.ts 中的 sendProactiveMessage 不同,这个函数接受 OpenClawConfig 并自动解析账户
 * 
 * @param options - 发送选项
 * @param cfg - OpenClaw 配置
 * @returns 发送结果
 * 
 * @example
 * ```typescript
 * // 发送私聊消息
 * const result = await sendProactive({
 *   to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4",  // 用户 openid
 *   text: "你好!这是一条主动消息",
 *   type: "c2c",
 * }, cfg);
 * 
 * // 发送群聊消息
 * const result = await sendProactive({
 *   to: "A1B2C3D4E5F6A7B8",  // 群组 openid
 *   text: "群公告:今天有活动",
 *   type: "group",
 * }, cfg);
 * 
 * // 发送带图片的消息
 * const result = await sendProactive({
 *   to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4",
 *   text: "看看这张图片",
 *   imageUrl: "https://example.com/image.png",
 *   type: "c2c",
 * }, cfg);
 * ```
 */
export async function sendProactive(
  options: ProactiveSendOptions,
  cfg: OpenClawConfig
): Promise<ProactiveSendResult> {
  const { to, text, type = "c2c", imageUrl, accountId = "default" } = options;
  
  // 解析账户配置
  const account = resolveQQBotAccount(cfg, accountId);
  
  if (!account.appId || !account.clientSecret) {
    return {
      success: false,
      error: "QQBot not configured (missing appId or clientSecret)",
    };
  }
  
  try {
    const accessToken = await getAccessToken(account.appId, account.clientSecret);
    
    // 如果有图片,先发送图片
    if (imageUrl) {
      try {
        if (type === "c2c") {
          await sendC2CImageMessage(accessToken, to, imageUrl, undefined, undefined);
        } else if (type === "group") {
          await sendGroupImageMessage(accessToken, to, imageUrl, undefined, undefined);
        }
        console.log(`[qqbot:proactive] Sent image to ${type}:${to}`);
      } catch (err) {
        console.error(`[qqbot:proactive] Failed to send image: ${err}`);
        // 图片发送失败不影响文本发送
      }
    }
    
    // 发送文本消息
    let result: { id: string; timestamp: number | string };
    
    if (type === "c2c") {
      result = await sendProactiveC2CMessage(accessToken, to, text);
    } else if (type === "group") {
      result = await sendProactiveGroupMessage(accessToken, to, text);
    } else if (type === "channel") {
      // 频道消息需要 channel_id,这里暂时不支持主动发送
      return {
        success: false,
        error: "Channel proactive messages are not supported. Please use group or c2c.",
      };
    } else {
      return {
        success: false,
        error: `Unknown message type: ${type}`,
      };
    }
    
    console.log(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`);
    
    return {
      success: true,
      messageId: result.id,
      timestamp: result.timestamp,
    };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    console.error(`[qqbot:proactive] Failed to send message: ${message}`);
    
    return {
      success: false,
      error: message,
    };
  }
}

/**
 * 批量发送主动消息
 * 
 * @param recipients - 接收者列表(openid 数组)
 * @param text - 消息内容
 * @param type - 消息类型
 * @param cfg - OpenClaw 配置
 * @param accountId - 账户 ID
 * @returns 发送结果列表
 */
export async function sendBulkProactiveMessage(
  recipients: string[],
  text: string,
  type: "c2c" | "group",
  cfg: OpenClawConfig,
  accountId = "default"
): Promise<Array<{ to: string; result: ProactiveSendResult }>> {
  const results: Array<{ to: string; result: ProactiveSendResult }> = [];
  
  for (const to of recipients) {
    const result = await sendProactive({ to, text, type, accountId }, cfg);
    results.push({ to, result });
    
    // 添加延迟,避免频率限制
    await new Promise(resolve => setTimeout(resolve, 500));
  }
  
  return results;
}

/**
 * 发送消息给所有已知用户
 * 
 * @param text - 消息内容
 * @param cfg - OpenClaw 配置
 * @param options - 过滤选项
 * @returns 发送结果统计
 */
export async function broadcastMessage(
  text: string,
  cfg: OpenClawConfig,
  options?: {
    type?: "c2c" | "group";
    accountId?: string;
    limit?: number;
  }
): Promise<{
  total: number;
  success: number;
  failed: number;
  results: Array<{ to: string; result: ProactiveSendResult }>;
}> {
  const users = listKnownUsers({
    type: options?.type,
    accountId: options?.accountId,
    limit: options?.limit,
    sortByLastInteraction: true,
  });
  
  // 过滤掉频道用户(不支持主动发送)
  const validUsers = users.filter(u => u.type === "c2c" || u.type === "group");
  
  const results: Array<{ to: string; result: ProactiveSendResult }> = [];
  let success = 0;
  let failed = 0;
  
  for (const user of validUsers) {
    const result = await sendProactive({
      to: user.openid,
      text,
      type: user.type as "c2c" | "group",
      accountId: user.accountId,
    }, cfg);
    
    results.push({ to: user.openid, result });
    
    if (result.success) {
      success++;
    } else {
      failed++;
    }
    
    // 添加延迟,避免频率限制
    await new Promise(resolve => setTimeout(resolve, 500));
  }
  
  return {
    total: validUsers.length,
    success,
    failed,
    results,
  };
}

// ============ 辅助函数 ============

/**
 * 根据账户配置直接发送主动消息(不需要 cfg)
 * 
 * @param account - 已解析的账户配置
 * @param to - 目标 openid
 * @param text - 消息内容
 * @param type - 消息类型
 */
export async function sendProactiveMessageDirect(
  account: ResolvedQQBotAccount,
  to: string,
  text: string,
  type: "c2c" | "group" = "c2c"
): Promise<ProactiveSendResult> {
  if (!account.appId || !account.clientSecret) {
    return {
      success: false,
      error: "QQBot not configured (missing appId or clientSecret)",
    };
  }
  
  try {
    const accessToken = await getAccessToken(account.appId, account.clientSecret);
    
    let result: { id: string; timestamp: number | string };
    
    if (type === "c2c") {
      result = await sendProactiveC2CMessage(accessToken, to, text);
    } else {
      result = await sendProactiveGroupMessage(accessToken, to, text);
    }
    
    return {
      success: true,
      messageId: result.id,
      timestamp: result.timestamp,
    };
  } catch (err) {
    return {
      success: false,
      error: err instanceof Error ? err.message : String(err),
    };
  }
}

/**
 * 获取已知用户统计
 */
export function getKnownUsersStats(accountId?: string): {
  total: number;
  c2c: number;
  group: number;
  channel: number;
} {
  const users = listKnownUsers({ accountId });
  
  return {
    total: users.length,
    c2c: users.filter(u => u.type === "c2c").length,
    group: users.filter(u => u.type === "group").length,
    channel: users.filter(u => u.type === "channel").length,
  };
}