/** * 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 * @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, };