android-wechat-sender.js 7.39 KB
/**
 * android-wechat-sender.js — 手机端微信消息发送(无 adb,直接 su)
 *
 * 运行环境:Pixel 10 Pro Fold 的 Termux / Debian chroot
 * 前提:已 root,LSPosed 模块已启用,微信主进程运行中
 *
 * 原理:su -c "content insert ..." → 模块 Provider → 广播 → 微信内部 API
 *       或 su -c "am broadcast -a com.hermes.wechat.SEND_MSG --es toUser ..."
 */

const { spawn } = require('child_process');

const PROVIDER_URI = 'content://com.hermes.wechat.xposed.provider/send';
const STATUS_URI   = 'content://com.hermes.wechat.xposed.provider/status';
const ACTION_SEND  = 'com.hermes.wechat.SEND_MSG';

/**
 * 检测当前是否已有 root 权限(uid=0)
 */
function hasRoot() {
  return process.getuid && process.getuid() === 0;
}

/**
 * 包装命令:非 root 时自动加 su -c
 */
function wrapCmd(cmd) {
  if (hasRoot()) return cmd;
  // su -c 的参数需要整体作为一个字符串传给 su
  return `su -c "${cmd.replace(/"/g, '\\"')}"`;
}

/**
 * 执行 shell 命令,返回 Promise<string>
 * @param {string} cmd — 原始命令(不含 su)
 * @param {number} timeoutMs
 */
function shell(cmd, timeoutMs = 15000) {
  const fullCmd = wrapCmd(cmd);
  const args = ['-c', fullCmd];
  // Debian chroot 中可能用 /bin/sh,Termux 中也有
  const shellBin = process.env.SHELL || '/system/bin/sh';

  return new Promise((resolve, reject) => {
    const proc = spawn(shellBin, args, { timeout: timeoutMs });
    let stdout = '';
    let stderr = '';
    proc.stdout.on('data', d => stdout += d.toString());
    proc.stderr.on('data', d => stderr += d.toString());
    proc.on('close', code => {
      if (code !== 0) {
        reject(new Error(`exit ${code}: ${stderr.trim() || stdout.trim()}`));
      } else {
        resolve(stdout.trim());
      }
    });
    proc.on('error', reject);
  });
}

/**
 * 转义单引号(shell 单引号内安全)
 * "it's" → "it'\''s"
 */
function escapeShellSingleQuote(str) {
  return str.replace(/'/g, "'\\''");
}

/**
 * 发送文本消息(通过 ContentProvider)
 * @param {string} toUser — wxid / filehelper / 群聊ID
 * @param {string} content — 消息内容
 * @returns {Promise<{ok: boolean, uri?: string, error?: string}>}
 */
async function sendTextMessage(toUser, content) {
  if (!toUser || !content) {
    return { ok: false, error: 'toUser and content are required' };
  }
  const safeToUser = escapeShellSingleQuote(toUser);
  const safeContent = escapeShellSingleQuote(content);

  const cmd = `content insert --uri ${PROVIDER_URI} --bind toUser:s:'${safeToUser}' --bind content:s:'${safeContent}'`;

  try {
    const out = await shell(cmd, 10000);
    if (out.includes(PROVIDER_URI)) {
      return { ok: true, uri: out };
    }
    return { ok: false, error: `unexpected output: ${out}` };
  } catch (e) {
    return { ok: false, error: e.message };
  }
}

/**
 * 发送文本消息(通过广播 → HermesAccessibilityService UI 自动化)
 *
 * 这是当前唯一可用的发送方式。A11y service 必须已启用(见 README)。
 *
 * @param {string} toUser - wxid(用于校验,不参与 UI 搜索)
 * @param {string} content - 消息内容
 * @param {object} [options]
 * @param {string} [options.toUserDisplayName] - **必传**:A11y 在搜索框中输入的名字
 *        通常是 conRemark || nickname,例如 `火星人` / `宜创AI全员群`。
 *        如果省略会 fallback 到 toUser,但绝大多数情况下用 wxid 是搜不到的。
 * @param {string} [options.quoteText] - 可选:要引用的消息文本片段(substring)。
 *        A11y 进入聊天后会在 bkl 气泡里按 substring 匹配,找到后长按 → 点"引用",
 *        然后输入 content + 点发送。找不到时降级为不引用。
 * @param {string} [options.atSender] - 可选:群里要 @ 的成员显示名(如 `小火子`)。
 *        A11y 会在输入框先打 "@" 触发选人面板,匹配 atSender 点击,再追加 content。
 * @returns {Promise<{ok: boolean, error?: string}>}
 */
async function sendTextMessageBroadcast(toUser, content, options = {}) {
  if (!toUser || !content) {
    return { ok: false, error: 'toUser and content are required' };
  }
  const displayName = options.toUserDisplayName || toUser;
  const safeToUser = escapeShellSingleQuote(toUser);
  const safeDisplayName = escapeShellSingleQuote(displayName);
  const safeContent = escapeShellSingleQuote(content);

  let cmd = `am broadcast -a ${ACTION_SEND} `
    + `--es toUser '${safeToUser}' `
    + `--es toUserDisplayName '${safeDisplayName}' `
    + `--es content '${safeContent}'`;

  if (options.quoteText) {
    // 截到 40 字符,避免 UI 渲染省略号导致 substring match 失败
    const qt = String(options.quoteText).substring(0, 40);
    cmd += ` --es quoteText '${escapeShellSingleQuote(qt)}'`;
  }
  if (options.atSender) {
    cmd += ` --es atSender '${escapeShellSingleQuote(options.atSender)}'`;
  }

  try {
    const out = await shell(cmd, 10000);
    // am broadcast 成功输出通常包含 "Broadcast completed: result=0"
    if (out.includes('Broadcast completed') || out.includes('result=')) {
      return { ok: true };
    }
    return { ok: false, error: `unexpected output: ${out}` };
  } catch (e) {
    return { ok: false, error: e.message };
  }
}

/**
 * 校验 HermesAccessibilityService 是否已启用。
 * 这是 broadcast 发送能否真正完成的最关键前提。
 * @returns {Promise<{enabled: boolean, raw: string}>}
 */
async function checkAccessibilityServiceEnabled() {
  try {
    const out = await shell('settings get secure enabled_accessibility_services', 5000);
    const enabled = out.includes('com.hermes.wechat.xposed') && out.includes('HermesAccessibilityService');
    return { enabled, raw: out };
  } catch (e) {
    return { enabled: false, raw: e.message };
  }
}

/**
 * 启用 HermesAccessibilityService(root 下用 settings put secure 直接写入)
 * @returns {Promise<{ok: boolean, error?: string}>}
 */
async function enableAccessibilityService() {
  const svc = 'com.hermes.wechat.xposed/com.hermes.wechat.xposed.accessibility.HermesAccessibilityService';
  try {
    await shell(`settings put secure enabled_accessibility_services ${svc}`, 5000);
    await shell('settings put secure accessibility_enabled 1', 5000);
    const check = await checkAccessibilityServiceEnabled();
    return { ok: check.enabled };
  } catch (e) {
    return { ok: false, error: e.message };
  }
}

/**
 * 查询最近一次发送结果
 * @returns {Promise<{status: string, error?: string}>}
 */
async function queryStatus() {
  try {
    const out = await shell(`content query --uri ${STATUS_URI}`, 5000);
    const m = out.match(/status=(\w+)/);
    return { status: m ? m[1] : 'unknown' };
  } catch (e) {
    return { status: 'error', error: e.message };
  }
}

/**
 * 批量发送(带间隔,避免被风控)
 * @param {Array<{toUser: string, content: string}>} messages
 * @param {number} delayMs — 默认 2000ms
 */
async function sendBatch(messages, delayMs = 2000) {
  const results = [];
  for (const msg of messages) {
    const r = await sendTextMessage(msg.toUser, msg.content);
    results.push({ ...msg, ...r });
    if (delayMs > 0) await sleep(delayMs);
  }
  return results;
}

function sleep(ms) {
  return new Promise(r => setTimeout(r, ms));
}

module.exports = {
  sendTextMessage,
  sendTextMessageBroadcast,
  checkAccessibilityServiceEnabled,
  enableAccessibilityService,
  queryStatus,
  sendBatch,
  shell,
};