From 93c81b0f837bf727a2078d4e15371bc7b53b028f Mon Sep 17 00:00:00 2001 From: fnvtk Date: Mon, 18 May 2026 22:03:10 +0800 Subject: [PATCH] =?UTF-8?q?[v9]=20=E4=B8=8A=E4=BC=A0=20workphone=5Fapi=5Fc?= =?UTF-8?q?omplete.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/app/workphone_api_complete.py | 749 ++++++++++++++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 sdk/app/workphone_api_complete.py diff --git a/sdk/app/workphone_api_complete.py b/sdk/app/workphone_api_complete.py new file mode 100644 index 0000000000..8669aaf804 --- /dev/null +++ b/sdk/app/workphone_api_complete.py @@ -0,0 +1,749 @@ +#!/usr/bin/env python3 +""" +工作手机 SDK - 完整HTTP REST API服务 +版本: v9-complete +功能: 微信全量控制接口,基于Frida无感注入 +""" + +import frida +import json +import time +import threading +import subprocess +import os +import sys +from flask import Flask, request, jsonify +from functools import wraps +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# 配置 +API_KEY = os.environ.get('API_KEY', 'workphone-sdk-2026') +PHONE_IP = os.environ.get('PHONE_IP', '192.168.110.80') +PHONE_PORT = int(os.environ.get('PHONE_PORT', 27042)) +JS_FILE = os.path.join(os.path.dirname(__file__), 'sdk/agent/hook/wechat_api_v8_fix.js') + +# 全局状态 +class SDKState: + device = None + session = None + script = None + connected = False + wechat_pid = None + lock = threading.Lock() + last_heartbeat = 0 + +state = SDKState() + +def get_js_code(): + """读取JS脚本""" + js_paths = [ + JS_FILE, + os.path.join(os.path.dirname(__file__), 'wechat_api_v8_fix.js'), + '/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk/agent/hook/wechat_api_v8_fix.js', + ] + for p in js_paths: + if os.path.exists(p): + with open(p, 'r', encoding='utf-8') as f: + return f.read() + raise FileNotFoundError(f"JS文件未找到,已尝试: {js_paths}") + +def get_wechat_pid(): + """获取微信主进程PID""" + try: + result = subprocess.run( + ['adb', '-s', f'{PHONE_IP}:5555', 'shell', 'ps -A | grep com.tencent.mm'], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.split('\n'): + if 'com.tencent.mm' in line and ':push' not in line and ':sandbox' not in line and ':tools' not in line: + parts = line.split() + if len(parts) >= 2: + return int(parts[1]) + except Exception as e: + logger.error(f"获取微信PID失败: {e}") + return None + +def ensure_frida_forward(): + """确保ADB端口转发""" + try: + subprocess.run( + ['adb', '-s', f'{PHONE_IP}:5555', 'forward', 'tcp:27042', 'tcp:27042'], + capture_output=True, timeout=5 + ) + except Exception as e: + logger.warning(f"端口转发失败: {e}") + +def connect_frida(): + """连接Frida并注入脚本""" + with state.lock: + try: + ensure_frida_forward() + + # 获取设备 + dm = frida.get_device_manager() + state.device = dm.add_remote_device('localhost:27042') + + # 获取微信PID + pid = get_wechat_pid() + if not pid: + # 尝试启动微信 + subprocess.run( + ['adb', '-s', f'{PHONE_IP}:5555', 'shell', + 'monkey -p com.tencent.mm -c android.intent.category.LAUNCHER 1'], + capture_output=True, timeout=10 + ) + time.sleep(3) + pid = get_wechat_pid() + + if not pid: + raise Exception("无法获取微信PID") + + state.wechat_pid = pid + logger.info(f"微信PID: {pid}") + + # Attach + state.session = state.device.attach(pid) + + # 加载脚本 + js_code = get_js_code() + state.script = state.session.create_script(js_code) + + def on_message(message, data): + if message.get('type') == 'send': + payload = message.get('payload', {}) + if isinstance(payload, dict): + logger.debug(f"Frida: {payload.get('type', '')} - {str(payload.get('data', ''))[:100]}") + elif message.get('type') == 'error': + logger.error(f"Frida错误: {message.get('description', '')}") + + state.script.on('message', on_message) + state.script.load() + + # 初始化DB + time.sleep(1) + try: + state.script.exports.initDbs() + except Exception as e: + logger.warning(f"initDbs: {e}") + + state.connected = True + state.last_heartbeat = time.time() + logger.info("✅ Frida连接成功") + return True + + except Exception as e: + logger.error(f"Frida连接失败: {e}") + state.connected = False + return False + +def ensure_connected(): + """确保Frida连接有效""" + if not state.connected or not state.script: + return connect_frida() + + # 心跳检测(每30秒) + if time.time() - state.last_heartbeat > 30: + try: + state.script.exports.ping() + state.last_heartbeat = time.time() + except Exception: + logger.warning("Frida心跳失败,重新连接...") + state.connected = False + return connect_frida() + + return True + +def call_rpc(method, *args): + """调用Frida RPC方法""" + if not ensure_connected(): + raise Exception("Frida未连接") + + try: + func = getattr(state.script.exports, method) + result = func(*args) if args else func() + state.last_heartbeat = time.time() + return result + except Exception as e: + logger.error(f"RPC调用失败 {method}: {e}") + # 尝试重连一次 + if 'session' in str(e).lower() or 'detach' in str(e).lower(): + state.connected = False + if connect_frida(): + func = getattr(state.script.exports, method) + return func(*args) if args else func() + raise + +def require_api_key(f): + @wraps(f) + def decorated(*args, **kwargs): + key = request.headers.get('X-API-Key') or request.args.get('api_key') + if key != API_KEY: + return jsonify({'error': '无效的API Key'}), 401 + return f(*args, **kwargs) + return decorated + +def success(data=None, **kwargs): + resp = {'success': True, 'timestamp': int(time.time() * 1000)} + if data is not None: + resp['data'] = data + resp.update(kwargs) + return jsonify(resp) + +def error(msg, code=500): + return jsonify({'success': False, 'error': str(msg)}), code + +# ==================== 系统接口 ==================== + +@app.route('/api/v1/ping', methods=['GET']) +def ping(): + return jsonify({ + 'status': 'ok', + 'version': 'v9-complete', + 'frida_connected': state.connected, + 'wechat_pid': state.wechat_pid, + 'timestamp': int(time.time() * 1000) + }) + +@app.route('/api/v1/init', methods=['POST']) +@require_api_key +def init(): + result = connect_frida() + return success({'connected': result, 'pid': state.wechat_pid}) + +@app.route('/api/v1/dbs', methods=['GET']) +@require_api_key +def list_dbs(): + try: + result = call_rpc('getDbInfo') + return success(result) + except Exception as e: + return error(e) + +# ==================== 账号接口 ==================== + +@app.route('/api/v1/account', methods=['GET']) +@require_api_key +def get_account(): + try: + result = call_rpc('getAccountInfo') + return success(result) + except Exception as e: + return error(e) + +# ==================== 联系人接口 ==================== + +@app.route('/api/v1/contacts', methods=['GET']) +@require_api_key +def get_contacts(): + limit = int(request.args.get('limit', 100)) + offset = int(request.args.get('offset', 0)) + try: + result = call_rpc('getContacts', limit, offset) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/contacts/count', methods=['GET']) +@require_api_key +def get_contacts_count(): + try: + result = call_rpc('getContactStats') + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/contacts/search', methods=['GET']) +@require_api_key +def search_contacts(): + keyword = request.args.get('keyword', '') + limit = int(request.args.get('limit', 50)) + try: + result = call_rpc('searchContacts', keyword, limit) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/contacts/', methods=['GET']) +@require_api_key +def get_contact(wxid): + try: + result = call_rpc('getContactDetail', wxid) + return success(result) + except Exception as e: + return error(e) + +# ==================== 消息接口 ==================== + +@app.route('/api/v1/messages/send', methods=['POST']) +@require_api_key +def send_message(): + data = request.get_json() + to_user = data.get('to_user', 'filehelper') + content = data.get('content', '') + msg_type = data.get('type', 1) + + if not content: + return error('content不能为空', 400) + + try: + result = call_rpc('sendMessage', to_user, content, msg_type) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/messages/mass', methods=['POST']) +@require_api_key +def mass_send(): + data = request.get_json() + targets = data.get('targets', []) + content = data.get('content', '') + + if not targets or not content: + return error('targets和content不能为空', 400) + + try: + result = call_rpc('massMessage', targets, content) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/messages/', methods=['GET']) +@require_api_key +def get_messages(talker): + limit = int(request.args.get('limit', 50)) + try: + result = call_rpc('getMessages', talker, limit) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/messages/recent', methods=['GET']) +@require_api_key +def get_recent_messages(): + hours = int(request.args.get('hours', 24)) + try: + result = call_rpc('getRecentMessages', hours) + return success(result) + except Exception as e: + return error(e) + +# ==================== 会话接口 ==================== + +@app.route('/api/v1/conversations', methods=['GET']) +@require_api_key +def get_conversations(): + limit = int(request.args.get('limit', 30)) + try: + result = call_rpc('getConversations', limit) + return success(result) + except Exception as e: + return error(e) + +# ==================== 群组接口 ==================== + +@app.route('/api/v1/groups', methods=['GET']) +@require_api_key +def get_groups(): + limit = int(request.args.get('limit', 50)) + try: + result = call_rpc('getGroups', limit) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/groups/count', methods=['GET']) +@require_api_key +def get_groups_count(): + try: + result = call_rpc('getGroupStats') + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/groups//members', methods=['GET']) +@require_api_key +def get_group_members(chatroom_id): + try: + result = call_rpc('getGroupMembers', chatroom_id) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/groups//remark', methods=['PUT']) +@require_api_key +def set_group_remark(chatroom_id): + data = request.get_json() + remark = data.get('remark', '') + try: + result = call_rpc('setGroupRemark', chatroom_id, remark) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/groups//announcement', methods=['PUT']) +@require_api_key +def set_group_announcement(chatroom_id): + data = request.get_json() + announcement = data.get('announcement', '') + try: + result = call_rpc('setGroupAnnouncement', chatroom_id, announcement) + return success(result) + except Exception as e: + return error(e) + +# ==================== 标签接口 ==================== + +@app.route('/api/v1/labels', methods=['GET']) +@require_api_key +def get_labels(): + try: + result = call_rpc('getLabels') + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/labels//contacts', methods=['GET']) +@require_api_key +def get_label_contacts(label_id): + limit = int(request.args.get('limit', 100)) + try: + result = call_rpc('getContactsByLabel', label_id, limit) + return success(result) + except Exception as e: + return error(e) + +# ==================== 朋友圈接口 ==================== + +@app.route('/api/v1/moments', methods=['GET']) +@require_api_key +def get_moments(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getMoments', limit) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/moments', methods=['POST']) +@require_api_key +def post_moment(): + data = request.get_json() + content = data.get('content', '') + if not content: + return error('content不能为空', 400) + try: + result = call_rpc('postMoment', content) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/moments/dbinfo', methods=['GET']) +@require_api_key +def moments_dbinfo(): + try: + result = call_rpc('getMomentsDbInfo') + return success(result) + except Exception as e: + return error(e) + +# ==================== 视频号接口 ==================== + +@app.route('/api/v1/finder/info', methods=['GET']) +@require_api_key +def finder_info(): + try: + result = call_rpc('getFinderInfo') + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/finder/contacts', methods=['GET']) +@require_api_key +def finder_contacts(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getFinderContacts', limit) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/finder/accounts', methods=['GET']) +@require_api_key +def finder_accounts(): + try: + result = call_rpc('getFinderAccounts') + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/finder/media', methods=['GET']) +@require_api_key +def finder_media(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getFinderMedia', limit) + return success(result) + except Exception as e: + return error(e) + +# ==================== 收藏接口 ==================== + +@app.route('/api/v1/favorites', methods=['GET']) +@require_api_key +def get_favorites(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getFavorites', limit) + return success(result) + except Exception as e: + return error(e) + +# ==================== 钱包接口 ==================== + +@app.route('/api/v1/wallet/info', methods=['GET']) +@require_api_key +def wallet_info(): + try: + result = call_rpc('getWalletInfo') + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/wallet/ledger', methods=['GET']) +@require_api_key +def wallet_ledger(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getWalletLedger', limit) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/wallet/lucky-money', methods=['GET']) +@require_api_key +def lucky_money(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getLuckyMoney', limit) + return success(result) + except Exception as e: + return error(e) + +# ==================== 媒体接口 ==================== + +@app.route('/api/v1/media/images', methods=['GET']) +@require_api_key +def get_images(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getImages', limit) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/media/images/count', methods=['GET']) +@require_api_key +def images_count(): + try: + result = call_rpc('getImageCount') + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/media/videos', methods=['GET']) +@require_api_key +def get_videos(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getVideos', limit) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/media/files', methods=['GET']) +@require_api_key +def get_files(): + limit = int(request.args.get('limit', 20)) + try: + result = call_rpc('getFiles', limit) + return success(result) + except Exception as e: + return error(e) + +# ==================== 小程序接口 ==================== + +@app.route('/api/v1/appbrand', methods=['GET']) +@require_api_key +def get_appbrand(): + try: + result = call_rpc('getAppBrandInfo') + return success(result) + except Exception as e: + return error(e) + +# ==================== 搜索接口 ==================== + +@app.route('/api/v1/search', methods=['GET']) +@require_api_key +def search(): + keyword = request.args.get('keyword', '') + if not keyword: + return error('keyword不能为空', 400) + try: + result = call_rpc('globalSearch', keyword) + return success(result) + except Exception as e: + return error(e) + +# ==================== 原始SQL接口 ==================== + +@app.route('/api/v1/raw/query', methods=['POST']) +@require_api_key +def raw_query(): + data = request.get_json() + db = data.get('db', 'main') + sql = data.get('sql', '') + if not sql: + return error('sql不能为空', 400) + try: + result = call_rpc('rawSql', db, sql) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/raw/write', methods=['POST']) +@require_api_key +def raw_write(): + data = request.get_json() + db = data.get('db', 'main') + sql = data.get('sql', '') + if not sql: + return error('sql不能为空', 400) + # 安全检查 + sql_upper = sql.strip().upper() + if sql_upper.startswith('DROP') or sql_upper.startswith('TRUNCATE'): + return error('不允许DROP/TRUNCATE操作', 403) + try: + result = call_rpc('rawSqlWrite', db, sql) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/raw/tables', methods=['GET']) +@require_api_key +def list_tables(): + db = request.args.get('db', 'main') + try: + result = call_rpc('listTables', db) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v1/raw/table', methods=['GET']) +@require_api_key +def table_info(): + db = request.args.get('db', 'main') + table = request.args.get('table', '') + if not table: + return error('table不能为空', 400) + try: + result = call_rpc('getTableInfo', db, table) + return success(result) + except Exception as e: + return error(e) + +# ==================== 存客宝对接接口 ==================== + +@app.route('/api/v3/cunke-bao/contacts/sync', methods=['POST']) +@require_api_key +def ckb_sync_contacts(): + """批量同步联系人到存客宝""" + data = request.get_json() + limit = data.get('limit', 100) + offset = data.get('offset', 0) + try: + contacts = call_rpc('getContacts', limit, offset) + # 格式化为存客宝格式 + ckb_contacts = [] + for c in (contacts.get('contacts', []) if isinstance(contacts, dict) else []): + ckb_contacts.append({ + 'wxid': c.get('username', ''), + 'nickname': c.get('nickname', ''), + 'remark': c.get('remark', ''), + 'avatar': c.get('headImgUrl', ''), + 'labels': c.get('contactLabelIds', ''), + 'sex': c.get('sex', 0), + 'region': f"{c.get('province', '')} {c.get('city', '')}".strip(), + }) + return success({'contacts': ckb_contacts, 'count': len(ckb_contacts), 'total': contacts.get('total', 0) if isinstance(contacts, dict) else 0}) + except Exception as e: + return error(e) + +@app.route('/api/v3/cunke-bao/message/send', methods=['POST']) +@require_api_key +def ckb_send_message(): + """存客宝发送消息""" + data = request.get_json() + to_user = data.get('wxid', data.get('to_user', '')) + content = data.get('content', data.get('message', '')) + if not to_user or not content: + return error('wxid和content不能为空', 400) + try: + result = call_rpc('sendMessage', to_user, content, 1) + return success(result) + except Exception as e: + return error(e) + +@app.route('/api/v3/cunke-bao/groups', methods=['GET']) +@require_api_key +def ckb_get_groups(): + """存客宝获取群列表""" + try: + result = call_rpc('getGroups', 100) + return success(result) + except Exception as e: + return error(e) + +# ==================== 健康检查和自动重连 ==================== + +def heartbeat_thread(): + """后台心跳线程,保持Frida连接""" + while True: + try: + time.sleep(60) + if state.connected: + try: + call_rpc('ping') + logger.debug("心跳正常") + except Exception as e: + logger.warning(f"心跳失败: {e},尝试重连...") + state.connected = False + connect_frida() + except Exception as e: + logger.error(f"心跳线程异常: {e}") + +if __name__ == '__main__': + print("=" * 60) + print("工作手机 SDK API 服务 v9-complete") + print(f"手机IP: {PHONE_IP}") + print(f"API Key: {API_KEY}") + print("=" * 60) + + # 初始连接 + logger.info("正在连接Frida...") + if connect_frida(): + logger.info("✅ Frida连接成功") + else: + logger.warning("⚠️ Frida初始连接失败,将在请求时重试") + + # 启动心跳线程 + t = threading.Thread(target=heartbeat_thread, daemon=True) + t.start() + + # 启动API服务 + app.run(host='0.0.0.0', port=8899, debug=False, threaded=True)