diff --git a/internal/core/channels_loomy_test.go b/internal/core/channels_loomy_test.go new file mode 100644 index 0000000..d743b9a --- /dev/null +++ b/internal/core/channels_loomy_test.go @@ -0,0 +1,174 @@ +package core + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// loomyHarness:Loomy 微信扫码长轮询的测试台。 +// +// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"): +// +// 408 = 等待扫码;404 = 已扫码待确认(继续轮询); +// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效 +type loomyHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + polls int32 + confirmAt int32 + gotAuth string +} + +func newLoomyHarness(t *testing.T) *loomyHarness { + t.Helper() + h := &loomyHarness{t: t, confirmAt: 3} + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"): + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, ``) + case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"): + n := atomic.AddInt32(&h.polls, 1) + w.Header().Set("Content-Type", "text/plain") + if n == 1 { + fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码 + return + } + if n < atomic.LoadInt32(&h.confirmAt) { + fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认 + return + } + fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认 + case r.URL.Path == "/login/thirdAccount/bind/auth": + h.gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "loomy", + "wechat": map[string]interface{}{ + "auth_url": h.ts.URL + "/connect/qrconnect?state={state}", + "qr_url": h.ts.URL + "/connect/qrcode/{uuid}", + "poll_url": h.ts.URL + "/connect/l/qrconnect", + "account_base": h.ts.URL, + }, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +func (h *loomyHarness) begin() (map[string]interface{}, string) { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin", + }) + if err != nil { + h.t.Fatal(err) + } + j := out.JSON.(map[string]interface{}) + return j, j["session"].(map[string]interface{})["id"].(string) +} + +func (h *loomyHarness) pollOnce(sid string) map[string]interface{} { + out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", + }) + if err != nil { + h.t.Fatal(err) + } + return out.JSON.(map[string]interface{}) +} + +// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。 +func TestLoomyWechatPollBehavior(t *testing.T) { + h := newLoomyHarness(t) + + t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + j, _ := h.begin() + qr, _ := j["qr_url"].(string) + if !strings.Contains(qr, "UUID-TEST-1") { + t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr) + } + }) + + t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + atomic.StoreInt32(&h.confirmAt, 99) + _, sid := h.begin() + // 第一拍 408 + if st := h.pollOnce(sid); st["state"] != "pending" { + t.Fatalf("408 应 pending, 实得 %v", st["state"]) + } + // 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里) + st := h.pollOnce(sid) + if st["state"] != "pending" { + t.Fatalf("★ 404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"]) + } + }) + + t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + atomic.StoreInt32(&h.confirmAt, 3) + _, sid := h.begin() + h.pollOnce(sid) // 408 + h.pollOnce(sid) // 404 + st := h.pollOnce(sid) // 405 + if st["state"] != "completed" { + t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"]) + } + // 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer + if !strings.HasPrefix(h.gotAuth, "account ") { + t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth) + } + if !strings.Contains(h.gotAuth, ":") { + t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth) + } + v, ok, _ := h.ps.GetSecret("channels", "channels:loomy") + if !ok || !strings.Contains(v, "RCODE-1") { + t.Fatalf("凭据应含 rcode, 实得 %q", v) + } + }) + + t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) { + for _, tc := range []struct{ errcode, want string }{ + {"403", "取消"}, {"402", "失效"}, + } { + hh := newLoomyHarness(t) + hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") { + fmt.Fprint(w, ``) + return + } + fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode) + }) + _, sid := hh.begin() + st := hh.pollOnce(sid) + if st["state"] != "failed" { + t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"]) + } + } + }) +} diff --git a/internal/core/channels_qoder_test.go b/internal/core/channels_qoder_test.go new file mode 100644 index 0000000..bd1584e --- /dev/null +++ b/internal/core/channels_qoder_test.go @@ -0,0 +1,181 @@ +package core + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// qoderHarness:Qoder 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 +// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成 +// +// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"—— +// 把聊天加密与登录混为一谈,导致该平台完全不可用。 +type qoderHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + poll int32 // 已发生的轮询次数 + // step: 第几次轮询开始返回 token(0 = 一直 404) + tokenAt int32 + gotQuery string +} + +func newQoderHarness(t *testing.T) *qoderHarness { + t.Helper() + h := &qoderHarness{t: t, tokenAt: 2} + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/deviceToken/poll" { + h.gotQuery = r.URL.RawQuery + n := atomic.AddInt32(&h.poll, 1) + if n < atomic.LoadInt32(&h.tokenAt) { + w.WriteHeader(404) // 尚无会话 + fmt.Fprint(w, `{"code":404,"msg":"no session"}`) + return + } + fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`) + return + } + w.WriteHeader(404) + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) + // 把 poll 指到假上游(授权页是本地拼装,不经网络) + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "qoder", + "device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"}, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +func (h *qoderHarness) begin() (map[string]interface{}, string) { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin", + }) + if err != nil { + h.t.Fatal(err) + } + j := out.JSON.(map[string]interface{}) + return j, j["session"].(map[string]interface{})["id"].(string) +} + +func (h *qoderHarness) pollOnce(sid string) map[string]interface{} { + out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", + }) + if err != nil { + h.t.Fatal(err) + } + return out.JSON.(map[string]interface{}) +} + +// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。 +func TestQoderDevicePollBehavior(t *testing.T) { + h := newQoderHarness(t) + + t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) { + j, _ := h.begin() + url, _ := j["url"].(string) + for _, want := range []string{ + "qoder.com/device/selectAccounts", + "challenge=", "challenge_method=S256", "nonce=", "machine_id=", + // client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒) + "client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb", + } { + if !strings.Contains(url, want) { + t.Fatalf("授权页缺 %q:\n%s", want, url) + } + } + }) + + t.Run("行2 404视为继续_不误报失败", func(t *testing.T) { + atomic.StoreInt32(&h.poll, 0) + atomic.StoreInt32(&h.tokenAt, 99) // 一直 404 + _, sid := h.begin() + for i := 0; i < 3; i++ { + st := h.pollOnce(sid) + if st["state"] != "pending" { + t.Fatalf("404 应保持 pending, 实得 %v", st["state"]) + } + } + }) + + t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) { + atomic.StoreInt32(&h.poll, 0) + atomic.StoreInt32(&h.tokenAt, 99) + _, sid := h.begin() + h.pollOnce(sid) + q := h.gotQuery + if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") { + t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q) + } + if !strings.Contains(q, "challenge_method=S256") { + t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q) + } + }) + + t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) { + atomic.StoreInt32(&h.poll, 0) + atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token + _, sid := h.begin() + if st := h.pollOnce(sid); st["state"] != "pending" { + t.Fatalf("首拍应 pending: %v", st) + } + st := h.pollOnce(sid) + if st["state"] != "completed" { + t.Fatalf("拿到 token 后应 completed, 实得 %v", st) + } + // 凭据落保险库(旧路径键) + v, ok, _ := h.ps.GetSecret("channels", "channels:qoder") + if !ok || !strings.Contains(v, "QD-AT-1") { + t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v) + } + }) + + t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) { + atomic.StoreInt32(&h.poll, 0) + atomic.StoreInt32(&h.tokenAt, 1) + add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin", + }) + if err != nil { + t.Fatal(err) + } + aid := add.JSON.(map[string]interface{})["account_id"].(string) + out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin", + }) + sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string) + st := h.pollOnce(sid) + if st["state"] != "completed" { + t.Fatalf("池路径应完成: %v", st) + } + if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok { + t.Fatalf("★ 池路径凭据未落到 channels:qoder:%s", aid) + } + }) +} diff --git a/internal/core/channels_zcode_test.go b/internal/core/channels_zcode_test.go new file mode 100644 index 0000000..f152707 --- /dev/null +++ b/internal/core/channels_zcode_test.go @@ -0,0 +1,188 @@ +package core + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。 +// +// 参考语义(zcode-login.ts 逐行): +// +// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec} +// Authorization 是**本地造的会话密钥**(不是用户凭据) +// ② 用户在浏览器打开 authorize_url +// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}} +// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等; +// code!=0 或 data 缺失 = pending;status=pending = 继续 +// +// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha** +// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。 +type zcodeHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + polls int32 + readyAt int32 + gotInit map[string]interface{} + gotAuth string +} + +func newZcodeHarness(t *testing.T) *zcodeHarness { + t.Helper() + h := &zcodeHarness{t: t, readyAt: 2} + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "POST" && r.URL.Path == "/api/v1/oauth/cli/init": + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &h.gotInit) + h.gotAuth = r.Header.Get("Authorization") + fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT", + "authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x", + "expires_at":9999999999,"poll_interval_sec":2}}`) + case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"): + if !strings.HasSuffix(r.URL.Path, "FLOW-1") { + fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`) + return + } + n := atomic.AddInt32(&h.polls, 1) + if n < atomic.LoadInt32(&h.readyAt) { + fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`) + return + } + fmt.Fprint(w, `{"code":0,"data":{"status":"ready", + "token":"ZCODE-JWT-1", + "user":{"user_id":"u7","name":"zcode-dev"}, + "bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "zcode", + "initpoll": map[string]interface{}{ + "init_url": h.ts.URL + "/api/v1/oauth/cli/init", + "poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}", + }, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +func (h *zcodeHarness) begin() (map[string]interface{}, string) { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin", + }) + if err != nil { + h.t.Fatal(err) + } + j := out.JSON.(map[string]interface{}) + return j, j["session"].(map[string]interface{})["id"].(string) +} + +func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} { + out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", + }) + if err != nil { + h.t.Fatal(err) + } + return out.JSON.(map[string]interface{}) +} + +// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。 +func TestZcodeInitPollBehavior(t *testing.T) { + h := newZcodeHarness(t) + + t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + j, _ := h.begin() + if h.gotInit["provider"] != "bigmodel" { + t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit) + } + // Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据 + if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 { + t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth) + } + if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") { + t.Fatalf("应给出 authorize_url, 实得 %q", u) + } + }) + + t.Run("行2 pending状态保持pending", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + atomic.StoreInt32(&h.readyAt, 99) + _, sid := h.begin() + for i := 0; i < 3; i++ { + if st := h.pollOnce(sid); st["state"] != "pending" { + t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"]) + } + } + }) + + t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + atomic.StoreInt32(&h.readyAt, 2) + _, sid := h.begin() + if st := h.pollOnce(sid); st["state"] != "pending" { + t.Fatalf("首拍应 pending: %v", st) + } + st := h.pollOnce(sid) + if st["state"] != "completed" { + t.Fatalf("ready 应 completed, 实得 %v", st) + } + v, ok, _ := h.ps.GetSecret("channels", "channels:zcode") + if !ok { + t.Fatal("凭据未落库") + } + for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} { + if !strings.Contains(v, want) { + t.Fatalf("凭据应含 %q, 实得 %s", want, v) + } + } + }) + + t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) { + atomic.StoreInt32(&h.polls, 0) + atomic.StoreInt32(&h.readyAt, 1) + add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin", + }) + if err != nil { + t.Fatal(err) + } + aid := add.JSON.(map[string]interface{})["account_id"].(string) + out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin", + }) + sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string) + if st := h.pollOnce(sid); st["state"] != "completed" { + t.Fatalf("池路径应完成: %v", st) + } + if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok { + t.Fatalf("★ 池路径凭据未落到 channels:zcode:%s", aid) + } + }) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 027cdb8..3289b3b 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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