#!/usr/bin/env python3 # ilink_bridge.py — 微信 ClawBot iLink 最小示例(Python) # 流程: 扫码登录(官方 POST get_bot_qrcode + local_token_list + 完整状态机)→ 长轮询收消息 → 回声回复 # 依赖: pip install requests(二维码展示可加装 qrcode,非必需) import json, os, time, uuid, base64, random, requests BASE_URL = "https://ilinkai.weixin.qq.com" STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge-state.json") # ---------- 状态持久化: bot_token / 游标 / context_token ---------- state = {"bot_token": None, "cursor": "", "contexts": {}} if os.path.exists(STATE_FILE): state.update(json.load(open(STATE_FILE))) def save_state(): with open(STATE_FILE, "w") as f: json.dump(state, f, indent=2) os.chmod(STATE_FILE, 0o600) # ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN + iLink-App 标识 ---------- def headers(): # 注: get_bot_qrcode / get_qrcode_status(预绑定阶段)无 token,不加 Authorization; # 但 iLink-App-Id / iLink-App-ClientVersion / AuthorizationType / X-WECHAT-UIN 恒带。 h = { "Content-Type": "application/json", "AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": base64.b64encode(str(random.getrandbits(32)).encode()).decode(), "iLink-App-Id": "bot", # 2.4.8 -> (2&0xff)<<16 | (4&0xff)<<8 | (8&0xff) = 132104(数字字符串,非 "1") "iLink-App-ClientVersion": "132104", } if state["bot_token"]: h["Authorization"] = f"Bearer {state['bot_token']}" return h def api_post(path, body, timeout=45): return requests.post(BASE_URL + path, headers=headers(), json=body, timeout=timeout).json() # ---------- 接口1: POST /ilink/bot/get_bot_qrcode 获取登录二维码(官方姿势) ---------- def fetch_qrcode(): # 官方为 POST + body {"local_token_list":[...]},非裸 GET(裸 GET 也能拿到码但非官方姿势)。 # local_token_list: 本实例已登录的 bot token 列表;首次绑定传空数组 []。若已持久化其它 bot, # 可收集 token 传入(最多 10 个),官方据此识别(较可靠地触发 binded_redirect)。 return requests.post(f"{BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3", headers=headers(), json={"local_token_list": []}, timeout=15).json() def show_qrcode(url): # qrcode_img_content 是 liteapp 跳转 URL(liteapp.weixin.qq.com/q/...),须作为"二维码内容" # 用二维码库编码展示;勿把响应里的 qrcode 字段当二维码(会扫出一串文本)。 try: import qrcode qr = qrcode.QRCode(border=1) qr.add_data(url) qr.make() qr.print_ascii() return except Exception: pass # 无 qrcode 库时降级:打印 URL(微信扫此 URL 同样可完成确认,只是非标准"二维码"展示)。 print("二维码内容(URL):", url) # ---------- 接口2: GET /ilink/bot/get_qrcode_status 轮询扫码状态(完整状态机) ---------- def login(): # 状态机: wait / scaned / scaned_but_redirect / need_verifycode / # verify_code_blocked / binded_redirect / confirmed / expired # 两大坑(务必读 docs/02 §4.5): # ① 码服务端 TTL≈60s,生成后须在窗口内扫(超窗服务端自返回 expired,此处换新码)。 # ② 「换码竞态」:一旦收到 scaned(或 scaned_but_redirect / need_verifycode), # 绝不能再换码——换码会砍掉"扫码→确认"会话,导致微信端显示成功但后端永远收不到 confirmed。 base_url = BASE_URL # scaned_but_redirect 时切换轮询 host qr = fetch_qrcode() qrcode = qr["qrcode"] show_qrcode(qr["qrcode_img_content"]) deadline = time.time() + 8 * 60 verify_code = None while time.time() < deadline: params = {"qrcode": qrcode} if verify_code: params["verify_code"] = verify_code s = requests.get(f"{base_url}/ilink/bot/get_qrcode_status", params=params, headers=headers(), timeout=35).json() st = s.get("status") if st == "wait": print("等待扫码...") elif st == "scaned": verify_code = None print("已扫码,请在手机上确认...(此后绝不换码,避免砍掉确认会话)") elif st == "scaned_but_redirect": verify_code = None host = s.get("redirect_host") if host: base_url = f"https://{host}" print("IDC 重定向,切换轮询至", base_url) elif st == "need_verifycode": # 已是"被扫等待确认"态,同样禁止换码 verify_code = input("请在手机微信上输入显示的数字后回车: ").strip() continue elif st == "verify_code_blocked": verify_code = None print("配对码多次输入错误,重新获取二维码") qr = fetch_qrcode(); qrcode = qr["qrcode"]; show_qrcode(qr["qrcode_img_content"]) continue elif st == "binded_redirect": print("该 bot 已绑定过本实例,视为连接成功") return elif st == "confirmed": state.update(bot_token=s["bot_token"], bot_id=s.get("ilink_bot_id"), owner_id=s.get("ilink_user_id"), baseurl=s.get("baseurl")) save_state() print("登录成功, bot:", s.get("ilink_bot_id")) return elif st == "expired": # 码过期(服务端作废)→ 换新码;仅在 expired 时换,scaned 后绝不换 verify_code = None print("二维码已过期,重新获取") qr = fetch_qrcode(); qrcode = qr["qrcode"]; show_qrcode(qr["qrcode_img_content"]) continue else: print("未知状态:", st) time.sleep(1) raise RuntimeError("扫码超时") # ---------- 你的 AI 后端: 替换成自己项目的调用 ---------- def ask_your_ai(peer_id: str, text: str) -> str: # 例: requests.post("http://127.0.0.1:8000/chat", # json={"session_id": peer_id, "text": text}).json()["reply"] return f"你说的是:{text}" # ---------- 接口4: POST /ilink/bot/sendmessage 发送消息 ---------- def send_text(to_user_id, text, context_token): for i in range(0, max(len(text), 1), 1500): # 长文本分片 api_post("/ilink/bot/sendmessage", { "msg": { "to_user_id": to_user_id, "client_id": str(uuid.uuid4()), # 去重 ID "message_type": 2, # 2 = bot 消息 "message_state": 2, # 2 = FINISH "context_token": context_token, # 必填! 来自入站消息 "item_list": [{"type": 1, "text_item": {"text": text[i:i+1500]}}], } }) # ---------- 接口3: POST /ilink/bot/getupdates 长轮询收消息 ---------- def poll_loop(): failures = 0 while True: try: resp = api_post("/ilink/bot/getupdates", { "get_updates_buf": state["cursor"], # 同步游标 "base_info": {"channel_version": "1.0.2"}, }) if resp.get("errcode") == -14: # 会话过期 print("会话过期(errcode=-14),请删除 bridge-state.json 重新扫码") raise SystemExit(2) if resp.get("ret", 0) != 0: raise RuntimeError(f"getupdates 错误: {resp}") if resp.get("get_updates_buf"): # 游标立即落盘 state["cursor"] = resp["get_updates_buf"] save_state() for msg in resp.get("msgs") or []: if msg.get("message_type") != 1: # 只处理用户消息 continue text = "\n".join( it.get("text_item", {}).get("text", "") for it in msg.get("item_list", []) if it.get("type") == 1 ).strip() if not text: continue state["contexts"][msg["from_user_id"]] = msg["context_token"] save_state() try: send_text(msg["from_user_id"], ask_your_ai(msg["from_user_id"], text), msg["context_token"]) except Exception as e: print("处理消息失败:", e) failures = 0 except requests.exceptions.Timeout: continue # 长轮询正常超时 except Exception as e: failures += 1 wait = 2 if failures <= 3 else 30 # 退避: 2s ×3 → 30s print(f"轮询异常({failures}次),{wait}s 后重试:", e) time.sleep(wait) if __name__ == "__main__": if not state["bot_token"]: login() poll_loop()