mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): Qoder / ZCode / Loomy 从 manual 变为真正可用的自动化登录
审计 12 个平台时发现:四个标 flow="manual" 的平台里,**三个的登录协议完全可以在
Lua 里实现** —— 我把"没实现"写成了"做不到",理由全是错的。
## Qoder:WASM 是聊天加密,不是登录
错误理由:「登录要 WASM 签名头」。实际:WASM 用于 **/algo/ 加密推理端点**,
登录本身是标准 PKCE 设备码轮询(qoder-oauth.ts / qoder.ts):
授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
&nonce&machine_id&client_id(本地拼装,不打网络)
轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
三个必须照抄的细节(参考点名):
· client_id 用 **prod 的**(非 prod 会在授权回调阶段被拒)
· poll 挂 openApiBase(openapi.qoder.sh);authBase 返回 401
· 404 = 尚无会话(**继续等**,判失败会让登录永远失败)
实现:新增 judge="pending404" 判据 + 既有的 device-poll 机制支持
`GET_QUERY`/`auth_query`(PKCE 授权页形态,无 device_code)。
顺带修 poll_login 的条件(原要求 `s.device_code`,PKCE 形态没有它 → 永远不推进)。
## ZCode:授权流不涉及 captcha
错误理由:「登录要解 captcha」。实际:captcha 属于**另一条已废弃的旁路**
(读本机官方客户端数据);本流注释明确写着「普通进程就能走完」:
① POST {origin}/api/v1/oauth/cli/init Bearer <本地造的 flow_secret>
→ {flow_id, authorize_url, poll_interval_sec}
② 用户浏览器打开 authorize_url
③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
判据(参考点名):4xx(**除 408/429**)才是终态失败;code!=0 或 data 缺失 → pending;
token(zcode JWT) 与 user.user_id 必需,第三方 token 取 bigmodel/zai 二者之一。
另按参考修正 base_url/adapter(走 Anthropic 协议的 zcode.z.ai 免费额度通道)
并补余额端点。
## Loomy:扫码在手机上,协议在 HTTP 里
错误理由:「扫码在微信里完成,Lua 做不了」。实际:扫码动作确实在用户手机上,
但协议全是纯 HTTP:
① 取 uuid(授权页 HTML 里抽)② 二维码图 URL(前端渲染)
③ 长轮询 long.open.weixin.qq.com/connect/l/qrconnect
④ wx_code → 讯飞账号端点换凭据(HMAC-SHA1 签名)
判据(参考点名,早期版本**读反了**导致"扫码后卡在已扫码"):
408=等待;404=已扫码待确认(**继续轮询**);405=**已确认**(wx_code 在此帧);
403=取消;402=失效
签名:内核只有 sha256/hmac_sha256,没有 sha1/md5 —— 插件用 openssl 算
(HMAC-SHA1 与 Python 实测一致),**不改内核**。账号头前缀是 `account ak:sig`。
## 顺带修的真 bug
1. `loomyAuthHeaders` 调 `newToken()`,而它定义在文件后段 → Lua 词法作用域下是
nil,调用即崩。改用内核全局 sha256_hex 造 nonce。
2. `declaredHosts()` 只看 hosts/base_url/checkin → 各 flow 的端点域名
(open.weixin.qq.com / account.xfyun.cn / openapi.qoder.sh / zcode.z.ai …)
不在出站白名单里,授权页与长轮询会被拦。已按 flow 补全推导。
3. `set_endpoints` 不支持 wechat —— 自动化测试无法把 Loomy 端点指到假上游
(端点覆盖是参考明确的产品配置能力,也是测试缝)。已补 wechat/initpoll。
4. 两处 Lua 字符串引号错误(`"$(cat ...)"` 与 `"([^"]+)"` 提前结束字符串,
插件编译失败)。插件加载测试抓到的。
## 判据
- TestQoderDevicePollBehavior(5 项):授权页含 PKCE 四要素与 prod client_id;
404 保持 pending;轮询带 verifier/nonce;拿 token 即完成;池路径凭据落账号 ref
- TestZcodeInitPollBehavior(4 项):init 带会话密钥与 provider;pending 保持;
ready 完成且凭据含 zcode_jwt + 第三方 token;池路径
- TestLoomyWechatPollBehavior(4 项):抽出 uuid 并给二维码;**408/404 都是
pending**(404 非完成);405 才完成且账号端点用 account ak:sig 签名;403/402 失败
- 全量 core 回归通过;确认测试不写生产插件目录、无孤儿进程
- 内核对照 v2.0.2:diff 为空
## 剩余
Raccoon 仍为 manual(本地承载页 + SMS,含人工输入验证码),另做。
This commit is contained in:
@ -456,15 +456,40 @@ local PLATFORMS = {
|
||||
claim_fn_name = "claimCodeArts",
|
||||
},
|
||||
|
||||
-- Qoder —— PKCE 设备码 + WASM 加密签名(照参考 qoder-oauth.ts 逆向)。
|
||||
-- WASM 签名头无法在纯 Lua 环境完成,登录标 manual 给明确说明。
|
||||
-- Qoder —— **标准 PKCE 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。
|
||||
--
|
||||
-- ⚠️ 纠正一处长期错误:此前把 Qoder 标成 manual,理由是"登录要 WASM 签名头"。
|
||||
-- 那是把**聊天加密**(WASM 用于 /algo/ 加密推理端点)与**登录**混为一谈 ——
|
||||
-- 登录本身是纯 HTTP 的 PKCE 设备码流,Lua 完全能做:
|
||||
-- ① 打开 {authBase}/device/selectAccounts?challenge&challenge_method=S256
|
||||
-- &nonce&machine_id&client_id
|
||||
-- ② 轮询 {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
|
||||
-- 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
|
||||
-- 三个必须照抄的细节(参考注释都点了名):
|
||||
-- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝)
|
||||
-- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401)
|
||||
-- · nonce/verifier 必须原样回传(PKCE 校验)
|
||||
{
|
||||
id = "qoder",
|
||||
name = "Qoder",
|
||||
vendor = "qoder",
|
||||
flow = "manual",
|
||||
manual_hint = "Qoder 登录要 WASM 签名头,纯 Lua 环境无法生成。请在浏览器完成"
|
||||
.. "授权后,把最终凭证粘贴进来(手动输入凭据)。",
|
||||
flow = "device-poll",
|
||||
device = {
|
||||
-- 授权页在 {authBase}/device/selectAccounts,query 由 pkce 表单拼装
|
||||
code_url = "https://qoder.com/device/selectAccounts",
|
||||
code_method = "GET_QUERY",
|
||||
-- code_url 的 query 模板:{challenge}/{nonce}/{machine_id}/{client_id}
|
||||
auth_query = "challenge={challenge}&challenge_method=S256&nonce={nonce}"
|
||||
.. "&machine_id={machine_id}&client_id=" .. "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
|
||||
-- 轮询在 openApiBase
|
||||
token_url = "https://openapi.qoder.sh/api/v1/deviceToken/poll",
|
||||
token_method = "GET_QUERY",
|
||||
-- poll 的 query 模板:PKCE 用 verifier(不是 challenge)
|
||||
poll_query = "nonce={nonce}&verifier={verifier}&challenge_method=S256",
|
||||
-- 判据:404 = 继续(见 deviceOneStep 的 pending404 分支)
|
||||
judge = "pending404",
|
||||
interval = 2,
|
||||
},
|
||||
base_url = "https://api2-v2.qoder.sh",
|
||||
adapter = "openai",
|
||||
models = { "qoder-default" },
|
||||
@ -482,17 +507,53 @@ local PLATFORMS = {
|
||||
},
|
||||
},
|
||||
|
||||
-- ZCode —— cli/init + captcha(照参考 zcode-auth.ts 逆向)。
|
||||
-- captcha 求解(mint captcha + 补激活上报)无法在纯 Lua 完成,标 manual。
|
||||
-- ZCode —— **两段式服务端中介授权流**(照参考 zcode-login.ts 逐行移植)。
|
||||
--
|
||||
-- ⚠️ 纠正第二处长期错误:此前标 manual,理由是"登录要解 captcha"。核对参考
|
||||
-- 后发现**该授权流完全不涉及 captcha** —— captcha 属于另一条已废弃的旁路
|
||||
-- (zcode-captcha.ts,用于读取本机官方客户端数据),而本流的注释明确写着
|
||||
-- 「完全不经 zcode:// 自定义协议回调,故普通进程就能走完」:
|
||||
-- ① POST {origin}/api/v1/oauth/cli/init
|
||||
-- Authorization: Bearer <本地造的 flow_secret(32 字节 hex)>
|
||||
-- body {provider:"bigmodel"}
|
||||
-- → {flow_id, poll_token, authorize_url, expires_at, poll_interval_sec}
|
||||
-- ② 用户在浏览器打开 authorize_url 完成授权
|
||||
-- ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id}
|
||||
-- Authorization: Bearer <同一个 flow_secret>
|
||||
-- → {status:"pending"} 继续 / {status:"ready", token, user, bigmodel|zai}
|
||||
-- 判据(参考注释点名,与官方一致):
|
||||
-- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试
|
||||
-- · code != 0 或 data 缺失 → pending
|
||||
-- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一
|
||||
{
|
||||
id = "zcode",
|
||||
name = "ZCode",
|
||||
vendor = "zcode",
|
||||
flow = "manual",
|
||||
manual_hint = "ZCode 登录要解 captcha(client/configs 拿 sceneId 后 mint),"
|
||||
.. "纯 Lua 环境无法完成。请完成 captcha 验证后,把最终凭证粘贴进来。",
|
||||
base_url = "https://zcode.example.com/v1",
|
||||
adapter = "openai",
|
||||
flow = "init-poll",
|
||||
initpoll = {
|
||||
init_url = "https://zcode.z.ai/api/v1/oauth/cli/init",
|
||||
poll_url = "https://zcode.z.ai/api/v1/oauth/cli/poll/{flow_id}",
|
||||
provider = "bigmodel",
|
||||
app_version = "3.14.3",
|
||||
origin = "https://zcode.z.ai",
|
||||
interval = 2,
|
||||
},
|
||||
-- 推理走 Anthropic 协议的免费额度通道(照参考 zcode-upstream.ts):
|
||||
-- 积分制无论 bigmodel/zai 都走 zcode.z.ai(官方分派表规则 2/5,2026-10-03 实测);
|
||||
-- 只有订阅制才走 api.z.ai。这里用积分制那条。
|
||||
base_url = "https://zcode.z.ai",
|
||||
endpoint = "/api/v1/zcode-plan/anthropic/v1/messages",
|
||||
adapter = "anthropic",
|
||||
-- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL)
|
||||
balance = {
|
||||
url = "https://zcode.z.ai/api/v1/zcode-plan/billing/balance",
|
||||
method = "GET",
|
||||
total_fn = function(rec)
|
||||
local d = rec.data or rec
|
||||
-- 优先取明确的余额字段;取不到就不编造(返回 nil 让调用方报错)
|
||||
return tonumber(d.balance or d.remaining or d.credits or d.total)
|
||||
end,
|
||||
},
|
||||
models = { "zcode-default" },
|
||||
},
|
||||
|
||||
@ -502,10 +563,32 @@ local PLATFORMS = {
|
||||
id = "loomy",
|
||||
name = "Loomy",
|
||||
vendor = "loomy",
|
||||
flow = "manual",
|
||||
manual_hint = "Loomy 登录要微信扫码(在微信公众号里完成授权)。请在微信里"
|
||||
.. "扫码后,把拿到的凭证粘贴进来。",
|
||||
base_url = "https://loomy.example.com/v1",
|
||||
-- ⚠️ 纠正第三处长期错误:此前标 manual,理由是"扫码在微信里完成,Lua 做不了"。
|
||||
-- 扫码动作确实在用户手机上,但**协议全是纯 HTTP/Lua 可做**:
|
||||
-- ① 取 uuid:GET open.weixin.qq.com/connect/qrconnect?...&state=... 从 HTML 抽 uuid
|
||||
-- ② 二维码图:GET open.weixin.qq.com/connect/qrcode/{uuid}(JPEG,前端渲染)
|
||||
-- ③ 长轮询:GET long.open.weixin.qq.com/connect/l/qrconnect?uuid&last&_
|
||||
-- 响应体里 wx_errcode=NNN / wx_code='...'
|
||||
-- ④ 拿 wx_code 后调讯飞账号端点换凭据(HMAC-SHA1 签名,见 loomyAuthHeaders)
|
||||
-- 判据(参考点名;早期版本**读反了**导致"扫码后卡在已扫码"):
|
||||
-- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧);
|
||||
-- 403=用户取消;402=二维码失效
|
||||
flow = "wechat-poll",
|
||||
wechat = {
|
||||
auth_url = "https://open.weixin.qq.com/connect/qrconnect"
|
||||
.. "?appid=wx16e5c2b8e0c0e1f2&scope=snsapi_login&response_type=code"
|
||||
.. "&redirect_uri=https%3A%2F%2Floomy.xunfei.cn%2Foauth%2Fwechat%2Fcallback&state={state}",
|
||||
qr_url = "https://open.weixin.qq.com/connect/qrcode/{uuid}",
|
||||
poll_url = "https://long.open.weixin.qq.com/connect/l/qrconnect",
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
.. " (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
|
||||
account_base = "https://account.xfyun.cn",
|
||||
bind_path = "/login/thirdAccount/bind/auth",
|
||||
access_key_id = "2thryby66wxi53sk",
|
||||
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
|
||||
app_id = "GM3LOOMY",
|
||||
},
|
||||
base_url = "https://loomy.xunfei.cn",
|
||||
adapter = "openai",
|
||||
models = { "loomy-default" },
|
||||
},
|
||||
@ -593,6 +676,26 @@ local function declaredHosts()
|
||||
local h = (p.checkin and p.checkin.url or ""):match("^https?://([^/:]+)")
|
||||
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
|
||||
end
|
||||
-- 各 flow 自己的端点域名也要(否则授权页/长轮询/账号端点会被出站白名单拦)。
|
||||
-- 这些域名与 base_url 往往不同:Loomy 的授权在 open.weixin.qq.com、
|
||||
-- 长轮询在 long.open.weixin.qq.com、账号在 account.xfyun.cn;
|
||||
-- ZCode 的 init/poll 在 zcode.z.ai(与 base_url 同域但显式声明更清楚)。
|
||||
local function addHost(u)
|
||||
local h = (u or ""):match("^https?://([^/:]+)")
|
||||
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
|
||||
end
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
if p.wechat then
|
||||
addHost(p.wechat.auth_url); addHost(p.wechat.qr_url)
|
||||
addHost(p.wechat.poll_url); addHost(p.wechat.account_base)
|
||||
end
|
||||
if p.initpoll then addHost(p.initpoll.init_url); addHost(p.initpoll.poll_url) end
|
||||
if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end
|
||||
if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end
|
||||
if p.balance then addHost(p.balance.url) end
|
||||
if p.checkin_status then addHost(p.checkin_status.url) end
|
||||
if p.callback then addHost(p.callback.exchange_url) end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
@ -643,6 +746,102 @@ local function hexToBin(h)
|
||||
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
|
||||
end
|
||||
|
||||
-- ---- 讯飞账号(CAccount)HMAC-SHA1 签名(Loomy 用)----
|
||||
-- 逐段复刻参考 loomy-sign.ts。任何偏差都会让账号端点返回鉴权失败。
|
||||
--
|
||||
-- 内核只有 sha256/hmac_sha256 —— 没有 sha1,也没有 md5。但插件有完整 OS 能力,
|
||||
-- 直接用 openssl(本机已装)算 HMAC-SHA1 与 MD5,不在 Lua 里重写密码学原语。
|
||||
--
|
||||
-- 签名字符串 9 段以 \n 连接,**后两段恒为空串**(不发 x-* 头),
|
||||
-- 故最终字符串以两个换行结尾 —— 不要"顺手"去掉尾随换行。
|
||||
-- 认证头前缀是 `account {ak}:{sig}`(**不是** Bearer)。
|
||||
|
||||
-- rfc3986Escape:encodeURIComponent 等价 + 补转 ! ' ( ) *
|
||||
local function rfc3986Escape(v)
|
||||
v = tostring(v or "")
|
||||
v = v:gsub("([^A-Za-z0-9%-%._~])", function(c)
|
||||
return string.format("%%%02X", string.byte(c))
|
||||
end)
|
||||
-- encodeURIComponent 不转 ! ' ( ) * ~,但参考的 escapeRfc3986 要转前五个
|
||||
v = v:gsub("!", "%%21"):gsub("'", "%%27"):gsub("%(", "%%28")
|
||||
:gsub("%)", "%%29"):gsub("%*", "%%2A")
|
||||
return v
|
||||
end
|
||||
|
||||
-- opensslDigest:调 openssl 算摘要。mode 是 openssl 的算法名(md5/sha1)。
|
||||
-- hmacKey 非空时用 -hmac;输出 base64(参考用的就是 base64)。
|
||||
local function opensslDigest(mode, data, hmacKey)
|
||||
-- 用临时文件传原文:命令行的引号/换行/二进制都不可靠
|
||||
local inPath = "/tmp/llmsproxy-sig-in-" .. tostring(math.random(1e12))
|
||||
local f = io.open(inPath, "wb")
|
||||
if not f then return nil, "无法写临时文件" end
|
||||
f:write(data)
|
||||
f:close()
|
||||
local cmd
|
||||
if hmacKey and hmacKey ~= "" then
|
||||
local keyPath = inPath .. ".key"
|
||||
local kf = io.open(keyPath, "wb")
|
||||
if not kf then os.remove(inPath) return nil, "无法写密钥文件" end
|
||||
kf:write(hmacKey)
|
||||
kf:close()
|
||||
-- 外层用单引号包 Lua 字符串,内部才能安全地带 shell 的双引号
|
||||
cmd = ('openssl dgst -%s -hmac "$(cat %s)" -binary %s 2>/dev/null | base64 -w0')
|
||||
:format(mode, keyPath, inPath)
|
||||
local h = io.popen(cmd)
|
||||
local out = h and h:read("*a") or ""
|
||||
if h then h:close() end
|
||||
os.remove(keyPath)
|
||||
os.remove(inPath)
|
||||
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
|
||||
end
|
||||
cmd = ("openssl dgst -%s -binary %s 2>/dev/null | base64 -w0"):format(mode, inPath)
|
||||
local h = io.popen(cmd)
|
||||
local out = h and h:read("*a") or ""
|
||||
if h then h:close() end
|
||||
os.remove(inPath)
|
||||
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
|
||||
end
|
||||
|
||||
-- loomyContentMd5:空 body 返回**空串**(不是空串的 md5),与客户端一致。
|
||||
local function loomyContentMd5(body)
|
||||
if not body or body == "" then return "" end
|
||||
return opensslDigest("md5", body, nil) or ""
|
||||
end
|
||||
|
||||
-- loomySigningString:9 段拼串(method,path,query,md5,ctype,date,nonce,"","")
|
||||
local function loomySigningString(method, path, query, body, contentType, date, nonce)
|
||||
local md5 = loomyContentMd5(body)
|
||||
return table.concat({
|
||||
method:upper(), path, query or "", md5, contentType or "",
|
||||
date, nonce, "", "",
|
||||
}, "\n")
|
||||
end
|
||||
|
||||
-- loomyAuthHeaders:生成讯飞账号请求头。
|
||||
local function loomyAuthHeaders(ak, aks, method, path, body, contentType)
|
||||
local date = os.date("!%a, %d %b %Y %H:%M:%S GMT")
|
||||
-- nonce 只要唯一即可(服务端不校验格式)。这里**不能调 newToken** ——
|
||||
-- 它定义在文件后段(1518 行),Lua 词法作用域下此处是 nil,调用会崩。
|
||||
-- 用内核全局 sha256_hex(随时可用)造一个 32 字节 hex,与参考的 UUID 同量级。
|
||||
local nonce = string.format("%s-%s-%s",
|
||||
sha256_hex(tostring(os.time()) .. tostring(math.random(1e12))):sub(1, 8),
|
||||
sha256_hex(tostring(math.random(1e12))):sub(1, 4),
|
||||
sha256_hex(tostring(math.random(1e12))):sub(1, 12))
|
||||
local ctype = contentType or "application/json"
|
||||
local sts = loomySigningString(method, path, "", body, ctype, date, nonce)
|
||||
local sig, err = opensslDigest("sha1", sts, aks)
|
||||
if not sig then return nil, err or "签名计算失败" end
|
||||
local h = {
|
||||
["Authorization"] = "account " .. ak .. ":" .. sig,
|
||||
["Date"] = date,
|
||||
["Nonce"] = nonce,
|
||||
["Content-Type"] = ctype,
|
||||
}
|
||||
local md5 = loomyContentMd5(body)
|
||||
if md5 ~= "" then h["Content-MD5"] = md5 end
|
||||
return h
|
||||
end
|
||||
|
||||
-- devicePkce 生成一次授权的 verifier/challenge 对(SHA256 S256)。
|
||||
-- verifier 自造:熵拼接后哈希。不依赖文件后段才定义的 newToken —— Lua 词法
|
||||
-- 作用域下那会是 nil 全局调用。熵用 os.time + math.random 两次哈希拼接。
|
||||
@ -660,13 +859,26 @@ end
|
||||
--(slow_down 后 interval 增大,与参考一致)。
|
||||
local function deviceOneStep(plat, sess)
|
||||
local d = plat.device
|
||||
local body = (d.token_body
|
||||
:gsub("{device_code}", sess.device_code)
|
||||
:gsub("{verifier}", sess.device_verifier or "")
|
||||
:gsub("{challenge}", sess.device_challenge or ""))
|
||||
local url, method, body = d.token_url, d.token_method or "POST", nil
|
||||
if d.poll_query or (d.token_method == "GET_QUERY") then
|
||||
-- 形态②(Qoder):GET + query 模板。PKCE 用 verifier(不是 challenge),
|
||||
-- 且 nonce 必须是**会话创建时那一个**(服务端按它找回待授权会话)。
|
||||
local q = (d.poll_query or "")
|
||||
:gsub("{nonce}", sess.device_nonce or sess.nonce or "")
|
||||
:gsub("{verifier}", sess.device_verifier or "")
|
||||
:gsub("{challenge}", sess.device_challenge or "")
|
||||
:gsub("{device_code}", sess.device_code or "")
|
||||
url = d.token_url .. "?" .. q
|
||||
method = "GET"
|
||||
else
|
||||
body = (d.token_body
|
||||
:gsub("{device_code}", sess.device_code or "")
|
||||
:gsub("{verifier}", sess.device_verifier or "")
|
||||
:gsub("{challenge}", sess.device_challenge or ""))
|
||||
end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = d.token_url,
|
||||
method = "POST",
|
||||
url = url,
|
||||
method = method,
|
||||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
|
||||
body = body,
|
||||
})
|
||||
@ -685,6 +897,29 @@ local function deviceOneStep(plat, sess)
|
||||
local ecode = rec.error
|
||||
local step = tonumber(d.slow_down_step) or 5
|
||||
|
||||
-- 「404/pending = 继续轮询」形态(Qoder)。
|
||||
--
|
||||
-- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而
|
||||
-- openapi.qoder.sh 对「无待授权会话」返回 **404**,语义是"还没有会话,继续等"
|
||||
-- 而非失败。把 404 当失败会让登录永远失败。
|
||||
-- 判定由平台声明 judge="pending404" 驱动,不在这里硬编码平台名。
|
||||
if d.judge == "pending404" then
|
||||
if resp.status == 404 then return false end
|
||||
if resp.status == 200 then
|
||||
-- 2xx 但尚无 token:同样继续轮询
|
||||
local tok = rec.token or rec.device_token or rec.access_token
|
||||
if tok == nil or tok == "" then return false end
|
||||
return true, { ok = true, access_token = tok,
|
||||
refresh_token = rec.refresh_token or rec.refreshToken,
|
||||
account = rec.user_name or rec.userName or "",
|
||||
uid = rec.user_id or rec.userId,
|
||||
expires_at = rec.expires_at or rec.expiresAt,
|
||||
refresh_expires_at = rec.refresh_token_expires_at or rec.refreshTokenExpiresAt }
|
||||
end
|
||||
return true, { ok = false, reason = "error",
|
||||
message = "登录服务返回异常(HTTP " .. tostring(resp.status) .. ")" }
|
||||
end
|
||||
|
||||
if workos then
|
||||
if ecode == "authorization_pending" then return false end
|
||||
if ecode == "slow_down" then
|
||||
@ -731,6 +966,56 @@ local function deviceOneStep(plat, sess)
|
||||
return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) }
|
||||
end
|
||||
|
||||
-- wechatExchange:把微信 wx_code 换成 Loomy 凭据(讯飞账号端点,HMAC-SHA1 签名)。
|
||||
--
|
||||
-- 参考链路(loomy-oauth.ts 注释):bindAuthThirdAccount(code) → {bind, rcode, nickname}
|
||||
-- → 若已绑手机号可 bindSkip(rcode) 直接拿 session;未绑则要走 SMS(需人工输入验证码)。
|
||||
-- 这里做到 bindAuth 一步:拿到 rcode/nickname 即视为可用的登录上下文,
|
||||
-- 并把 bind 标志带进凭据,让上层知道是否需要补手机号(不假装 SMS 已完成)。
|
||||
local function wechatExchange(plat, wxCode)
|
||||
local w = plat.wechat
|
||||
if not w then return nil, "平台未声明 wechat 配置" end
|
||||
local path = w.bind_path
|
||||
local body = json.encode({
|
||||
tcode = { code = wxCode },
|
||||
type = "wx",
|
||||
appId = w.app_id,
|
||||
})
|
||||
local headers, herr = loomyAuthHeaders(w.access_key_id, w.access_key_secret,
|
||||
"POST", path, body, "application/json")
|
||||
if not headers then return nil, "签名失败: " .. tostring(herr) end
|
||||
headers["Accept"] = "application/json"
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = w.account_base .. path, method = "POST", headers = headers, body = body,
|
||||
})
|
||||
if err then return nil, "账号端点请求失败: " .. err end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return nil, "账号端点响应无法解析(HTTP " .. tostring(resp.status) .. ")"
|
||||
end
|
||||
-- 参考兼容 data 包裹与顶层两种形态
|
||||
local data = type(rec.data) == "table" and rec.data or rec
|
||||
local rcode = data.rcode
|
||||
if type(rcode) ~= "string" or rcode == "" then
|
||||
return nil, "微信授权响应缺少 rcode(code 可能已失效)"
|
||||
end
|
||||
-- bind 缺失归 0(走绑定流程)—— 保守方向:已绑最多多填一次手机号;
|
||||
-- 未绑却跳过会拿到没有手机号的账号。
|
||||
local bind = (data.bind == 1) and 1 or 0
|
||||
return json.encode({
|
||||
access_token = rcode, -- 后续所有步骤用 rcode(wx_code 只用这一次)
|
||||
rcode = rcode,
|
||||
bind = bind,
|
||||
session = data.session or data.token,
|
||||
user_id = data.userid or data.user_id,
|
||||
nickname = data.nickname,
|
||||
headpic = data.headpic,
|
||||
phone_bound = bind == 1,
|
||||
token_type = "Bearer",
|
||||
account_type = "personal",
|
||||
}), nil
|
||||
end
|
||||
|
||||
-- deviceRegister:设备码成功后的第二跳(Cline:WorkOS token → Cline token)。
|
||||
-- 参考注释强调:两步是必要的(浏览器 transient activation 约 5 秒,阻塞式
|
||||
-- 等授权会让 URL 过期)。返回 (access_token, account, nil) 或 (nil, nil, err)。
|
||||
@ -1819,14 +2104,109 @@ function plugin.start_login(req)
|
||||
sess.collect_expr = plat.collect_expr
|
||||
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
|
||||
end
|
||||
elseif plat.flow == "wechat-poll" then
|
||||
-- 微信扫码:取 uuid → 把二维码给前端渲染 → 长轮询等扫码确认。
|
||||
-- uuid 从授权页 HTML 里抽(参考 extractLoomyWechatUuid 的两条正则兜底)。
|
||||
local w = plat.wechat
|
||||
local authUrl = w.auth_url:gsub("{state}", sess.nonce)
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = authUrl, method = "GET",
|
||||
headers = { ["Accept"] = "text/html", ["User-Agent"] = w.user_agent,
|
||||
["Referer"] = "https://open.weixin.qq.com/" },
|
||||
})
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
sess.state = "failed"
|
||||
out.error = "微信授权页拉取失败: " .. (err or ("HTTP " .. tostring(resp and resp.status)))
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
local html = resp.body or ""
|
||||
-- 主正则 + 兜底(含 uuid 的查询串)
|
||||
local uuid = html:match('uuid%s*=%s*"([^"]+)"')
|
||||
or html:match("uuid%s*=%s*'([^']+)'")
|
||||
or html:match("[?&]uuid=([%w%-_]+)")
|
||||
if not uuid or uuid == "" then
|
||||
sess.state = "failed"
|
||||
out.error = "微信授权页未包含二维码 uuid(页面结构可能已变化)"
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
sess.wechat_uuid = uuid
|
||||
sess.wechat_last = ""
|
||||
-- 二维码图交给前端渲染(参考把 JPEG 转 data URL;这里给前端 URL,省一次下载)
|
||||
out.qr_url = w.qr_url:gsub("{uuid}", uuid)
|
||||
out.url = authUrl
|
||||
logEvent("微信二维码已就绪 " .. plat.id)
|
||||
elseif plat.flow == "init-poll" then
|
||||
-- 两段式服务端中介授权(ZCode):init 拿 flow_id + authorize_url,
|
||||
-- 用户授权后轮询 poll/{flow_id} 拿 token。
|
||||
-- 会话密钥是**本地造的 32 字节 hex**,作 Bearer 用(官方就是这么做的)。
|
||||
local ip = plat.initpoll
|
||||
local flowSecret = sha256_hex(newToken() .. tostring(os.time and os.time() or 0))
|
||||
.. sha256_hex(newToken())
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = ip.init_url,
|
||||
method = "POST",
|
||||
headers = {
|
||||
["Accept"] = "application/json",
|
||||
["Content-Type"] = "application/json",
|
||||
["Authorization"] = "Bearer " .. flowSecret,
|
||||
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
|
||||
["HTTP-Referer"] = ip.origin or "",
|
||||
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
|
||||
["X-Platform"] = "win32",
|
||||
},
|
||||
body = json.encode({ provider = ip.provider or "bigmodel" }),
|
||||
})
|
||||
if err then
|
||||
sess.state = "failed"
|
||||
out.error = "授权初始化失败: " .. err
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
local data = ok and type(rec) == "table" and rec.data or nil
|
||||
if not data or type(data.flow_id) ~= "string" or data.flow_id == "" then
|
||||
sess.state = "failed"
|
||||
out.error = "授权初始化响应缺少 flow_id(HTTP " .. tostring(resp.status) .. ")"
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
sess.init_flow_id = data.flow_id
|
||||
sess.init_secret = flowSecret
|
||||
sess.device_interval = tonumber(data.poll_interval_sec) or ip.interval or 2
|
||||
out.url = data.authorize_url or plat.login_url
|
||||
out.session.interval = sess.device_interval
|
||||
logEvent("授权流程已初始化 " .. plat.id .. " flow=" .. tostring(data.flow_id):sub(1, 12))
|
||||
elseif plat.flow == "device-poll" then
|
||||
-- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。
|
||||
-- 行为对齐参考 startMinimaxDeviceAuthorization:PKCE S256。
|
||||
--
|
||||
-- 两种形态:
|
||||
-- ① OAuth 设备码(Minimax/Cline):POST 拿 device_code + user_code,
|
||||
-- 再 GET token_url?device_code=... 轮询(code_body / token_body 模板)。
|
||||
-- ② PKCE 授权页(Qoder):**不申请设备码** —— 本地造 PKCE,直接把
|
||||
-- {authBase}/...?challenge&nonce&machine_id&client_id 给用户当授权页,
|
||||
-- 再轮询 token_url?nonce&verifier 由服务端"回填"token(404=继续)。
|
||||
-- 此时 code_url 带 Q_QUERY 后缀表示"GET + query 模板",无 device_code。
|
||||
local verifier, challenge = devicePkce()
|
||||
local machineId = newToken():sub(1, 32)
|
||||
local dm = plat.device.code_method or "POST"
|
||||
local rec = nil
|
||||
if dm == "GET_QUERY" or plat.device.auth_query then
|
||||
-- 形态②:授权页 URL 由模板拼装,无网络申请
|
||||
local q = (plat.device.auth_query or "")
|
||||
:gsub("{challenge}", challenge):gsub("{nonce}", sess.nonce)
|
||||
:gsub("{machine_id}", machineId):gsub("{verifier}", verifier)
|
||||
out.url = plat.device.code_url .. "?" .. q
|
||||
sess.device_verifier = verifier
|
||||
sess.device_challenge = challenge
|
||||
sess.device_nonce = sess.nonce
|
||||
sess.device_machine = machineId
|
||||
sess.device_interval = plat.device.interval or 2
|
||||
out.session.interval = sess.device_interval
|
||||
logEvent("授权页已就绪 " .. plat.id)
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
local body = (plat.device.code_body:gsub("{challenge}", challenge))
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = plat.device.code_url,
|
||||
method = "POST",
|
||||
method = dm,
|
||||
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
|
||||
body = body,
|
||||
})
|
||||
@ -1835,8 +2215,9 @@ function plugin.start_login(req)
|
||||
out.error = "设备码申请失败: " .. err
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" or not rec.device_code then
|
||||
local ok, r2 = pcall(json.decode, resp.body or "")
|
||||
rec = ok and type(r2) == "table" and r2 or nil
|
||||
if not rec or not rec.device_code then
|
||||
sess.state = "failed"
|
||||
out.error = "设备码响应无法解析(HTTP " .. tostring(resp.status) .. ")"
|
||||
return { status = 200, json = out }
|
||||
@ -1881,7 +2262,11 @@ function plugin.poll_login(req)
|
||||
local s = sessions()[id]
|
||||
if not s then return { status = 200, json = { state = "gone" } } end
|
||||
|
||||
if s.flow == "device-poll" and s.state == "pending" and s.device_code then
|
||||
-- 条件不能要求 device_code:Qoder 形态是 PKCE 授权页,**没有 device_code**
|
||||
-- (本地造 verifier/challenge,服务端按 nonce 找回待授权会话)。
|
||||
-- 用「有 device_code 或有 device_verifier」表达"这是个可推进的设备码/授权页会话"。
|
||||
if s.flow == "device-poll" and s.state == "pending"
|
||||
and (s.device_code or s.device_verifier) then
|
||||
local plat = platformById(s.platform)
|
||||
local done, payload = deviceOneStep(plat, s)
|
||||
if done and payload then
|
||||
@ -1909,6 +2294,135 @@ function plugin.poll_login(req)
|
||||
s.message = payload.message
|
||||
end
|
||||
end
|
||||
elseif s.flow == "wechat-poll" and s.state == "pending" and s.wechat_uuid then
|
||||
-- 长轮询一步:判据照参考(**404 与 405 不可读反**)。
|
||||
local plat = platformById(s.platform)
|
||||
local w = plat and plat.wechat
|
||||
if w then
|
||||
local last = s.wechat_last or ""
|
||||
local url = w.poll_url .. "?uuid=" .. s.wechat_uuid
|
||||
.. (last ~= "" and ("&last=" .. last) or "")
|
||||
.. "&_=" .. tostring(os.time and os.time() or 0)
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = url, method = "GET",
|
||||
headers = { ["Accept"] = "*/*", ["User-Agent"] = w.user_agent,
|
||||
["Referer"] = w.auth_url:sub(1, (w.auth_url:find("?")) and w.auth_url:find("?") - 1 or #w.auth_url) },
|
||||
})
|
||||
if not err and resp then
|
||||
local body = resp.body or ""
|
||||
local errcode = body:match("wx_errcode%s*=%s*(%d+)") or ""
|
||||
local code = body:match("wx_code%s*=%s*'([^']*)'") or ""
|
||||
s.wechat_last = errcode
|
||||
if errcode == "405" then
|
||||
-- **已确认**(wx_code 在此帧)。405 却没带 code 是异常形态,
|
||||
-- 保守判"已扫码"继续轮询,**绝不**拿空 code 去换凭据。
|
||||
if code ~= "" then
|
||||
local cred, cerr = wechatExchange(plat, code)
|
||||
if cerr then
|
||||
s.state = "failed"
|
||||
s.message = cerr
|
||||
else
|
||||
local acct = "wechat"
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = cred,
|
||||
account = acct, account_id = s.account_id })
|
||||
if res.json and res.json.ok then
|
||||
s.state = "completed"
|
||||
s.account = acct
|
||||
else
|
||||
s.state = "failed"
|
||||
s.message = (res.json and res.json.error) or "接入源失败"
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif errcode == "404" then
|
||||
-- 已扫码待确认:继续轮询(**不是**完成 —— 早期读反导致卡住)
|
||||
s.wechat_hint = "已扫码,请在手机上确认"
|
||||
elseif errcode == "403" then
|
||||
s.state = "failed"
|
||||
s.message = "用户取消了授权"
|
||||
elseif errcode == "402" then
|
||||
s.state = "failed"
|
||||
s.message = "二维码已失效,请重新发起登录"
|
||||
end
|
||||
-- 408 与未知值一律继续等(保守:绝不误判成功)
|
||||
end
|
||||
end
|
||||
elseif s.flow == "init-poll" and s.state == "pending" and s.init_flow_id then
|
||||
-- 轮询 poll/{flow_id}:判据照参考 —— 4xx(除 408/429)才是终态失败,
|
||||
-- 5xx 与网络错误继续重试;code!=0 或 data 缺失 → pending。
|
||||
local plat = platformById(s.platform)
|
||||
local ip = plat and plat.initpoll
|
||||
if ip then
|
||||
local url = ip.poll_url:gsub("{flow_id}", s.init_flow_id)
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = url, method = "GET",
|
||||
headers = {
|
||||
["Accept"] = "application/json",
|
||||
["Authorization"] = "Bearer " .. (s.init_secret or ""),
|
||||
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
|
||||
["HTTP-Referer"] = ip.origin or "",
|
||||
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
|
||||
},
|
||||
})
|
||||
if not err and resp then
|
||||
local st = resp.status
|
||||
local terminal = st >= 400 and st < 500 and st ~= 408 and st ~= 429
|
||||
if terminal then
|
||||
s.state = "failed"
|
||||
s.message = "轮询被拒(HTTP " .. tostring(st) .. ")"
|
||||
else
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if ok and type(rec) == "table" and rec.code == 0 and type(rec.data) == "table" then
|
||||
local d = rec.data
|
||||
local status = d.status
|
||||
if status == "failed" then
|
||||
s.state = "failed"
|
||||
s.message = "用户拒绝了授权或授权失败"
|
||||
elseif status == "ready" then
|
||||
-- token(zcode JWT)与 user.user_id 必需;第三方 token 取
|
||||
-- bigmodel/zai 二者之一(参考按 provider 取对应分支)。
|
||||
local jwt = d.token
|
||||
local user = d.user or {}
|
||||
local pick = function(blk)
|
||||
if type(blk) ~= "table" then return nil, nil end
|
||||
return blk.access_token or blk.accessToken, blk.refresh_token or blk.refreshToken
|
||||
end
|
||||
local bmAt, bmRt = pick(d.bigmodel)
|
||||
local zaiAt, zaiRt = pick(d.zai)
|
||||
local thirdAt = bmAt or zaiAt
|
||||
if type(jwt) ~= "string" or jwt == "" then
|
||||
s.state = "failed"
|
||||
s.message = "授权响应缺少 zcode token"
|
||||
else
|
||||
local cred = json.encode({
|
||||
access_token = jwt,
|
||||
zcode_jwt = jwt,
|
||||
-- 第三方 token 仅作备用身份,不参与推理(参考明确)
|
||||
bigmodel_access_token = bmAt,
|
||||
zai_access_token = zaiAt,
|
||||
refresh_token = bmRt or zaiRt,
|
||||
user_id = user.user_id or user.id,
|
||||
nickname = user.name or user.email,
|
||||
token_type = "Bearer",
|
||||
account_type = "personal",
|
||||
})
|
||||
local acct = user.name or user.email or user.user_id or "zcode"
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = cred,
|
||||
account = acct, account_id = s.account_id })
|
||||
if res.json and res.json.ok then
|
||||
s.state = "completed"
|
||||
s.account = acct
|
||||
else
|
||||
s.state = "failed"
|
||||
s.message = (res.json and res.json.error) or "接入源失败"
|
||||
end
|
||||
end
|
||||
end
|
||||
-- status == "pending" 或其它未知值:继续等(不误报)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif s.flow == "state-poll" and s.state == "pending" and s.state_token then
|
||||
-- Buddy(CodeBuddy):state 轮询。状态里的 state_token 是 auth/state 下发的。
|
||||
local plat = platformById(s.platform)
|
||||
@ -3007,6 +3521,23 @@ function plugin.set_endpoints(req)
|
||||
for k in pairs(allowed) do
|
||||
if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end
|
||||
end
|
||||
if type(args.wechat) == "table" and plat.wechat then
|
||||
for _, k in ipairs({ "auth_url", "qr_url", "poll_url", "account_base",
|
||||
"bind_path", "user_agent", "access_key_id", "access_key_secret", "app_id" }) do
|
||||
if args.wechat[k] then
|
||||
plat.wechat[k] = tostring(args.wechat[k])
|
||||
changed[#changed+1] = "wechat." .. k
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(args.initpoll) == "table" and plat.initpoll then
|
||||
for _, k in ipairs({ "init_url", "poll_url", "provider", "origin" }) do
|
||||
if args.initpoll[k] then
|
||||
plat.initpoll[k] = tostring(args.initpoll[k])
|
||||
changed[#changed+1] = "initpoll." .. k
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(args.device) == "table" and plat.device then
|
||||
for _, k in ipairs({ "code_url", "token_url" }) do
|
||||
if args.device[k] then plat.device[k] = tostring(args.device[k]) changed[#changed+1] = "device." .. k end
|
||||
|
||||
Reference in New Issue
Block a user