feat(channels): Raccoon 扫码登录实现 —— 12 个平台零 manual

第四处也是最后一处「把没实现说成做不到」:此前标 manual,理由"要扫图片二维码"。

参考事实(raccoon-oauth.ts / raccoon-login-page.ts 注释):
  · `qrcode_code` 由**宿主侧本地随机生成**(16 字节 hex),服务端接受任意
    自造 code 并进入 pending —— 完全绕开收不到的自定义协议回调
  · 二维码只是一张公开页面 {apiBase}/login/mp?code=...&appname=...
  · 轮询 POST /api/web/auth/v1/login_with_qrcode_code {qrcode_code}
    → data.status ∈ pending / logging / canceled / success
  · **success 但缺 token 视为未完成**(否则产出空凭据并让流程卡死)
  · 参考明确:任何异常都降级为 pending(网络抖动/畸形响应/未知 status)
真正需要人工的只有短信路径(阿里云滑块 captcha_param + 手机号 AES 加密)——
那一条不做,但扫码这条完全自动化。

## 同时按参考修正的两处错误
- `base_url` 写成 raccoon.sensetime.com,实际是 **xiaohuanxiong.com**;
  推理路径是 `/api/web/llm/v2`(标准 OpenAI 兼容)
- 余额路径缺 `/api/web` 前缀,且未带 `X-Org-Code` / `X-Raccoon-Language` 头
  (照参考 raccoonHeaders)

## 判据
TestRaccoonQrPollBehavior(5 项):本地生成 32 位 hex code + 二维码 URL;
logging 保持 pending;success 且带 token 才完成;canceled 是终态失败;
**缺 token 的 success 必须 pending**(防空凭据卡死)。
全量 core 回归通过;内核对照 v2.0.2 diff 为空。

## 十二个平台现状(零 manual)
trae/minimax/cline/buddy/gemini/lobsterai/opencode/codearts 原有流程 +
qoder(device-poll) / zcode(init-poll) / loomy(wechat-poll) / raccoon(qr-poll)
This commit is contained in:
JianFeeeee
2026-10-13 10:08:39 +08:00
parent 8949405746
commit 705e079285
2 changed files with 269 additions and 8 deletions

View File

@ -599,22 +599,47 @@ local PLATFORMS = {
id = "raccoon",
name = "Raccoon(商汤小浣熊)",
vendor = "sensetime",
flow = "manual",
manual_hint = "Raccoon 登录要扫图片二维码(商汤小浣熊官网)。请扫码后,"
.. "把 login_with_qrcode_code 拿到的凭证粘贴进来。",
base_url = "https://raccoon.sensetime.com",
-- ⚠️ 纠正第四处:此前标 manual,说"要扫码,做不了"。**扫码路径纯 HTTP 可做**:
-- ① 本地造 qrcode_code(16 字节随机 hex)
-- ② 二维码 URL {apiBase}/login/mp?code=...&appname=商汤小浣熊官网(前端渲染)
-- ③ 轮询 POST {apiBase}/api/web/auth/v1/login_with_qrcode_code {qrcode_code}
-- → data.status ∈ pending/logging/canceled/success;success 带 token
-- 参考注释:code 由**宿主侧本地随机生成**(服务端接受任意自造 code 并进入
-- pending),故完全绕开收不到的自定义协议回调。
-- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号
-- AES 加密 —— 那一条才真需要人工,故不走。
flow = "qr-poll",
qr = {
api_base = "https://xiaohuanxiong.com",
auth_prefix = "/api/web/auth/v1",
poll_path = "/login_with_qrcode_code",
login_page = "/login/mp",
app_name = "商汤小浣熊官网",
interval = 2,
},
-- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions)
base_url = "https://xiaohuanxiong.com/api/web/llm/v2",
adapter = "openai",
models = { "raccoon-default" },
-- 余额(对齐参考 raccoon-credits.ts:GET {apiBase}/points/v1/balance → data.available_points)
-- 余额(照参考:GET {apiBase}/api/web/points/v1/balance)
balance = {
url = "https://raccoon.sensetime.com/points/v1/balance",
url = "https://xiaohuanxiong.com/api/web/points/v1/balance",
method = "GET",
headers_fn = function(accessToken, cred)
return {
["Authorization"] = "Bearer " .. accessToken,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Org-Code"] = (cred and cred.office_identity) or "",
["X-Raccoon-Language"] = "zh",
}
end,
total_fn = function(rec)
local d = rec.data or rec
return tonumber(d.available_points or d.availablePoints) or 0
return tonumber(d.available_points or d.availablePoints or d.points)
end,
},
-- 一次性新人奖励(对齐 onboarding:桌面登录奖励 3000 分)
-- 一次性新人奖励(对齐参考 RACCOON_LOGIN_REWARD_POINTS)
onboarding = { reward_points = 3000 },
},
}
@ -690,6 +715,7 @@ local function declaredHosts()
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.qr then addHost(p.qr.api_base) 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
@ -2104,6 +2130,17 @@ function plugin.start_login(req)
sess.collect_expr = plat.collect_expr
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
end
elseif plat.flow == "qr-poll" then
-- 自带二维码的扫码登录(Raccoon):code 由**本地随机生成**,服务端接受
-- 任意自造 code 并进入 pending(参考注释实测)。二维码图交给前端渲染。
local q = plat.qr
local code = tostring(sha256_hex(tostring(os.time and os.time() or 0) .. tostring(math.random(1e12)))):sub(1, 32)
sess.qr_code = code
out.qr_url = q.api_base .. q.login_page .. "?code=" .. code
.. "&appname=" .. rfc3986Escape(q.app_name)
out.url = out.qr_url
out.session.interval = q.interval or 2
logEvent("二维码已生成 " .. plat.id)
elseif plat.flow == "wechat-poll" then
-- 微信扫码:取 uuid → 把二维码给前端渲染 → 长轮询等扫码确认。
-- uuid 从授权页 HTML 里抽(参考 extractLoomyWechatUuid 的两条正则兜底)。
@ -2294,6 +2331,57 @@ function plugin.poll_login(req)
s.message = payload.message
end
end
elseif s.flow == "qr-poll" and s.state == "pending" and s.qr_code then
-- 轮询 login_with_qrcode_code:status ∈ pending/logging/canceled/success。
-- 参考:**任何异常都降级为 pending**(网络抖动/响应畸形/未知 status),
-- success 但缺 token 也视为未完成(否则产出空凭据并卡死)。
local plat = platformById(s.platform)
local q = plat and plat.qr
if q then
local resp, err = __llmsproxy_http({
url = q.api_base .. q.auth_prefix .. q.poll_path,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
body = json.encode({ qrcode_code = s.qr_code }),
})
if not err and resp then
local ok, rec = pcall(json.decode, resp.body or "")
local env = ok and type(rec) == "table" and rec or nil
if env and env.code == 0 and type(env.data) == "table" then
local st = env.data.status
if st == "canceled" then
s.state = "failed"
s.message = "用户取消了扫码授权"
elseif st == "success" then
local at = env.data.access_token
if type(at) ~= "string" or at == "" then
-- 缺 token 的 success 视为未完成(不产出空凭据)
s.wechat_hint = "扫码成功,等待凭据下发"
else
local cred = json.encode({
access_token = at,
refresh_token = env.data.refresh_token,
office_identity = env.data.office_identity,
token_type = "Bearer",
account_type = "personal",
})
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = "raccoon", account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = "raccoon"
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
elseif st == "logging" then
s.wechat_hint = "已扫码,正在登录"
end
-- pending 与未知值:继续等
end
end
end
elseif s.flow == "wechat-poll" and s.state == "pending" and s.wechat_uuid then
-- 长轮询一步:判据照参考(**404 与 405 不可读反**)。
local plat = platformById(s.platform)
@ -3521,6 +3609,14 @@ 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.qr) == "table" and plat.qr then
for _, k in ipairs({ "api_base", "auth_prefix", "poll_path", "login_page", "app_name" }) do
if args.qr[k] then
plat.qr[k] = tostring(args.qr[k])
changed[#changed+1] = "qr." .. k
end
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