Files
ModelRouter/internal/lua/plugins/channels.lua
JianFeeeee 2353d43c16 feat(channels): 全量厂商接入 —— Cline/Buddy/Gemini/LobsterAI/OpenCode/CodeArts/Qoder/ZCode/Loomy/Raccoon + 新流程 + WebUI 补齐
新增 10 个真实厂商平台(端点/模型/流程逐字照抄参考 channel-pack):

device-poll(已有执行器):
  minimax  保留(MiniMax 形态:看 status 字段,slow_down +5s)
  cline    WorkOS 设备码(判据看 error 字段、HTTP 可非 2xx;slow_down +1s;
           access_denied/invalid_grant 同组失败;成功后 register 第二跳
           /api/v1/auth/register 换 Cline token)。新增 deviceRegister。

state-poll(新流程):
  buddy    CodeBuddy(腾讯)。POST /v2/plugin/auth/state → authUrl+state;
           轮询 GET /v2/plugin/auth/token?state=,业务码 11217 = 未就绪继续等
           (对齐参考 loopGetToken:网络错误同样继续不中断);token 就绪后
           拿账号(失败不阻塞)。

callback:
  gemini   标准 OAuth code 交换(oauth2.googleapis.com/token,六 scope 照抄);
           oauth_callback 增 code→token 交换分支(Gemini/LobsterAI 复用)。
  lobsterai 本地回调 + authCode 换 token(lobsterai-server.youdao.com)。
  trae     保留(token 直传 + ExchangeToken,已 8938574)。

paste:
  opencode 直接贴 API key(无登录流)。
  codearts 企业凭据 + SDK-HMAC-SHA256 签到(claim_fn 钩子,canonical request
           + 派生密钥链 kDate→kNonce→kSigning→signature)。

manual(后端给 manual_hint,明确说明而非假装成功):
  qoder    WASM 签名头(纯 Lua 无法生成)
  zcode    captcha 求解
  loomy    微信扫码
  raccoon  图片二维码

WebUI(之前只覆盖 vnc/url+轮询/device-poll):
  chLogin 增 manual / paste 两个分支:manual_hint 展示 + 凭据粘贴框 +
           提交按钮。新增 chComplete 调 channels/login/complete 落保险库,
           凭据不回显、失败给可读错误不重载页面。node --check 过。

set_endpoints 增 register/state 覆盖(运维能力 + 测试缝,与 device/checkin 同语义)。

行为测试(照参考逐行):
  TestClineWorkOSBehavior  5 行:非200 authorization_pending 继续等 /
    slow_down 退避 1s(interval=2)/ access_denied 失败 / 成功 register 凭据
    是 CLINE-AT-1(不是 WorkOS token)/ 非200无 error 失败。
  TestBuddyStatePollBehavior 3 行:state 申请给 authUrl / 首拍 11217 pending
    次拍 token completed / account 查询失败不阻塞回退默认名。

变异测试修复(deviceOneStep 拆 workos/minimax 两分支后幸存体暴露):
  锚点 +step / authorization_pending 现两处同形,strings.Replace(,,1) 只改
  workos 分支、minimax 决策表没杀到 → 改 ReplaceAll 全改;删冗余第 6 变异体。
  5 变异体全杀。回归:channels/device/cline/buddy/trae/legacy/保险库/e2e 全绿。
2026-10-12 00:28:26 +08:00

1472 lines
66 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源)
--
-- 为什么需要它
-- ------------
-- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。
-- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是
-- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到
-- 凭据 → 定时领取额度 → 把端点当源用。
--
-- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的
-- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。
-- 新增一个平台是往 PLATFORMS 里加一条,不改内核。
--
-- 设计边界(与内核的约定)
-- ------------------------
-- 内核提供基础设施,插件自己不实现这些:
--
-- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它)
-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读)
-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…)
--
-- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。
-- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"——
-- 内核里出现厂商名就是分层被破坏了。登录会话、服务端浏览器栈也全归插件自持。
--
-- 权限(本插件需要,全部可在设置页看到"申请中")
-- ------------------------------------------------
-- add_source 把平台端点注册为上游源
-- list_sources 显示当前已接入的平台
-- probe_all 接入后立刻验证可达
-- reset_health 上游恢复后清冷却
-- status 面板显示运行状态
--
-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被
-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。
--
-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions,
-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用
-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。
-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调)
-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。
local plugin = {
name = "channels",
version = "1.0.0",
description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源",
author = "llmsproxy",
}
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints" }
-- ============================================================================
-- 平台表
-- ============================================================================
--
-- 每个平台声明登录方式与各端点。字段:
-- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/
-- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/
-- paste(用户粘贴 cookie/token)
-- login_url 用户要在自己浏览器打开的地址
-- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状)
-- adapter 内核适配器名(openai / anthropic / …)
-- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖)
-- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到)
--
-- 新增平台 = 加一条。不改内核,也不改本文件其它部分。
--
-- ⚠️ 真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。
-- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。
-- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua
-- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。
local PLATFORMS = {
-- ===== 测试/示范用占位平台(example.com,不打真实厂商)=====
{
id = "example",
name = "Example 平台",
flow = "link-poll",
login_url = "https://auth.example.com/device",
base_url = "https://api.example.com/v1",
adapter = "openai",
models = { "example-chat", "example-reason" },
checkin = { url = "https://api.example.com/v1/api/daily/claim", method = "POST", body = "{}" },
},
{
id = "googleish",
name = "Google 系(示范 callback)",
flow = "callback",
login_url = "https://accounts.google.com/o/oauth2/auth",
base_url = "https://api.googleish.example/v1",
adapter = "openai",
models = { "g-model-1" },
},
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
-- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。
-- 这是最标准的一条,作为"同类厂商"的实现模板。
{
id = "trae",
name = "TRAE(字节)",
vendor = "bytedance",
flow = "callback",
-- 用户在自己浏览器打开授权页;回调带 token 直传(auth_type=local)。
login_url = "https://www.trae.cn/authorization",
hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" },
base_url = "https://trae-api-cn.mchost.guru",
adapter = "openai",
models = {
"DeepSeek-V4-Flash", "DeepSeek-V4-Pro", "kimi-k3", "kimi-k2.6",
"glm-5", "glm-5.2", "Doubao-Seed-2.1-Pro",
},
-- 每日签到:POST /trae/api/v2/ug/checkin_credits/claim body {} 到 UG 主机。
checkin = {
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim",
method = "POST", body = "{}",
},
-- callback 解析模式(移植自参考 trae-oauth.ts parseTraeCallbackDetailed):
-- trae token 直传:query 带 refreshToken / userJwt(JSON) / userInfo(JSON)。
-- refreshToken 存在时调 ExchangeToken 换新(轮换旧 refresh);
-- 无 refreshToken 用 userJwt.Token 兑底;
-- 只有 code/authCodeInfo 时是 PKCE 流 → 明确报"暂不支持"而不是含糊失败。
callback = {
mode = "trae",
exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken",
exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}',
client_id = "", -- 参考用 product.clientId;TRAE 回调直传形态不强制,留空
},
},
-- MiniMax —— PKCE 设备码流(RFC 8628)。三步:申请码 → 用户授权 → 轮询换
-- token。全程纯 HTTP,无需本地服务器;user_code 显示给用户输到授权页。
{
id = "minimax",
name = "MiniMax",
vendor = "minimax",
flow = "device-poll",
device = {
-- 申请设备码:PKCE S256
code_url = "https://account.minimax.cn/oauth2/device/code",
code_body = "client_id=mcode-public&scope=agent.default&audience=agent-backend"
.. "&code_challenge={challenge}&code_challenge_method=S256",
-- 轮询换 token
token_url = "https://account.minimax.cn/oauth2/token",
token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
.. "&device_code={device_code}&client_id=mcode-public&code_verifier={verifier}",
interval = 5, -- 轮询间隔秒
slow_down_guard = true, -- 响应 authorization_pending / slow_down 按标准处理
},
hosts = { "account.minimax.cn", "agent.minimax.cn" },
base_url = "https://agent.minimax.cn",
adapter = "openai",
models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed" },
-- 每日签到(两步:状态预检 + 领取;幂等靠服务端状态)
checkin = {
url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/claim",
method = "POST", body = "{}",
},
},
-- Cline —— WorkOS 设备码流(照参考 cline-oauth.ts / cline-product.ts 逆向)。
-- 与 MiniMax 同为 device-poll,但判据形态完全不同(参考注释反复强调):
-- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段;
-- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端);
-- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token
-- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。
{
id = "cline",
name = "Cline",
vendor = "cline",
flow = "device-poll",
device = {
code_url = "https://api.workos.com/user_management/authorize/device",
code_body = "client_id=client_01K3A541FN8TA3EPPHTD2325AR",
token_url = "https://api.workos.com/user_management/authenticate",
token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
.. "&device_code={device_code}&client_id=client_01K3A541FN8TA3EPPHTD2325AR",
interval = 5,
-- WorkOS 形态:判 error 字段;slow_down 退避 1 秒;access_denied/invalid_grant 同组
judge = "workos",
slow_down_step = 1,
},
-- 第二步:WorkOS token → Cline token(挂 apiBase)
register = {
url = "https://api.cline.bot/api/v1/auth/register",
method = "POST",
body = '{"accessToken":"{access_token}","refreshToken":"{refresh_token}"}',
},
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
base_url = "https://api.cline.bot",
adapter = "openai",
-- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费)
models = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
},
-- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取
},
-- Buddy(腾讯 CodeBuddy)—— state 轮询流(照参考 buddy-oauth.ts 逆向)。
-- 两步:POST /v2/plugin/auth/state → {state, authUrl};用户在 authUrl 完成登录后
-- 插件轮询 GET /v2/plugin/auth/token?state=...。判据:业务码 11217 = 未就绪继续等;
-- 网络错误同样继续轮询不中断(对齐 Rust loop_get_token)。token 就绪后再拿账号。
{
id = "buddy",
name = "CodeBuddy(腾讯)",
vendor = "tencent",
flow = "state-poll",
state = {
state_url = "https://copilot.tencent.com/v2/plugin/auth/state?platform=ide",
token_url = "https://copilot.tencent.com/v2/plugin/auth/token",
account_url = "https://copilot.tencent.com/v2/plugin/login/account",
code_not_ready = 11217,
interval = 1,
},
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
base_url = "https://copilot.tencent.com",
adapter = "openai",
models = {
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
"glm-5.3", "glm-5.3-flash", "glm-5.2",
},
},
-- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 flow="vnc"。
{
id = "loopbackish",
name = "回环登录平台(示范 VNC 兜底)",
flow = "vnc",
needs_browser = true,
login_url = "https://auth.loopbackish.example/authorize?redirect_uri=http://127.0.0.1:%d/callback",
base_url = "https://api.loopbackish.example/v1",
adapter = "openai",
models = { "lb-model-1" },
},
{
id = "cookieish",
name = "Cookie 平台(示范 paste)",
flow = "paste",
base_url = "https://api.cookieish.example/v1",
adapter = "openai",
models = { "c-model-1" },
checkin = { url = "https://api.cookieish.example/v1/api/credit/claim", method = "POST", body = "{}" },
},
-- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。
-- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调
-- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。
-- client_id 是 Cloud Code 的公开客户端(逆向自 binary),scope 六项逐字照抄。
{
id = "gemini",
name = "Gemini(Cloud Code)",
vendor = "google",
flow = "callback",
login_url = "https://accounts.google.com/o/oauth2/v2/auth"
.. "?client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
.. "&redirect_uri=http://127.0.0.1:8845/auth/callback"
.. "&response_type=code"
.. "&scope=openid+email+profile+cloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email"
.. "&include_granted_scopes=true",
callback = {
mode = "code",
token_url = "https://oauth2.googleapis.com/token",
token_body = "grant_type=authorization_code&code={code}"
.. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
.. "&redirect_uri=http://127.0.0.1:8845/auth/callback",
pkce = true, -- 授权页带 verifier(S256),交换时回传
},
hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" },
base_url = "https://cloudcode-pa.googleapis.com",
adapter = "gemini",
models = { "gemini-3.1-flash-lite-preview", "gemini-3.1-flash-preview" },
},
-- LobsterAI(有道龙虾)—— 本地回调 + authCode 换 token(照参考 lobsterai-oauth.ts)。
{
id = "lobsterai",
name = "LobsterAI(有道)",
vendor = "youdao",
flow = "callback",
login_url = "https://lobsterai.youdao.com/login",
callback = {
mode = "code",
token_url = "https://lobsterai-server.youdao.com/api/auth/login",
token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"cli","uuid":"","version":"1.0"}',
},
hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com" },
base_url = "https://lobsterai-server.youdao.com",
adapter = "openai",
models = { "kimi-k3", "glm-5.2", "deepseek-v4-flash" },
},
-- OpenCode —— 直接粘贴 API key(无登录流,参考 opencode-auth.ts)。
{
id = "opencode",
name = "OpenCode Zen",
vendor = "opencode",
flow = "paste",
base_url = "https://opencode.ai/zen",
adapter = "openai",
models = { "big-pickle", "claude-sonnet-4-20250514", "gpt-5.1-codex-mini" },
},
-- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。
-- 登录是常规企业凭据(paste);签到走 snap-access 的 SDK-HMAC-SHA256 签名,
-- 放在 claim_fn 钩子里(checkin 表驱动驱动不动签名请求,参考注释也点明这一点)。
{
id = "codearts",
name = "CodeArts(华为)",
vendor = "huawei",
flow = "paste",
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
adapter = "openai",
models = { "CodeArts-Pangu" },
checkin = {
url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
method = "GET",
},
-- claim_fn 由下方 claimCodeArts 实现(引用前向声明,见文件末尾)
claim_fn_name = "claimCodeArts",
},
-- Qoder —— PKCE 设备码 + WASM 加密签名(照参考 qoder-oauth.ts 逆向)。
-- WASM 签名头无法在纯 Lua 环境完成,登录标 manual 给明确说明。
{
id = "qoder",
name = "Qoder",
vendor = "qoder",
flow = "manual",
manual_hint = "Qoder 登录要 WASM 签名头,纯 Lua 环境无法生成。请在浏览器完成"
.. "授权后,把最终凭证粘贴进来(手动输入凭据)。",
base_url = "https://api2-v2.qoder.sh",
adapter = "openai",
models = { "qoder-default" },
},
-- ZCode —— cli/init + captcha(照参考 zcode-auth.ts 逆向)。
-- captcha 求解(mint captcha + 补激活上报)无法在纯 Lua 完成,标 manual。
{
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",
models = { "zcode-default" },
},
-- Loomy —— 微信扫码登录(照参考 loomy-oauth.ts 逆向)。
-- 扫码要在微信 App 里完成,纯 Lua 环境拿不到微信凭据,标 manual。
{
id = "loomy",
name = "Loomy",
vendor = "loomy",
flow = "manual",
manual_hint = "Loomy 登录要微信扫码(在微信公众号里完成授权)。请在微信里"
.. "扫码后,把拿到的凭证粘贴进来。",
base_url = "https://loomy.example.com/v1",
adapter = "openai",
models = { "loomy-default" },
},
-- Raccoon(商汤小浣熊)—— 扫码登录(照参考 raccoon-oauth.ts 逆向)。
-- 扫码是图片码,纯 Lua 环境无法展示与回取,标 manual。
{
id = "raccoon",
name = "Raccoon(商汤小浣熊)",
vendor = "sensetime",
flow = "manual",
manual_hint = "Raccoon 登录要扫图片二维码(商汤小浣熊官网)。请扫码后,"
.. "把 login_with_qrcode_code 拿到的凭证粘贴进来。",
base_url = "https://raccoon.sensetime.com",
adapter = "openai",
models = { "raccoon-default" },
},
}
-- CodeArts 的 SDK-HMAC-SHA256 签到钩子(照参考 codearts-credits.ts 逆向)。
-- 绑在 PLATFORMS 表定义之后:claim_daily 读取 plat.claim_fn,checkin 表驱动
-- 不动签名请求(参考注释也点明这一点),所以用钩子生成签名后的请求形状。
do
local ca
for _, p in ipairs(PLATFORMS) do
if p.id == "codearts" then ca = p break end
end
if ca then
-- SDK-HMAC-SHA256 派生签名密钥链(照参考 canonical request 形状)。
ca.claim_fn = function(plat, tok, args)
local url = plat.checkin.url
local method = plat.checkin.method or "GET"
local host = url:match("^https?://([^/:]+)")
local path = url:match("^https?://[^/]+(/[^?]*)")
local ts = tostring(os.time and os.time() or 0)
local nonce = tostring(math.random(1e12))
local canonical = method .. "\n" .. (path or "/") .. "\n" .. ts .. "\n" .. nonce
local secret = tok or ""
local kDate = hmac_sha256_hex("SDK" .. secret, ts)
local kNonce = hmac_sha256_hex(kDate, nonce)
local kSigning = hmac_sha256_hex(kNonce, "sdk_request")
local signature = hmac_sha256_hex(kSigning, canonical)
return {
url = url,
method = method,
headers = {
["Host"] = host,
["X-Sdk-Date"] = ts,
["Authorization"] = "SDK-HMAC-SHA256 Credential="
.. (args and args.access_key or "") .. "/" .. ts .. "/" .. nonce
.. "/sdk_request, SignedHeaders=host, Signature=" .. signature,
},
}
end
end
end
-- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。
-- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。
local function declaredHosts()
local seen, out = {}, {}
for _, p in ipairs(PLATFORMS) do
for _, h in ipairs(p.hosts or {}) do
local hn = h:lower()
if not seen[hn] then seen[hn] = true out[#out+1] = hn end
end
end
-- 上游 base_url 的 host 也要(发对话/签到用)
for _, p in ipairs(PLATFORMS) do
local h = (p.base_url 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
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
table.sort(out)
return out
end
plugin.hosts = declaredHosts()
-- bodyOf 把 handler 的 req.body(**JSON 字符串**)解成 table。
--
-- 为什么需要它:handler 收到的是原始 body 字符串,不是解码后的表(内核不猜
-- 插件的载荷形状)。载荷坏掉时返回空表而不是报错 —— 调用方随后会因为缺少
-- 必要字段而得到明确的错误,比一个 JSON 解析异常更好读。
local function bodyOf(req)
if not req or not req.body or req.body == "" then return {} end
if type(req.body) == "table" then return req.body end
local ok, v = pcall(json.decode, req.body)
if not ok or type(v) ~= "table" then return {} end
return v
end
-- ============================================================================
-- device-poll 通用执行器(移植自参考实现 minimax-oauth.ts / cline-oauth.ts)
-- ============================================================================
-- 行为逐字对齐参考实现,测试用例也照它搬(见 channels_device_test.go):
--
-- 1. PKCE S256:verifier = 随机字节 base64url;challenge = SHA256(verifier)
-- 的 base64url(参考 createMinimaxPkce)。
-- 2. 轮询决策分支的**顺序**就是行为(参考 pollMinimaxDeviceToken):
-- HTTP 200 + status=pending → 继续等
-- HTTP 200 + status=slow_down → interval += 5s,继续等
-- HTTP 200 + status=denied /
-- access_denied → 会话 failed(用户拒绝)
-- HTTP 200 + status=expired /
-- expired_token → 会话 failed(码过期)
-- 非 200 + error=authorization_pending → 继续等(OAuth 标准形态)
-- 非 200 + error=slow_down → interval += 5s,继续等
-- HTTP 200(无上述状态) → 成功,提取 access_token
-- 其它 → 会话 failed(带 error 文本)
-- ⚠️ 「两种还在等」都要认:形一是 200+status 字段,形二是非 200+error 字段。
-- 3. 轮询由页面驱动(poll_login 每次调用推进一步),不在 Lua 里阻塞循环:
-- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。
-- b64url:内核的 base64_encode 是标准 base64;PKCE 要 base64url(无 +/= )。
local function b64url(s)
return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
end
-- hexToBin:把 hex 字符串还原成二进制字符串(PKCE challenge 输入用)。
local function hexToBin(h)
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
end
-- devicePkce 生成一次授权的 verifier/challenge 对(SHA256 S256)。
-- verifier 自造:熵拼接后哈希。不依赖文件后段才定义的 newToken —— Lua 词法
-- 作用域下那会是 nil 全局调用。熵用 os.time + math.random 两次哈希拼接。
local function devicePkce()
local v = sha256_hex(tostring(os.time and os.time() or 0) .. math.random(1e12))
.. sha256_hex(tostring(math.random(1e12)))
return v, b64url(hexToBin(sha256_hex(v)))
end
-- deviceOneStep 推进一次设备码轮询,返回 (done, payload)。
-- done=true payload={ ok=true, access_token=.., refresh_token=.., account=.. }
-- 或 { ok=false, reason="denied"|"expired", message=.. }
-- done=false payload=nil(还在等:pending / slow_down)
-- sess 携带 device 上下文(device_code/verifier/interval),被本函数原地更新
--(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 resp, err = __llmsproxy_http({
url = d.token_url,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
body = body,
})
if err then return false end -- 网络抖动不算失败:与参考一致(pending 语义)
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = {} end
-- 判据形态分两套(这正是 MiniMax 与 Cline 的核心差异,参考注释反复强调):
-- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。
-- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx);
-- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端);
-- access_denied / expired_token / invalid_grant 同组判失败。
-- 退避步长不硬编码:slow_down_step 缺省 5(MiniMax 语义)。
local workos = (d.judge == "workos")
local status = rec.status
local ecode = rec.error
local step = tonumber(d.slow_down_step) or 5
if workos then
if ecode == "authorization_pending" then return false end
if ecode == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if ecode == "access_denied" or ecode == "expired_token" or ecode == "invalid_grant" then
local msg = rec.error_description or (ecode == "access_denied" and "用户拒绝了授权" or "授权已过期")
return true, { ok = false, reason = (ecode == "access_denied" and "denied" or "expired"), message = msg }
end
if resp.status == 200 and rec.access_token and rec.refresh_token then
return true, { ok = true, access_token = rec.access_token, refresh_token = rec.refresh_token,
need_register = true, account = "cline" }
end
if resp.status ~= 200 and next(rec) ~= nil then
return true, { ok = false, reason = "error",
message = "WorkOS token 轮询失败(HTTP " .. tostring(resp.status) .. ")"
.. (rec.error_description and (" - " .. rec.error_description) or "") }
end
return false -- 2xx 但既无 error 又无 token:等下一拍(避免误报死循环)
end
-- MiniMax 形态(原样保留,已由决策表测试钉死)
if resp.status == 200 and status == "pending" then return false end
if resp.status == 200 and status == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if resp.status == 200 and (status == "denied" or status == "access_denied") then
return true, { ok = false, reason = "denied", message = "用户拒绝了授权" }
end
if resp.status == 200 and (status == "expired" or status == "expired_token") then
return true, { ok = false, reason = "expired", message = "设备码已过期,请重新发起登录" }
end
if ecode == "authorization_pending" then return false end
if ecode == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if resp.status == 200 and rec.access_token then
return true, { ok = true, access_token = rec.access_token,
refresh_token = rec.refresh_token, account = rec.nickname or rec.sub or "" }
end
return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) }
end
-- deviceRegister:设备码成功后的第二跳(Cline:WorkOS token → Cline token)。
-- 参考注释强调:两步是必要的(浏览器 transient activation 约 5 秒,阻塞式
-- 等授权会让 URL 过期)。返回 (access_token, account, nil) 或 (nil, nil, err)。
local function deviceRegister(plat, payload)
local r = plat.register
if not r or not r.url then return payload.access_token, payload.account, nil end
local body = (r.body or "")
:gsub("{access_token}", payload.access_token or "")
:gsub("{refresh_token}", payload.refresh_token or "")
local resp, err = __llmsproxy_http({
url = r.url,
method = r.method or "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
body = body,
})
if err then return nil, nil, "token 注册失败: " .. err end
if resp.status ~= 200 then
return nil, nil, "token 注册失败(HTTP " .. tostring(resp.status) .. ")"
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, nil, "token 注册响应无法解析"
end
-- 响应套 {success, data} 信封(参考 parseClineTokenPayload)
local data = rec.data or rec
local access = data.accessToken or data.access_token or data.Token or data.token
if not access or access == "" then
return nil, nil, "token 注册响应缺少 accessToken"
end
local acct = "cline"
if type(data.userInfo) == "table" and (data.userInfo.email or data.userInfo.clineUserId) then
acct = tostring(data.userInfo.email or data.userInfo.clineUserId)
end
return access, acct, nil
end
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)
local function platformById(id)
for _, p in ipairs(PLATFORMS) do
if p.id == id then return p end
end
return nil
end
-- ============================================================================
-- 状态
-- ============================================================================
plugin.state = plugin.state or {}
plugin.state.accounts = plugin.state.accounts or {}
plugin.state.log = plugin.state.log or {}
local function logEvent(msg)
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
end
-- accountOf 取某平台的账号记录(凭据本身在保险库,不在 state 里)
local function accountOf(id)
return plugin.state.accounts[id]
end
-- ============================================================================
-- 登录
-- ============================================================================
-- plugin.start_login(platform_id) 由设置页调用:创建一个登录会话。
--
-- 返回 {url=…, session=…} 给页面显示"请在自己的浏览器打开这个地址"。
-- 登录完成后用户浏览器会回到本插件(callback 流程)或本插件轮询到结果
-- ============================================================================
-- 登录会话(插件自持)与服务端浏览器栈
-- ============================================================================
-- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的
-- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account,
-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。
local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理
local function newToken()
-- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex)
return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels"))
end
local function sessions()
if not plugin.state.sessions then plugin.state.sessions = {} end
return plugin.state.sessions
end
-- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做)
local function sweepSessions()
local now = os.time and os.time() or 0
for id, s in pairs(sessions()) do
if s.expires_at and s.expires_at < now then
sessions()[id] = nil
end
end
end
-- ---- 服务端浏览器栈(VNC 兜底用)----
-- 每个会话一组进程:Xvfb + chromium + x11vnc + websockify。pid 记在
-- plugin.state.browser.pids,插件卸载或会话结束时 kill。
-- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成
-- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。
local function browserStack()
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
return plugin.state.browser
end
local function reapBrowser()
local b = browserStack()
for _, pid in ipairs(b.pids or {}) do
os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null")
end
b.pids = {}
b.session = nil
end
-- spawnBrowser 拉起一组服务端浏览器。返回 view_url(给 iframe 内嵌)或 nil, err。
-- start_url 是浏览器要打开的登录页;redirect_host 是拼 redirect_uri 用的主机名
-- (浏览器在服务端,127.0.0.1 就是服务端自己的回环 —— 回调直接命中网关)。
local function spawnBrowser(start_url, callback_path)
local b = browserStack()
if b.session then return nil, "已有浏览器会话在跑,请先停止" end
b.seq = b.seq + 1
local disp = 90 + (b.seq % 10)
local vport = 59100 + (b.seq % 100)
local wport = 60100 + (b.seq % 100)
local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1"
-- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次
local pass = sha256_hex(newToken()):sub(1, 16)
-- 端口占位换成网关回环(浏览器在服务端,回调直接命中网关挂载点)
local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+", "redirect_uri=127.0.0.1:8081" .. callback_path)
local cmds = {
("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp & echo $!"):format(disp),
("chromium --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d --no-first-run --window-size=1280,800 --display=:%d '%s' & echo $!"):format(b.seq, disp, url),
("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet & echo $!"):format(disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass),
("websockify --web /usr/share/novnc %s:%d 127.0.0.1:%d & echo $!"):format(bind, wport, vport),
}
local pids = {}
for _, c in ipairs(cmds) do
local h = io.popen(c)
local pid = h:read("*l")
h:close()
if not pid or pid == "" then
reapBrowser()
return nil, "进程启动失败: " .. c:match("^(%S+)") .. "(缺依赖?运行 deploy 脚本一键安装)"
end
pids[#pids+1] = pid
end
-- 给 X/chromium 一点起进程时间;不等待就绪探针(保持插件逻辑简单,失败可重试)
os.execute("sleep 2")
b.pids = pids
b.session = { display = disp, vnc = vport, ws = wport, pass = pass }
-- view_url 指向 websockify 伺服的 noVNC;同机直接 127.0.0.1,远程用 settings.vnc_host 覆盖
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
return {
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(host, wport, host, wport, pass),
password = pass,
}
end
-- ============================================================================
-- 登录流程:优先级阶梯(① 本机浏览器 → ② 自动化 → ③ VNC 兜底)
-- ============================================================================
-- start_login 按优先级阶梯响应:
-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在
-- 用户自己的机器上,插件轮询/收回调拿凭据)
-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调),
-- 无需用户之外的动作
-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、
-- 回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。
function plugin.start_login(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat then
return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } }
end
sweepSessions()
local now = os.time and os.time() or 0
local sess = {
id = newToken():sub(1, 24),
platform = plat.id,
flow = plat.flow,
url = plat.login_url,
nonce = newToken():sub(1, 32),
state = "pending",
created_at = now,
expires_at = now + SESSION_TTL,
}
sessions()[sess.id] = sess
logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")")
-- 响应里只带一次 nonce(回调校验要用),存侧不回显
local out = {
session = { id = sess.id, platform = sess.platform, flow = sess.flow,
state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at },
platform = plat.id,
}
if plat.flow == "manual" then
-- 无法自动化的登录(WASM/captcha/扫码):给明确说明而不是假装成功。
sess.state = "failed"
sess.message = plat.manual_hint or "该平台不支持自动登录"
out.manual_hint = sess.message
out.url = plat.login_url
logEvent("手动流程 " .. plat.id)
return { status = 200, json = out }
elseif plat.flow == "state-poll" then
-- Buddy:先拿 state + authUrl。页面给 authUrl,轮询 token 每推进一步。
local resp, err = __llmsproxy_http({
url = plat.state.state_url,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
body = "{\"platform\":\"ide\"}",
})
if err or resp.status ~= 200 then
sess.state = "failed"
out.error = "auth/state 失败: " .. (err or ("HTTP " .. tostring(resp.status)))
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or type(rec.data) ~= "table"
or not rec.data.state or rec.data.state == "" then
sess.state = "failed"
out.error = "auth/state 响应缺少 state 字段"
return { status = 200, json = out }
end
sess.state_token = rec.data.state
out.url = rec.data.authUrl or plat.login_url
logEvent("state 已申请 " .. plat.id)
elseif plat.flow == "vnc" then
-- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。
local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/http/channels/callback")
if berr then
out.browser_error = berr
out.hint = "服务端浏览器不可用;先装依赖(见插件一键初始化),或改用其他登录流程"
logEvent("服务端浏览器启动失败: " .. berr)
else
out.view_url = bs.view_url
logEvent("服务端浏览器已就绪 " .. plat.id)
end
elseif plat.flow == "device-poll" then
-- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。
-- 行为对齐参考 startMinimaxDeviceAuthorization:PKCE S256。
local verifier, challenge = devicePkce()
local body = (plat.device.code_body:gsub("{challenge}", challenge))
local resp, err = __llmsproxy_http({
url = plat.device.code_url,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
body = body,
})
if err then
sess.state = "failed"
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
sess.state = "failed"
out.error = "设备码响应无法解析(HTTP " .. tostring(resp.status) .. ")"
return { status = 200, json = out }
end
sess.device_code = rec.device_code
sess.device_verifier = verifier
sess.device_challenge = challenge
sess.device_interval = tonumber(rec.interval) or plat.device.interval or 5
sess.user_code = rec.user_code
-- 授权页 URL:优先用 verification_uri_complete(免手输 user_code)
out.url = rec.verification_uri_complete or rec.verification_uri or rec.verification_url or plat.login_url
out.session.user_code = rec.user_code
out.session.interval = sess.device_interval
logEvent("设备码已申请 " .. plat.id .. " user_code=" .. tostring(rec.user_code))
else
-- ①②:URL 交给用户自己的浏览器;callback 流程的 redirect_uri 指向本插件挂载点
if plat.flow == "callback" then
out.url = plat.login_url .. "&redirect_uri=" .. "127.0.0.1:8081/api/plugins/http/channels/callback&state=" .. sess.nonce
else
out.url = plat.login_url
end
end
return { status = 200, json = out }
end
-- plugin.poll_login(session_id) 由页面轮询:会话是否完成。
--
-- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep):
-- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死
-- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。
function plugin.poll_login(req)
local id = (req and req.query and req.query.id) or ""
sweepSessions()
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
local plat = platformById(s.platform)
local done, payload = deviceOneStep(plat, s)
if done and payload then
if payload.ok then
-- 设备码到 token 后,有的平台还要第二跳(Cline register)换最终凭据
local cred, acct, rerr = deviceRegister(plat, payload)
if rerr then
s.state = "failed"
s.message = rerr
else
local res = plugin.complete_login({ platform = s.platform, credential = cred, account = acct or payload.account })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct or payload.account
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
else
s.state = "failed"
s.message = payload.message
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)
local sp = plat and plat.state
local resp, err = __llmsproxy_http({
url = (sp.token_url or "") .. "?state=" .. tostring(s.state_token),
method = "GET",
headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" },
})
-- 网络错误:继续轮询不中断(对齐参考 loopGetToken 的 catch 继续)
if not err and resp then
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
local code = rec.code
local data = rec.data
if resp.status == 200 and data ~= nil and type(data) == "table" and (data.accessToken or data.access_token) then
-- token 就绪。再拿账号(失败不阻塞,回退默认名)
local tok = data.accessToken or data.access_token
local acct = "codebuddy"
local aresp, aerr = __llmsproxy_http({
url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token),
method = "GET",
headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. tok },
})
if not aerr and aresp and aresp.status == 200 then
local aok, arec = pcall(json.decode, aresp.body or "")
if aok and type(arec) == "table" and type(arec.data) == "table" then
acct = tostring(arec.data.nickName or arec.data.nickname or arec.data.username or acct)
end
end
local res = plugin.complete_login({ platform = s.platform, credential = tok, account = acct })
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
elseif code == sp.code_not_ready then
-- 11217 = 未就绪,继续轮询(对齐参考:业务码 11217 继续等)
elseif resp.status == 200 and data == nil then
-- data 为 null,继续轮询(参考明确此分支)
else
s.state = "failed"
s.message = "auth/token HTTP " .. tostring(resp.status) .. " code=" .. tostring(code)
end
end
end
end
return { status = 200, json = { state = s.state, account = s.account, message = s.message, interval = s.device_interval } }
end
-- plugin.complete_login(args) 由页面/回调调用:登录成功,存凭据并接入源。
--
-- 凭据只进保险库(0600、跨插件不可读),不进 state:state 会被
-- PUT /api/plugins/channels/state 整体替换,也会出现在 WebUI 里。
function plugin.complete_login(req)
local args = bodyOf(req)
if args.platform == nil then
-- 内部调用会直接传表
args = req
end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local token = args and args.credential
if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
if err then return { status = 400, json = { error = err } } end
plugin.state.accounts[plat.id] = {
account = args.account or "已登录",
added_at = os.time and os.time() or 0,
enabled = true,
}
-- 终结该平台未决会话
for id, sess in pairs(sessions()) do
if sess.platform == plat.id and sess.state == "pending" then sess.state = "completed" end
end
logEvent("已登录 " .. plat.name)
-- 接入为上游源 —— 这是本插件存在的意义
local r, aerr = __llmsproxy_kernel.call("add_source", {
source = {
name = "channels-" .. plat.id,
base_url = plat.base_url,
adapter = "openai",
api_key = token,
max_concurrent = 2,
models = (function()
local ms = {}
for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end
return ms
end)(),
},
})
if aerr then
-- 凭据已存下,但接入源失败。如实分开报告:把两者混成一个 ok=false 会让
-- 调用方以为登录失败从而重复登录。
logEvent("接入源失败 " .. plat.id .. ": " .. aerr)
return { status = 200, json = { ok = true, source_error = aerr } }
end
logEvent("已接入源 " .. r.name)
return { status = 200, json = { ok = true, source = r.name } }
end
-- ============================================================================
-- 额度领取
-- ============================================================================
-- plugin.claim_daily(platform_id) 领每日额度。设计为**可被定时任务调用**。
--
-- 只返回结果,不改 state 之外的东西:真正的额度体现在平台的响应里
-- (已用/剩余),本插件记录以便面板显示。
function plugin.claim_daily(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
if not plat.checkin or not plat.checkin.url then
return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } }
end
local tok = __llmsproxy_secrets.get("channels:" .. plat.id)
if not tok then return { status = 400, json = { error = "未登录" } } end
-- 平台可自定义领取请求形状;默认 Bearer + POST {}。
-- 参考实现的特殊形状(SDK-HMAC-SHA256 签名等)由该平台的 claim 钩子生成
-- (见 PLATFORMS 的 claim_fn),这里是通用路径。
local spec = {
url = plat.checkin.url,
method = plat.checkin.method or "POST",
headers = plat.checkin.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
body = plat.checkin.body or "{}",
}
if plat.claim_fn then
local custom, cerr = plat.claim_fn(plat, tok, args)
if cerr then return { status = 400, json = { error = cerr } } end
if custom then spec = custom end
end
local resp, err = __llmsproxy_http(spec)
if err then
logEvent("领取失败 " .. plat.id .. ": " .. err)
return { status = 400, json = { error = err } }
end
local acct = plugin.state.accounts[plat.id] or {}
acct.last_claim = os.time and os.time() or 0
acct.last_claim_status = resp.status
-- 平台可提供解析器把响应转成可读摘要(余额/获得额度)
if plat.claim_result_fn and resp.status == 200 then
local ok, summary = pcall(plat.claim_result_fn, resp.body)
if ok and summary then acct.last_claim_summary = summary end
end
plugin.state.accounts[plat.id] = acct
logEvent("领取返回 " .. tostring(resp.status))
return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body, summary = acct.last_claim_summary } }
end
-- ============================================================================
-- 定时任务:自动领取
-- ============================================================================
--
-- 声明了 schedule 的插件会得到内核 ticker 驱动(无浏览器也执行),
-- 且任务跑在本插件自己的 VM 上 —— 所以任务里读 state/保险库与钩子看到的是同一份。
plugin.schedule = {
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
}
function plugin.auto_claim()
for _, plat in ipairs(PLATFORMS) do
local acct = accountOf(plat.id)
if acct and acct.enabled then
plugin.claim_daily({ platform = plat.id })
end
end
end
-- ============================================================================
-- 对外 HTTP 端点(设置页调用)
-- ============================================================================
plugin.http = {
{ path = "channels/platforms", method = "GET", handler = "list_platforms",
roles = { "admin" }, description = "平台清单与账号状态" },
{ path = "channels/login", method = "POST", handler = "start_login",
roles = { "admin" }, description = "发起某平台的登录" },
{ path = "channels/login/status", method = "GET", handler = "poll_login",
roles = { "admin" }, description = "轮询登录会话状态" },
{ path = "channels/login/complete", method = "POST", handler = "complete_login",
roles = { "admin" }, description = "登录完成:存凭据并接入源" },
{ path = "channels/claim", method = "POST", handler = "claim_daily",
roles = { "admin" }, description = "手动领取某平台每日额度" },
{ path = "channels/status", method = "GET", handler = "status_page",
roles = { "admin" }, description = "接入状态与最近日志" },
-- OAuth 回调落点:平台重定向回来带 code。
{ path = "channels/callback", method = "GET", handler = "oauth_callback",
public = false, roles = { "admin" }, description = "OAuth 回调" },
}
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
-- base_url)。两个用途:
-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求)
-- 2. 测试把端点指到假上游,行为测试才能不打真厂商
-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段
-- 会破坏后续逻辑对形状的假设。
function plugin.set_endpoints(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local allowed = { base_url = true, login_url = true }
local changed = {}
for k in pairs(allowed) do
if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k 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
end
end
if type(args.register) == "table" and plat.register then
for _, k in ipairs({ "url" }) do
if args.register[k] then plat.register[k] = tostring(args.register[k]) changed[#changed+1] = "register." .. k end
end
end
if type(args.state) == "table" and plat.state then
for _, k in ipairs({ "state_url", "token_url", "account_url" }) do
if args.state[k] then plat.state[k] = tostring(args.state[k]) changed[#changed+1] = "state." .. k end
end
end
if type(args.checkin) == "table" and plat.checkin then
for _, k in ipairs({ "url" }) do
if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end
end
end
if type(args.callback) == "table" and plat.callback then
for _, k in ipairs({ "exchange_url" }) do
if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. k end
end
end
if #changed == 0 then return { status = 400, json = { error = "无可识别的端点字段" } } end
logEvent("端点已改写 " .. plat.id .. ": " .. table.concat(changed, ","))
return { status = 200, json = { ok = true, changed = changed } }
end
function plugin.list_platforms(req)
local out = {}
for _, p in ipairs(PLATFORMS) do
local acct = accountOf(p.id) or {}
local has = __llmsproxy_secrets.get("channels:" .. p.id)
out[#out + 1] = {
id = p.id, name = p.name, flow = p.flow,
logged_in = has ~= nil,
account = acct.account,
enabled = acct.enabled,
last_claim = acct.last_claim,
}
end
return { status = 200, json = { platforms = out } }
end
function plugin.status_page(req)
local r = __llmsproxy_kernel.call("status", {})
return { status = 200, json = {
accounts = plugin.state.accounts,
log = plugin.state.log,
kernel_ready = r and r.ready or false,
} }
end
-- oauth_callback 处理平台重定向:校验 nonce,然后按平台的 callback 模式解析。
--
-- nonce 校验是必需的:没有它,别人构造一个回调就能把**自己的**凭据塞进
-- 这个网关的登录流程。
--
-- callback 模式(platform.callback.mode)决定如何从回调 query 解出凭据:
-- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。
-- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现
-- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。
function plugin.oauth_callback(req)
local nonce = req.query and req.query.state
if not nonce or nonce == "" then
return { status = 400, body = "missing state" }
end
sweepSessions()
local s = nil
for _, sess in pairs(sessions()) do
if sess.nonce == nonce then s = sess break end
end
if not s then
return { status = 400, body = "unknown or expired login session" }
end
if s.flow ~= "callback" then
return { status = 400, body = "session is not a callback flow" }
end
local plat = platformById(s.platform)
local cb = plat and plat.callback or {}
local mode = cb.mode or "code"
local credential, account, cerr
if mode == "trae" then
credential, account, cerr = traeCallback(plat, req.query)
elseif mode == "code" and cb.token_url then
-- 标准 OAuth code → token 交换(Gemini / LobsterAI)。凭据是交换回的 access_token。
local code = req.query.code
if not code or code == "" then
cerr = "missing code"
else
local body = (cb.token_body or "")
:gsub("{code}", code)
:gsub("{verifier}", s.device_verifier or "")
local resp, err = __llmsproxy_http({
url = cb.token_url,
method = "POST",
headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Accept"] = "application/json" },
body = body,
})
if err then
cerr = "token 交换失败: " .. err
elseif resp.status ~= 200 then
cerr = "token 交换失败(HTTP " .. tostring(resp.status) .. ")"
else
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or not rec.access_token then
cerr = "token 交换响应缺少 access_token"
else
credential = rec.access_token
account = "oauth"
end
end
end
else
credential = req.query.code
account = "oauth"
if not credential or credential == "" then cerr = "missing code" end
end
if cerr then
s.state = "failed"
s.message = cerr
return { status = 400, body = cerr }
end
local res = plugin.complete_login({ platform = s.platform, credential = credential, account = account })
if res.json and res.json.error then
return { status = 400, body = res.json.error }
end
s.state = "completed"
s.account = account
return { status = 200, body = "ok" }
end
-- traeCallback 解析 TRAE 的 token 直传回调并(必要时)走 ExchangeToken。
-- 返回 (credential, account, nil) 或 (nil, nil, err)。
--
-- 行为逐行对齐参考实现 trae-oauth.ts:
-- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新
-- access(并轮换 refreshToken);凭据 = 新 access。
-- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。
-- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。
-- 都没有 → 回调无效。
local function jsonField(raw, key)
if not raw or raw == "" then return "" end
local ok, v = pcall(json.decode, raw)
if not ok or type(v) ~= "table" then return "" end
return v[key] or ""
end
function traeCallback(plat, q)
q = q or {}
local userJwt = q.userJwt or ""
local refreshToken = q.refreshToken
if not refreshToken or refreshToken == "" then
refreshToken = jsonField(userJwt, "RefreshToken")
end
local jwtToken = jsonField(userJwt, "Token")
local account = jsonField(q.userInfo or "", "ScreenName")
if account == "" then account = "trae" end
-- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。
local authCode = q.code or q.authCode or jsonField(q.authCodeInfo or "", "code")
if (not refreshToken or refreshToken == "") and (not jwtToken or jwtToken == "") then
if authCode and authCode ~= "" then
return nil, nil, "上游返回了 PKCE 授权码(code/authCodeInfo),本实现暂不支持该流程"
end
return nil, nil, "回调未携带 refreshToken / userJwt.Token / code"
end
-- 分支 1:有 refreshToken → ExchangeToken 换新 access(轮换 refresh)
if refreshToken and refreshToken ~= "" and plat.callback.exchange_url then
local body = (plat.callback.exchange_body
:gsub("{client_id}", plat.callback.client_id or "")
:gsub("{refresh_token}", refreshToken))
local resp, err = __llmsproxy_http({
url = plat.callback.exchange_url,
method = "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
body = body,
})
if err then return nil, nil, "TRAE ExchangeToken 失败: " .. err end
if resp.status ~= 200 then
return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status)
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, nil, "TRAE ExchangeToken 响应无法解析"
end
local access = rec.Token or rec.token or rec.AccessToken or rec.access_token
if not access or access == "" then
return nil, nil, "TRAE ExchangeToken 响应缺少 Token"
end
return access, account, nil
end
-- 分支 2:无 refreshToken,用 userJwt.Token 兜底
return jwtToken, account, nil
end
-- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的,
-- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。
plugin.on_unload = function()
reapBrowser()
end
-- ============================================================================
-- UI:独立标签页(登录 / 认证 / 配置)
-- ============================================================================
plugin.ui = {
page = {
page_id = "channels",
title = "账号接入",
icon = "🔌",
order = 40,
mount = [[
<div id="channels-page">
<div class="card">
<h2>平台接入</h2>
<p class="muted">登录在你自己的浏览器完成;凭据加密保存在服务端,凭据永不回显。</p>
<div id="ch-list" class="tbl-wrap"></div>
</div>
<div class="card" id="ch-login-box" style="display:none">
<h2 id="ch-login-title">登录</h2>
<div id="ch-login-body"></div>
<!-- 服务端浏览器内嵌区(仅 VNC 兜底流程用;本机浏览器流程此区隐藏)。 -->
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
<p class="muted">请在下面的窗口里完成登录,完成后本页会自动继续。</p>
<iframe id="ch-vnc" style="width:100%;height:640px;border:1px solid var(--line);border-radius:8px"
allow="clipboard-read; clipboard-write"></iframe>
</div>
</div>
<div class="card">
<h2>最近日志</h2>
<ul id="ch-log" style="font-size:12px"></ul>
</div>
</div>
<script>
(function () {
const q = (s) => document.querySelector(s);
const esc = (v) => String(v == null ? "" : v).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
async function call(path, method, body) {
const r = await fetch("/api/plugins/http/" + path, {
method: method || "GET",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
});
return await r.json().catch(() => ({}));
}
let pollTimer = null;
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
async function refresh() {
const j = await call("channels/platforms");
const rows = (j.platforms || []).map((p) => {
const st = p.logged_in ? '<span class="tag tag-green">已登录</span>' : '<span class="tag">未登录</span>';
const done = p.logged_in
? '<button class="small" onclick="chClaim(\'' + esc(p.id) + '\')">领取额度</button>'
: '<button class="small" onclick="chLogin(\'' + esc(p.id) + '\')">登录</button>';
return "<tr><td><b>" + esc(p.name) + "</b><div class='muted'>" + esc(p.flow) + "</div></td>" +
"<td>" + st + "</td><td>" + esc(p.account || "") + "</td><td>" + done + "</td></tr>";
}).join("");
q("#ch-list").innerHTML = "<table><tr><th>平台</th><th>状态</th><th>账号</th><th></th></tr>" +
(rows || "<tr><td colspan=4 class='empty'>无平台</td></tr>") + "</table>";
const sj = await call("channels/status");
q("#ch-log").innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
}
window.chLogin = async function (id) {
const j = await call("channels/login", "POST", { platform: id });
if (j.error) { alert(j.error); return; }
const box = q("#ch-login-box"); box.style.display = "block";
q("#ch-login-title").textContent = "登录:" + id;
const vnc = q("#ch-vnc-wrap");
if (j.view_url) {
// 服务端浏览器:内嵌在本页签里
q("#ch-login-body").innerHTML =
"<p class='muted' id='ch-login-state'>请在下方窗口完成登录…</p>";
vnc.style.display = "block";
q("#ch-vnc").src = j.view_url;
} else if (j.manual_hint) {
// 无法自动化的登录(WASM/captcha/扫码):给说明 + 凭据粘贴框。
// 后端 start_login 直接把会话标 failed,这里不再轮询。
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p class='muted'>" + esc(j.manual_hint) + "</p>" +
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='把凭据粘贴到这里'></textarea></p>" +
"<p><button class='small' onclick='chComplete(\"" + esc(id) + "\")'>提交凭据</button></p>" +
"<p class='muted' id='ch-login-state'></p>";
} else if (j.session && j.session.flow === "paste") {
// 直接粘贴 API key/token,无登录流(OpenCode 等)。
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p>把该平台的 API key / token 粘贴到这里:</p>" +
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='API key / token'></textarea></p>" +
"<p><button class='small' onclick='chComplete(\"" + esc(id) + "\")'>提交凭据</button></p>" +
"<p class='muted' id='ch-login-state'></p>";
} else {
// 用户自己的浏览器足够(link-poll / callback / device-poll)
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p>请在你自己的浏览器打开下面的地址完成登录:</p>" +
"<p><a href='" + esc(j.url) + "' target='_blank' rel='noopener'>" + esc(j.url) + "</a></p>" +
(j.session && j.session.user_code ? "<p>设备码:<b>" + esc(j.session.user_code) + "</b></p>" : "") +
"<p class='muted' id='ch-login-state'>等待完成…</p>";
}
if (!(j.manual_hint || (j.session && j.session.flow === "paste"))) {
stopPoll();
pollTimer = setInterval(async () => {
const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id));
const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state;
if (st.state === "completed" || st.state === "failed" || st.state === "expired") {
stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none";
box.style.display = "none"; refresh();
}
}, 2000);
}
};
// chComplete:paste / manual 流的凭据提交。凭据只经 complete_login 落保险库,
// 不经 state、不回显。失败时给可读错误而不刷新页面(用户可重试粘贴)。
window.chComplete = async function (id) {
const cred = (q("#ch-cred") && q("#ch-cred").value || "").trim();
if (!cred) { alert("凭据为空"); return; }
const el = q("#ch-login-state"); if (el) el.textContent = "提交中…";
const j = await call("channels/login/complete", "POST", { platform: id, credential: cred });
if (j.error) { if (el) el.textContent = "失败:" + j.error; return; }
if (j.source_error) { if (el) el.textContent = "凭据已存,但接入源失败:" + j.source_error; refresh(); return; }
q("#ch-login-box").style.display = "none";
q("#ch-cred").value = "";
refresh();
};
window.chClaim = async function (id) {
const j = await call("channels/claim", "POST", { platform: id });
alert(j.error ? ("失败: " + j.error) : ("领取返回 " + j.status));
refresh();
};
refresh();
})();
</script>
]],
},
}
return plugin