mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户指出「为什么生产插件会有实例平台?」—— 确实是设计错误。 ## ① 夹具内联进生产源码(根因) `PLATFORMS` 里内联了 4 个测试夹具: example / googleish / loopbackish / cookieish 后果:生产 `/api/plugins/http/channels/platforms` 返回 **16 个平台,其中 4 个是 夹具**,运维在 UI 上真的能选到。夹具属于测试,不属于发行物。 修法: - 生产 `PLATFORMS` 只留 12 个真实厂商。 - 新增 `plugin.state.injected_platforms` + `allPlatforms()`;`platformById` 先查内建再查注入表。 - 新增端点 `channels/inject_platforms`(roles=admin):运行时整表替换注入, 边界校验 —— 不得覆盖内建平台(注入不能劫持真实厂商)、拒绝重复 id、 拒绝无 id、限 32 条;空数组 = 清空。 - 8 处运行时遍历点(list_platforms/provider_status/backup/auto_checkin/ gateway 开关等)改用 `allPlatforms()`;4 处**加载期**遍历(HMAC 钩子绑定、 declaredHosts)保持 `PLATFORMS` —— 它们在 plugin.state 建立之前、注入尚未 发生,改了无意义。 - 测试改为经 `injectTestPlatforms` 注入夹具(channels_plugin_test / channels_stress_test);e2e 本就用真实平台(minimax/cline/opencode/qoder), 无需改动。 ## ② callback 路径的 account_id 漏网 UI 流程 = account/add 建占位账号 → login 带 account_id → 完成时回填。 `oauth_callback`(trae/gemini/lobsterai 的 callback 流)调 complete_login 时 **没传 account_id** —— 与 buddy poll_login 同一缺陷,此前只修了 device-poll / state-poll 两条分支,这条是漏网:凭据落到与占位账号无关的 `channels:<plat>`, 池账号永远 has_credential=false。 ## 判据 - TestInjectPlatformsRejectsBadInput:注入生效 + 三条拒绝规则 + 空数组清空 - TestTraeCallbackPoolPath:callback 池路径凭据落 `channels:trae:<aid>` 且 account_list 报 has_credential=true - 变异:callback 去掉 account_id → TestTraeCallbackPoolPath 立即 FAIL(已实测) go test ./internal/core/ 全绿(119s)。
3091 lines
147 KiB
Lua
3091 lines
147 KiB
Lua
-- 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", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim" }
|
||
|
||
|
||
|
||
-- ============================================================================
|
||
-- 平台表
|
||
-- ============================================================================
|
||
--
|
||
-- 每个平台声明登录方式与各端点。字段:
|
||
-- 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 的假平台、示范各种 flow 的
|
||
-- 占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接
|
||
-- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。
|
||
-- 夹具由测试经 channels/inject_platforms 端点运行时注入。
|
||
|
||
-- 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 = "{}",
|
||
},
|
||
-- 签到状态(对齐参考:POST /checkin_credits/status → {checked_in, credits, enable})
|
||
checkin_status = {
|
||
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/status",
|
||
method = "POST", body = "{}",
|
||
status_fn = function(rec)
|
||
return {
|
||
active = rec.enable == true,
|
||
todayCheckedIn = rec.checked_in == true,
|
||
dailyCredit = tonumber(rec.credits) or 0,
|
||
}
|
||
end,
|
||
},
|
||
-- 余额(对齐参考:POST /trae/api/v2/pay/ide_user_ent_usage → remain = Σ(credits_limit - credits_amount))
|
||
balance = {
|
||
url = "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage",
|
||
method = "POST", body = '{"require_usage":true,"req_source":2}',
|
||
total_fn = function(rec)
|
||
local sum = 0
|
||
local list = rec.user_entitlement_pack_list or rec.userEntitlementPackList or {}
|
||
if type(list) ~= "table" then return 0 end
|
||
for _, p in ipairs(list) do
|
||
local lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0
|
||
local amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0
|
||
sum = sum + math.max(0, lim - amt)
|
||
end
|
||
return sum
|
||
end,
|
||
},
|
||
-- 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 = "{}",
|
||
},
|
||
-- 签到状态预检(对齐参考 minimax-credits.ts,面板显示今日是否已签到)
|
||
checkin_status = {
|
||
url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/status",
|
||
method = "GET",
|
||
},
|
||
-- 余额(对齐参考:GET /credit/details,total = Σ details[].remaining_amount)
|
||
balance = {
|
||
url = "https://agent.minimax.cn/minimax-cloud/api/v1/credit/details",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
-- ⚠️ total_count 是记录条数不是余额(参考注释踩过的坑)。余额=Σ remaining_amount。
|
||
if type(rec) ~= "table" or type(rec.details) ~= "table" then return 0 end
|
||
local sum = 0
|
||
for _, d in ipairs(rec.details) do
|
||
sum = sum + (tonumber(d.remaining_amount) or 0)
|
||
end
|
||
return sum
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- 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 端点 —— 无每日领取。
|
||
-- 余额(对齐参考 cline-quota.ts:GET usage-limits,单位 dollars,show percentUsed)
|
||
credit_unit = "dollars",
|
||
balance = {
|
||
url = "https://api.cline.bot/api/v1/users/me/plan/usage-limits",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
local limits = d.limits or {}
|
||
if type(limits) ~= "table" then return 0 end
|
||
-- 取剩余百分比最小的那个限额的 (100-percentUsed);没有就 0
|
||
local minRemain
|
||
for _, l in ipairs(limits) do
|
||
local used = tonumber(l.percentUsed or l.percent_used) or 0
|
||
local rem = 100 - used
|
||
if not minRemain or rem < minRemain then minRemain = rem end
|
||
end
|
||
return minRemain or 0
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- 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",
|
||
-- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。
|
||
-- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。
|
||
-- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域,
|
||
-- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS);
|
||
-- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。
|
||
headers_fn = function(accessToken, cred)
|
||
local h = {
|
||
["Authorization"] = "Bearer " .. accessToken,
|
||
["Accept"] = "application/json",
|
||
["Content-Type"] = "application/json",
|
||
["X-Domain"] = "copilot.tencent.com",
|
||
["X-Product"] = "SaaS",
|
||
["X-Product-Code"] = "codebuddy",
|
||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||
}
|
||
local uid = cred and (cred.user_id or cred.uid)
|
||
if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end
|
||
local eid = cred and cred.enterprise_id
|
||
if type(eid) == "string" and eid ~= "" then
|
||
h["X-Enterprise-Id"] = eid
|
||
h["X-Tenant-Id"] = eid
|
||
end
|
||
return h
|
||
end,
|
||
models = {
|
||
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
|
||
"glm-5.3", "glm-5.3-flash", "glm-5.2",
|
||
},
|
||
-- 签到状态(对齐参考 credits.ts:必须用 checkin-activity-status,不能用
|
||
-- checkin-status —— 后者返回占位数据 active:false,是个坑)
|
||
checkin_status = {
|
||
url = "https://copilot.tencent.com/v2/billing/meter/checkin-activity-status",
|
||
method = "POST", body = "{}",
|
||
status_fn = function(rec)
|
||
local d = rec.data or rec
|
||
return {
|
||
active = d.active == true,
|
||
todayCheckedIn = d.todayCheckedIn == true or d.today_checked_in == true,
|
||
streakDays = tonumber(d.streakDays or d.streak_days) or 0,
|
||
todayCredit = tonumber(d.todayCredit or d.today_credit) or 0,
|
||
}
|
||
end,
|
||
},
|
||
-- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource)
|
||
--
|
||
-- ⚠️ 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层
|
||
-- data 不同),这是本接口最容易解析错的地方。
|
||
--
|
||
-- ⚠️ 刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一
|
||
-- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[]
|
||
-- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回,
|
||
-- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。
|
||
balance = {
|
||
url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource",
|
||
method = "POST", body = "{}",
|
||
total_fn = function(rec)
|
||
local data = rec.data
|
||
if type(data) ~= "table" then return nil end
|
||
local resp = data.Response
|
||
if type(resp) ~= "table" then return nil end
|
||
local inner = resp.Data
|
||
if type(inner) ~= "table" then return nil end
|
||
local accounts = inner.Accounts
|
||
if type(accounts) ~= "table" then return nil end
|
||
local now = os.time and os.time() or 0
|
||
local sum = 0
|
||
for _, pkg in ipairs(accounts) do
|
||
if type(pkg) == "table" then
|
||
-- 失效判定:Status 显式已过期,或 ExpiredTime / DeductionEndTime 已过。
|
||
local active = true
|
||
local st = pkg.Status
|
||
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
|
||
local function past(t)
|
||
if type(t) ~= "string" or t == "" then return false end
|
||
local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)")
|
||
if Y then
|
||
local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
|
||
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false })
|
||
return ts and now >= ts
|
||
end
|
||
return false
|
||
end
|
||
if past(pkg.ExpiredTime) then active = false end
|
||
local ded = tonumber(pkg.DeductionEndTime)
|
||
if ded and ded > 0 then
|
||
-- 毫秒时间戳
|
||
if now >= math.floor(ded / 1000) then active = false end
|
||
end
|
||
if active then
|
||
sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0)
|
||
end
|
||
end
|
||
end
|
||
-- 两位小数规整(服务端精确值带浮点噪声,如 55.67000031)
|
||
return math.floor(sum * 100 + 0.5) / 100
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- 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" },
|
||
-- 余额(对齐参考:GET /api/user/profile-summary)
|
||
balance = {
|
||
url = "https://lobsterai-server.youdao.com/api/user/profile-summary",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
return tonumber(d.availableCredits or d.available_credits or d.credits or d.balance) or 0
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- 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" },
|
||
-- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining
|
||
-- + addOnQuota.remaining,走 openApiBase)
|
||
balance = {
|
||
url = "https://openapi.qoder.sh/sash/api/v2/me/usage",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
local u = (d.userQuota or {}).remaining or 0
|
||
local a = (d.addOnQuota or {}).remaining or 0
|
||
return (tonumber(u) or 0) + (tonumber(a) or 0)
|
||
end,
|
||
},
|
||
},
|
||
|
||
-- 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" },
|
||
-- 余额(对齐参考 raccoon-credits.ts:GET {apiBase}/points/v1/balance → data.available_points)
|
||
balance = {
|
||
url = "https://raccoon.sensetime.com/points/v1/balance",
|
||
method = "GET",
|
||
total_fn = function(rec)
|
||
local d = rec.data or rec
|
||
return tonumber(d.available_points or d.availablePoints) or 0
|
||
end,
|
||
},
|
||
-- 一次性新人奖励(对齐 onboarding:桌面登录奖励 3000 分)
|
||
onboarding = { reward_points = 3000 },
|
||
},
|
||
}
|
||
|
||
-- 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(调用方必须处理,而不是拿到空表)。
|
||
--
|
||
-- 先查内建目录,再查运行时注入表(plugin.state.injected_platforms)。注入表的存在
|
||
-- 是因为**测试夹具绝不能写成生产源码**:此前 example/googleish/loopbackish/
|
||
-- cookieish 四个夹具直接内联在 PLATFORMS 里,于是它们真的出现在生产 UI(16 个
|
||
-- 平台里有 4 个是夹具)供运维选择。夹具属于测试,不属于发行物 —— 正确形态是
|
||
-- 测试用自己的那几行注入(见 channels/test_platforms 端点),生产目录只留真实厂商。
|
||
local function platformById(id)
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == id then return p end
|
||
end
|
||
local injected = plugin.state and plugin.state.injected_platforms
|
||
if type(injected) == "table" then
|
||
for _, p in ipairs(injected) do
|
||
if type(p) == "table" and p.id == id then return p end
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- allPlatforms 返回内建目录 + 注入表(列表/遍历用)。
|
||
local function allPlatforms()
|
||
local out = {}
|
||
for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end
|
||
local injected = plugin.state and plugin.state.injected_platforms
|
||
if type(injected) == "table" then
|
||
for _, p in ipairs(injected) do
|
||
if type(p) == "table" and p.id then out[#out + 1] = p end
|
||
end
|
||
end
|
||
return out
|
||
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
|
||
|
||
-- ============================================================================
|
||
-- 账号池(对齐参考 account.* RPC 契约):一个平台可挂多个账号
|
||
-- ============================================================================
|
||
-- plugin.state.pool[provider] = { [accountId] = 条目 },条目字段对齐参考
|
||
-- AccountEntry:{ id, provider, nickname, enabled, credentialRef, refreshable,
|
||
-- createdAt, expiresAt }。凭据在保险库(credentialRef 即保险库键),不入 state。
|
||
--
|
||
-- 兼容:旧的单账号路径(state.accounts)保留 —— e2e 与既有端点仍走它。
|
||
local function poolTable()
|
||
if not plugin.state.pool then plugin.state.pool = {} end
|
||
return plugin.state.pool
|
||
end
|
||
|
||
local function poolAccounts(provider)
|
||
local t = poolTable()[provider]
|
||
if not t then t = {} poolTable()[provider] = t end
|
||
return t
|
||
end
|
||
|
||
local function poolList(provider)
|
||
local out = {}
|
||
for _, a in pairs(poolAccounts(provider)) do out[#out+1] = a end
|
||
table.sort(out, function(x, y)
|
||
local px, py = x.priority or 0, y.priority or 0
|
||
if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义)
|
||
return (x.createdAt or 0) < (y.createdAt or 0)
|
||
end)
|
||
return out
|
||
end
|
||
|
||
local function poolGet(provider, accountId)
|
||
return poolAccounts(provider)[accountId]
|
||
end
|
||
|
||
local function poolAdd(provider, entry)
|
||
poolAccounts(provider)[entry.id] = entry
|
||
end
|
||
|
||
local function poolRemove(provider, accountId)
|
||
poolAccounts(provider)[accountId] = nil
|
||
end
|
||
|
||
-- 凭据取值:保险库里存的可能是**裸 token**(历史/device-poll/paste 路径),
|
||
-- 也可能是**完整凭据 JSON**( buddy / workbuddy 等 state-poll 平台,对齐参考
|
||
-- buildCredential 的结构:access_token + refresh_token + expires_at + domain + ...)。
|
||
--
|
||
-- 本助手统一取出可用的 access token(及 refresh_token),使所有 HTTP 消费点
|
||
-- (Bearer 头、HMAC 签名)无需关心存的是哪种形态:
|
||
-- 裸 token → 原样返回
|
||
-- 完整凭据 JSON 字符串 → 取 .access_token
|
||
-- 已经是 table → 取 .access_token
|
||
-- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。
|
||
local function credentialOf(ref)
|
||
local raw = ref and __llmsproxy_secrets.get(ref)
|
||
if not raw or raw == "" then return nil, nil, nil end
|
||
-- 裸 token 快路:不含 '{' 的字符串直接当 token(绝大多数历史数据)
|
||
if type(raw) == "string" and raw:sub(1, 1) ~= "{" then return raw, nil, nil end
|
||
local ok, rec = pcall(json.decode, type(raw) == "string" and raw or "")
|
||
if ok and type(rec) == "table" then
|
||
local at = rec.access_token or rec.accessToken or rec.token
|
||
local rt = rec.refresh_token or rec.refreshToken
|
||
if at and at ~= "" then return at, rt, rec end
|
||
-- 是 JSON 但没有 access_token:回退原样(可能是别的形状的凭据)
|
||
return raw, rt, rec
|
||
end
|
||
return raw, nil, nil
|
||
end
|
||
|
||
-- credentialFull 取完整凭据(供需要 refresh_token/expires_at 的路径用)。
|
||
local function credentialFull(ref)
|
||
local _, _, full = credentialOf(ref)
|
||
return full
|
||
end
|
||
|
||
-- jwtClaim 从 JWT 的 payload 读一个声明(不验签,仅用于读取展示/续期元数据)。
|
||
-- 对齐参考 buddy.ts 的 jwtNickname / jwtExpiresAtMs / jwtSubject:
|
||
-- CodeBuddy 的 login/account 响应**不含昵称**,昵称只在 access_token 的声明里;
|
||
-- token 端又只返回 expiresIn(相对秒数)而不含 expiresAt。两处都得从 JWT 兜底。
|
||
-- 解析失败返回 nil(调用方自行处理)。
|
||
local function jwtClaim(token, name)
|
||
if type(token) ~= "string" or token == "" then return nil end
|
||
local payload = token:match("^[^%.]+%.([^%.]+)")
|
||
if not payload then return nil end
|
||
local bin, derr = base64_decode(payload) -- 内核新增:base64url 兼容
|
||
if not bin or derr then return nil end
|
||
local ok, rec = pcall(json.decode, bin)
|
||
if not ok or type(rec) ~= "table" then return nil end
|
||
return rec[name]
|
||
end
|
||
|
||
-- stripControl 去掉控制字符(含 CR/LF/Tab)并把连续空白折叠为单个空格。
|
||
-- 对齐参考 stripControlChars:CodeBuddy 的 scope 字段有时返回多行文本,
|
||
-- 原样落盘会在序列化时被当作多行标量,破坏后续 JSON 解析。
|
||
local function stripControl(v)
|
||
if type(v) ~= "string" then return v end
|
||
v = v:gsub("[%z\1-\31\127]+", " ")
|
||
v = v:gsub("%s%s+", " ")
|
||
return (v:gsub("^%s+", ""):gsub("%s+$", ""))
|
||
end
|
||
|
||
-- readStr 从响应表安全读字符串字段(兼容后端把数字返为数字)。
|
||
local function readStr(rec, key)
|
||
if type(rec) ~= "table" then return "" end
|
||
local v = rec[key]
|
||
if type(v) == "string" then return stripControl(v) end
|
||
if type(v) == "number" then return tostring(v) end
|
||
return ""
|
||
end
|
||
|
||
-- absExpiryMs 把绝对字段或相对秒数换算为毫秒时间戳字符串。
|
||
-- 对齐参考 absoluteExpiryMs:
|
||
-- ① 绝对字段(expiresAt/refreshExpiresAt)存在 → 归一化为毫秒字符串
|
||
-- ② 否则用相对秒数(expiresIn/refreshExpiresIn)+ access_token 的 JWT iat 兜底
|
||
-- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底)
|
||
-- 实测:CodeBuddy 的 /v2/plugin/auth/token 只返回 expiresIn,没有 expiresAt;
|
||
-- 不换算的话凭据的 expires_at 永远是空串,UI 一直显示"有效期未知"。
|
||
local function absExpiryMs(rec, absKey, relKey, accessToken)
|
||
local absolute = readStr(rec, absKey)
|
||
if absolute ~= "" then
|
||
if absolute:match("^%d+$") then
|
||
local n = tonumber(absolute)
|
||
if n then
|
||
return tostring(n > 1e12 and n or n * 1000)
|
||
end
|
||
end
|
||
return absolute
|
||
end
|
||
local rel = rec[relKey]
|
||
local relSec = nil
|
||
if type(rel) == "number" then relSec = rel
|
||
elseif type(rel) == "string" and rel:match("^%-?%d+%.?%d*$") then relSec = tonumber(rel) end
|
||
if relSec == nil then return "" end
|
||
-- 基准:access_token 的 iat(签发时刻)优先,缺失时用当前时刻。
|
||
local iat = jwtClaim(accessToken, "iat")
|
||
local baseMs = (type(iat) == "number" and iat * 1000) or (os.time and os.time() * 1000 or 0)
|
||
return tostring(baseMs + relSec * 1000)
|
||
end
|
||
|
||
-- buildCredential 把 token 响应 + account 响应组合为可持久化的完整凭据 JSON。
|
||
-- 逐字段对齐参考 buddy.ts 的 buildCredential / parseTokenData / parseAccountData:
|
||
-- access_token / refresh_token / expires_at / refresh_expires_at /
|
||
-- token_type / scope / domain / user_id / nickname / enterprise_id / account_type
|
||
-- 昵称回填顺序:account.nickname → JWT.nickname → JWT.preferred_username。
|
||
-- expires_at 同理:token.expiresAt 为空时由 absExpiryMs 用 expiresIn 或 JWT exp 兜底。
|
||
local function buildCredential(data, accountData)
|
||
local access = readStr(data, "accessToken")
|
||
if access == "" then access = readStr(data, "access_token") end
|
||
local refresh = readStr(data, "refreshToken")
|
||
if refresh == "" then refresh = readStr(data, "refresh_token") end
|
||
local tokenType = readStr(data, "tokenType")
|
||
if tokenType == "" then tokenType = "Bearer" end
|
||
local acct = type(accountData) == "table" and accountData or {}
|
||
local nickname = readStr(acct, "nickname")
|
||
if nickname == "" then nickname = readStr(acct, "nickName") end
|
||
if nickname == "" then
|
||
local jn = jwtClaim(access, "nickname")
|
||
if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "preferred_username") end
|
||
if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "name") end
|
||
if type(jn) == "string" then nickname = stripControl(jn) end
|
||
end
|
||
local uid = readStr(acct, "uid")
|
||
if uid == "" then
|
||
local sub = jwtClaim(access, "sub")
|
||
if type(sub) == "string" then uid = sub end
|
||
end
|
||
local acctType = readStr(acct, "type")
|
||
if acctType == "" then acctType = "personal" end
|
||
return {
|
||
access_token = access,
|
||
refresh_token = refresh,
|
||
expires_at = absExpiryMs(data, "expiresAt", "expiresIn", access),
|
||
refresh_expires_at = absExpiryMs(data, "refreshExpiresAt", "refreshExpiresIn", access),
|
||
token_type = tokenType,
|
||
scope = readStr(data, "scope"),
|
||
domain = readStr(data, "domain"),
|
||
user_id = uid,
|
||
nickname = nickname,
|
||
enterprise_id = readStr(acct, "enterpriseId"),
|
||
account_type = acctType,
|
||
}
|
||
end
|
||
|
||
-- credentialExpiresAtMs 从凭据表解析毫秒过期时刻(兼容毫秒/秒级时间戳/ISO)。
|
||
-- 对齐参考 credentialExpiresAtMs:expires_at 为空时回退解析 access_token 的 JWT exp。
|
||
local function credentialExpiresAtMs(cred)
|
||
if type(cred) ~= "table" then return nil end
|
||
local raw = cred.expires_at
|
||
if type(raw) == "string" and raw ~= "" then
|
||
if raw:match("^%d+$") then
|
||
local n = tonumber(raw)
|
||
if n then return n > 1e12 and n or n * 1000 end
|
||
end
|
||
-- ISO 8601:解析 "YYYY-MM-DDThh:mm:ss" 形态(无时区偏移时按 UTC)
|
||
local Y, Mo, D, H, Mi, S = raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)")
|
||
if Y then return os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
|
||
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) * 1000 end
|
||
end
|
||
local exp = jwtClaim(cred.access_token, "exp")
|
||
if type(exp) == "number" then return exp * 1000 end
|
||
return nil
|
||
end
|
||
|
||
-- credentialIsRefreshable 凭据是否携带可静默续期的 refresh_token。
|
||
local function credentialIsRefreshable(cred)
|
||
return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= ""
|
||
end
|
||
|
||
-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true }
|
||
local function disabledModelsOf(provider)
|
||
if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end
|
||
local m = plugin.state.modelDisabled[provider]
|
||
if not m then m = {} plugin.state.modelDisabled[provider] = m end
|
||
return m
|
||
end
|
||
|
||
-- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。
|
||
local CHANNEL_CREDITS = {
|
||
codearts = { claim = true },
|
||
buddy = { claim = true, status = true },
|
||
lobsterai = { claim = true },
|
||
qoder = { claim = true },
|
||
trae = { claim = true },
|
||
cline = { unit = "dollars" },
|
||
loomy = { claim = true },
|
||
raccoon = {},
|
||
minimax = { claim = true, status = true },
|
||
zcode = { claim = true, unit = "tokens" },
|
||
gemini = { unit = "quota", quota = true },
|
||
}
|
||
|
||
-- ---- 账号级 source 同步 ----
|
||
-- 每个账号一条上游源:channels-<provider>-<accountId>。模型开关即改写
|
||
-- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式
|
||
-- 先 remove 更诚实,避免旧模型残留)。
|
||
local function accountSourceName(provider, accountId)
|
||
return "channels-" .. provider .. "-" .. accountId
|
||
end
|
||
|
||
-- syncAccountSource 把某账号的上游源与(平台模型目录 - 黑名单)对齐。
|
||
-- 无凭据(占位账号)不动源。
|
||
local function syncAccountSource(provider, accountId)
|
||
local plat = platformById(provider)
|
||
local acct = poolGet(provider, accountId)
|
||
if not plat or not acct then return end
|
||
-- 凭据可能是裸 token 或完整凭据 JSON;上游源只吃 access token。
|
||
local tok = credentialOf(acct.credentialRef)
|
||
if not tok or tok == "" then return end
|
||
local name = accountSourceName(provider, accountId)
|
||
local models = {}
|
||
local disabled = disabledModelsOf(provider)
|
||
for _, m in ipairs(plat.models or {}) do
|
||
if not disabled[m] then
|
||
models[#models+1] = { id = m, kind = "chat", priority = 20 }
|
||
end
|
||
end
|
||
-- 重建:先 remove(容错:源可能不存在)再 add
|
||
__llmsproxy_kernel.call("remove_source", { name = name })
|
||
local r, aerr = __llmsproxy_kernel.call("add_source", {
|
||
source = {
|
||
name = name,
|
||
base_url = plat.base_url,
|
||
adapter = plat.adapter or "openai",
|
||
api_key = tok,
|
||
max_concurrent = 2,
|
||
models = models,
|
||
},
|
||
})
|
||
if aerr then logEvent("接入源失败 " .. name .. ": " .. aerr)
|
||
else logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") end
|
||
end
|
||
|
||
local function dropAccountSource(provider, accountId)
|
||
__llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) })
|
||
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
|
||
sess.account_id = args and args.account_id -- 账号池路径:登录完成回填这个池账号
|
||
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)。
|
||
--
|
||
-- ⚠️ **必须把 session 上的 account_id 传给 complete_login**(真实缺陷):
|
||
-- 页面流程是「先 account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
|
||
-- → login 带 account_id → 轮询」。若这里不带 account_id,complete_login 会走
|
||
-- 兼容旧路径,凭据落到 `channels:<plat>` 这个**与占位账号无关的键**,而账号池
|
||
-- 条目的 credentialRef 指向 `channels:<plat>:<aid>` —— 于是 account/list 报
|
||
-- has_credential=false、credits/balances 报「无凭据」,表现为「登录成功但无法
|
||
-- 获取账号信息」。对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义。
|
||
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
|
||
-- ⚠️ 必须带 account_id:页面流程先 account/add 建了占位账号,
|
||
-- 不带就会落到与占位账号无关的 `channels:<plat>` 键(见 poll_login 顶部注释)。
|
||
local res = plugin.complete_login({ platform = s.platform, credential = cred,
|
||
account = acct or payload.account, account_id = s.account_id })
|
||
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 access = data.accessToken or data.access_token
|
||
local accountData = nil
|
||
local aresp, aerr = __llmsproxy_http({
|
||
url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token),
|
||
method = "GET",
|
||
headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access },
|
||
})
|
||
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
|
||
accountData = arec.data
|
||
end
|
||
end
|
||
-- ① 组合完整凭据 JSON(对齐参考 buildCredential):带 refresh_token /
|
||
-- expires_at / domain / user_id / nickname,而非只存裸 access_token。
|
||
-- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默
|
||
-- 续期,且 expires_at 未知。
|
||
local credTable = buildCredential(data, accountData)
|
||
local credJson = json.encode(credTable)
|
||
local acct = credTable.nickname
|
||
if acct == nil or acct == "" then
|
||
acct = credTable.user_id
|
||
end
|
||
if acct == nil or acct == "" then acct = "codebuddy" end
|
||
-- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的
|
||
-- `channels:<plat>` 键,账号池查不到。
|
||
local res = plugin.complete_login({ platform = s.platform, credential = credJson,
|
||
account = acct, account_id = s.account_id,
|
||
expires_at = credTable.expires_at, refreshable = credentialIsRefreshable(credTable) })
|
||
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
|
||
|
||
-- 账号池路径:带 account_id 时写入池条目(对齐参考 account.create 的
|
||
-- 占位账号语义 —— 先有账号行,凭据随后填入)。
|
||
local aid = args.account_id
|
||
if aid and aid ~= "" then
|
||
local acct = poolGet(plat.id, aid)
|
||
if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end
|
||
local ref = acct.credentialRef
|
||
local _, serr = __llmsproxy_secrets.set(ref, token)
|
||
if serr then return { status = 400, json = { error = serr } } end
|
||
-- 从凭据自动补齐展示/续期字段(调用方漏传也不丢)。
|
||
-- 对齐参考 saveCredential:昵称取 credential.nickname,
|
||
-- expiresAt 由 credentialExpiresAtMs 推断(expires_at 或 JWT exp),
|
||
-- refreshable 看是否携带 refresh_token。
|
||
local credTbl = nil
|
||
if type(token) == "string" and token:sub(1, 1) == "{" then
|
||
local dok, drec = pcall(json.decode, token)
|
||
if dok and type(drec) == "table" then credTbl = drec end
|
||
end
|
||
local derivedNick = credTbl and credTbl.nickname
|
||
if derivedNick == nil or derivedNick == "" then derivedNick = credTbl and credTbl.user_id end
|
||
local derivedExp = credTbl and credentialExpiresAtMs(credTbl)
|
||
acct.nickname = args.account or derivedNick or acct.nickname or aid
|
||
acct.expiresAt = args.expires_at or (derivedExp and tostring(derivedExp)) or acct.expiresAt
|
||
if args.refreshable ~= nil then
|
||
acct.refreshable = args.refreshable and true or false
|
||
elseif credTbl ~= nil then
|
||
acct.refreshable = credentialIsRefreshable(credTbl)
|
||
else
|
||
acct.refreshable = acct.refreshable or false
|
||
end
|
||
poolAdd(plat.id, acct)
|
||
-- 终结该账号的未决会话
|
||
for id, sess in pairs(sessions()) do
|
||
if sess.platform == plat.id and sess.state == "pending"
|
||
and (sess.account_id == aid or sess.pool_account_id == aid) then
|
||
sess.state = "completed"
|
||
end
|
||
end
|
||
logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid)
|
||
syncAccountSource(plat.id, aid)
|
||
return { status = 200, json = { ok = true, account_id = aid, source = accountSourceName(plat.id, aid) } }
|
||
end
|
||
|
||
-- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。
|
||
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
|
||
if err then return { status = 400, json = { error = err } } end
|
||
-- 上游源只吃 access token:凭据可能是完整 JSON(buddy 等 state-poll 平台),
|
||
-- 必须经 credentialOf 取出 access_token,不能把整个 JSON 当 api_key 发上去。
|
||
local legacyTok = credentialOf("channels:" .. plat.id) or token
|
||
|
||
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 = legacyTok,
|
||
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
|
||
-- 账号池路径:claim 某个池账号;否则旧路径单凭据。
|
||
local tok, acctEntry
|
||
if args.account_id and args.account_id ~= "" then
|
||
acctEntry = poolGet(plat.id, args.account_id)
|
||
if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end
|
||
tok = credentialOf(acctEntry.credentialRef)
|
||
else
|
||
tok = credentialOf("channels:" .. plat.id)
|
||
end
|
||
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
|
||
if acctEntry then
|
||
acct = acctEntry
|
||
else
|
||
acct = plugin.state.accounts[plat.id] or {}
|
||
end
|
||
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
|
||
if acctEntry then
|
||
poolAdd(plat.id, acctEntry)
|
||
else
|
||
plugin.state.accounts[plat.id] = acct
|
||
end
|
||
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()
|
||
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
if plugin.state.settings.auto_checkin == false then return end
|
||
for _, plat in ipairs(allPlatforms()) do
|
||
local cap = CHANNEL_CREDITS[plat.id]
|
||
if not cap or not cap.claim then goto continue end
|
||
local legacy = accountOf(plat.id)
|
||
if legacy and legacy.enabled then
|
||
plugin.claim_daily({ platform = plat.id })
|
||
end
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
if a.enabled ~= false and credentialOf(a.credentialRef) then
|
||
plugin.claim_daily({ platform = plat.id, account_id = a.id })
|
||
end
|
||
end
|
||
::continue::
|
||
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 回调" },
|
||
-- 账号池 / 模型开关 / 供应商治理 / 备份(对齐参考 channel-pack RPC 面)
|
||
{ path = "channels/account/add", method = "POST", handler = "account_add",
|
||
roles = { "admin" }, description = "向平台添加账号(占位或带凭据)" },
|
||
{ path = "channels/account/list", method = "POST", handler = "account_list",
|
||
roles = { "admin" }, description = "列平台账号池" },
|
||
{ path = "channels/account/update", method = "POST", handler = "account_update",
|
||
roles = { "admin" }, description = "改账号昵称/启用态" },
|
||
{ path = "channels/account/delete", method = "POST", handler = "account_delete",
|
||
roles = { "admin" }, description = "删除账号(含凭据与上游源)" },
|
||
{ path = "channels/account/test", method = "POST", handler = "account_test",
|
||
roles = { "admin" }, description = "测账号可达" },
|
||
{ path = "channels/account/refresh", method = "POST", handler = "account_refresh",
|
||
roles = { "admin" }, description = "刷新账号(重同步源)" },
|
||
{ path = "channels/account/reorder", method = "POST", handler = "account_reorder",
|
||
roles = { "admin" }, description = "调账号优先级" },
|
||
{ path = "channels/model/list", method = "POST", handler = "model_list",
|
||
roles = { "admin" }, description = "列平台模型与开关态" },
|
||
{ path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled",
|
||
roles = { "admin" }, description = "开关单个模型" },
|
||
{ path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled",
|
||
roles = { "admin" }, description = "全开/全关平台模型" },
|
||
{ path = "channels/provider/status", method = "POST", handler = "provider_status",
|
||
roles = { "admin" }, description = "全平台状态(账号数/模型数/关闭态)" },
|
||
{ path = "channels/provider/set_enabled", method = "POST", handler = "provider_set_enabled",
|
||
roles = { "admin" }, description = "供应商整体开关" },
|
||
{ path = "channels/credits/claim_all", method = "POST", handler = "credits_claim_all",
|
||
roles = { "admin" }, description = "一键领取全平台每日额度" },
|
||
{ path = "channels/backup/export", method = "POST", handler = "backup_export",
|
||
roles = { "admin" }, description = "导出账号备份(含凭据)" },
|
||
{ path = "channels/backup/import", method = "POST", handler = "backup_import",
|
||
roles = { "admin" }, description = "导入账号备份" },
|
||
{ path = "channels/auto_checkin", method = "POST", handler = "auto_checkin",
|
||
roles = { "admin" }, description = "自动签到开关读写" },
|
||
{ path = "channels/credits/status", method = "POST", handler = "credits_status",
|
||
roles = { "admin" }, description = "逐账号签到状态" },
|
||
{ path = "channels/credits/balances", method = "POST", handler = "credits_balances",
|
||
roles = { "admin" }, description = "逐账号余额" },
|
||
{ path = "channels/credits/permanent_lock", method = "POST", handler = "credits_permanent_lock",
|
||
roles = { "admin" }, description = "永久积分锁定" },
|
||
{ path = "channels/account/reset", method = "POST", handler = "account_reset",
|
||
roles = { "admin" }, description = "清账号限流标记" },
|
||
{ path = "channels/account/retest", method = "POST", handler = "account_retest",
|
||
roles = { "admin" }, description = "重测账号" },
|
||
{ path = "channels/model/set_disabled_many", method = "POST", handler = "model_set_disabled_many",
|
||
roles = { "admin" }, description = "批量开关模型" },
|
||
{ path = "channels/model/clear_dead", method = "POST", handler = "model_clear_dead",
|
||
roles = { "admin" }, description = "清死模型" },
|
||
{ path = "channels/provider/get_order", method = "POST", handler = "provider_get_order",
|
||
roles = { "admin" }, description = "供应商显示顺序" },
|
||
{ path = "channels/provider/set_order", method = "POST", handler = "provider_set_order",
|
||
roles = { "admin" }, description = "设置供应商显示顺序" },
|
||
{ path = "channels/gateway/get_enabled", method = "POST", handler = "gateway_get_enabled",
|
||
roles = { "admin" }, description = "网关开关状态" },
|
||
{ path = "channels/gateway/set_enabled", method = "POST", handler = "gateway_set_enabled",
|
||
roles = { "admin" }, description = "网关整体开关" },
|
||
{ path = "channels/backup/status", method = "POST", handler = "backup_status",
|
||
roles = { "admin" }, description = "备份健康度" },
|
||
{ path = "channels/usage/token_ledger", method = "POST", handler = "usage_token_ledger",
|
||
roles = { "admin" }, description = "token 用量快照" },
|
||
{ path = "channels/usage/badge", method = "POST", handler = "usage_badge",
|
||
roles = { "admin" }, description = "渠道徽标" },
|
||
{ path = "channels/usage/badge_preference", method = "POST", handler = "usage_badge_preference",
|
||
roles = { "admin" }, description = "徽标显示偏好" },
|
||
{ path = "channels/onboarding/status", method = "POST", handler = "onboarding_status",
|
||
roles = { "admin" }, description = "新人任务状态" },
|
||
{ path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim",
|
||
roles = { "admin" }, description = "领取新人奖励" },
|
||
{ path = "channels/inject_platforms", method = "POST", handler = "inject_platforms",
|
||
roles = { "admin" }, description = "注入测试/扩展平台定义(运行时,不落盘)" },
|
||
}
|
||
|
||
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
|
||
-- base_url)。两个用途:
|
||
-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求)
|
||
-- 2. 测试把端点指到假上游,行为测试才能不打真厂商
|
||
-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段
|
||
-- 会破坏后续逻辑对形状的假设。
|
||
-- ============================================================================
|
||
-- 账号池 RPC(对齐参考 account.* 契约)
|
||
-- ============================================================================
|
||
-- account_add:向平台加一个账号(占位或带凭据)。
|
||
-- 对应参考 account.create;登录类的流程返回登录 URL,凭据在 login 完成后回填。
|
||
function plugin.account_add(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 aid = args.account_id or newToken():sub(1, 12)
|
||
if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end
|
||
local ref = "channels:" .. plat.id .. ":" .. aid
|
||
if args.credential and args.credential ~= "" then
|
||
local _, serr = __llmsproxy_secrets.set(ref, args.credential)
|
||
if serr then return { status = 400, json = { error = serr } } end
|
||
end
|
||
local now = os.time and os.time() or 0
|
||
local entry = {
|
||
id = aid, provider = plat.id,
|
||
nickname = args.nickname or aid,
|
||
enabled = args.enabled ~= false,
|
||
credentialRef = ref,
|
||
refreshable = args.refreshable or false,
|
||
createdAt = now,
|
||
expiresAt = args.expires_at,
|
||
last_claim = 0, last_claim_status = 0, last_claim_summary = "",
|
||
}
|
||
poolAdd(plat.id, entry)
|
||
logEvent("账号入池 " .. plat.id .. "/" .. aid)
|
||
-- 已带凭据的账号立即同步上游源;未带凭据的等 login 完成
|
||
if args.credential and args.credential ~= "" then syncAccountSource(plat.id, aid) end
|
||
return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } }
|
||
end
|
||
|
||
-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段)
|
||
function plugin.account_list(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 out = {}
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local has = credentialOf(a.credentialRef)
|
||
out[#out+1] = {
|
||
id = a.id, nickname = a.nickname, enabled = a.enabled ~= false,
|
||
credentialRef = a.credentialRef, refreshable = a.refreshable or false,
|
||
createdAt = a.createdAt, expiresAt = a.expiresAt,
|
||
has_credential = has ~= nil and has ~= "",
|
||
-- 签到/余额展示字段
|
||
last_claim = a.last_claim, last_claim_status = a.last_claim_status,
|
||
last_claim_summary = a.last_claim_summary,
|
||
}
|
||
end
|
||
return { status = 200, json = { ok = true, accounts = out } }
|
||
end
|
||
|
||
-- account_update:改昵称 / enabled(对齐 account.update)
|
||
function plugin.account_update(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local acct = poolGet(plat.id, args and args.account_id)
|
||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||
local patch = type(args.patch) == "table" and args.patch or {}
|
||
if patch.nickname ~= nil then acct.nickname = tostring(patch.nickname) end
|
||
if patch.enabled ~= nil then acct.enabled = patch.enabled and true or false end
|
||
poolAdd(plat.id, acct)
|
||
-- enabled 变化要同步上游源(禁用即删掉该源)
|
||
if patch.enabled ~= nil then
|
||
if acct.enabled then syncAccountSource(plat.id, acct.id)
|
||
else dropAccountSource(plat.id, acct.id) end
|
||
end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- account_delete:删账号 + 删凭据 + 删源(对齐 account.delete)
|
||
function plugin.account_delete(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local acct = poolGet(plat.id, args and args.account_id)
|
||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||
dropAccountSource(plat.id, acct.id)
|
||
__llmsproxy_secrets.delete(acct.credentialRef)
|
||
poolRemove(plat.id, acct.id)
|
||
logEvent("账号删除 " .. plat.id .. "/" .. acct.id)
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。
|
||
-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。
|
||
function plugin.account_test(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local acct = poolGet(plat.id, args and args.account_id)
|
||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||
local tok = credentialOf(acct.credentialRef)
|
||
if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end
|
||
-- 同步一次源(确保最新凭据已入),返回同步结果
|
||
syncAccountSource(plat.id, acct.id)
|
||
local srcs, _ = __llmsproxy_kernel.call("list_sources", {})
|
||
local found = false
|
||
for _, s in ipairs(srcs or {}) do
|
||
if s.name == accountSourceName(plat.id, acct.id) then found = true break end
|
||
end
|
||
if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end
|
||
return { status = 200, json = { ok = true, ms = 1 } }
|
||
end
|
||
|
||
-- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额
|
||
function plugin.account_refresh(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local acct = poolGet(plat.id, args and args.account_id)
|
||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||
local tok = credentialOf(acct.credentialRef)
|
||
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- account_reorder:调账号优先级(对齐 account.reorder)
|
||
function plugin.account_reorder(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 ordered = args and args.ordered_ids
|
||
if type(ordered) ~= "table" then return { status = 400, json = { error = "ordered_ids 必须是数组" } } end
|
||
for i, aid in ipairs(ordered) do
|
||
local acct = poolGet(plat.id, tostring(aid))
|
||
if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end
|
||
end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 模型开关 RPC(对齐参考 model.*)
|
||
-- ============================================================================
|
||
function plugin.model_list(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 disabled = disabledModelsOf(plat.id)
|
||
local out = {}
|
||
for i, m in ipairs(plat.models or {}) do
|
||
out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false, isFree = false }
|
||
end
|
||
return { status = 200, json = { ok = true, models = out } }
|
||
end
|
||
|
||
function plugin.model_set_disabled(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 mid = tostring(args and args.model_id or "")
|
||
if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end
|
||
local disabled = disabledModelsOf(plat.id)
|
||
if args.disabled then disabled[mid] = true else disabled[mid] = nil end
|
||
-- 重同步该平台的全部源,让模型开关立即生效
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true, disabled = disabled[mid] and true or false } }
|
||
end
|
||
|
||
function plugin.model_set_all_disabled(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 disabled = disabledModelsOf(plat.id)
|
||
if args.disabled then
|
||
for _, m in ipairs(plat.models or {}) do disabled[m] = true end
|
||
else
|
||
for k in pairs(disabled) do disabled[k] = nil end
|
||
end
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 供应商开关 + 状态(对齐参考 provider.*)
|
||
-- ============================================================================
|
||
function plugin.provider_status(req)
|
||
local out = {}
|
||
for _, p in ipairs(allPlatforms()) do
|
||
local accts = poolList(p.id)
|
||
local enabledN = 0
|
||
for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end
|
||
local disabled = disabledModelsOf(p.id)
|
||
local disabledN = 0
|
||
for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end
|
||
local closed = (p.models and #p.models > 0) and disabledN == #p.models
|
||
local state_accounts = plugin.state.accounts[p.id]
|
||
local legacyN = (state_accounts and credentialOf("channels:" .. p.id)) and 1 or 0
|
||
out[#out+1] = {
|
||
id = p.id, name = p.name, flow = p.flow, closed = closed,
|
||
models = { total = #(p.models or {}), disabled = disabledN },
|
||
accounts = { total = #accts + legacyN, enabled = enabledN + legacyN },
|
||
}
|
||
end
|
||
return { status = 200, json = { ok = true, statuses = out } }
|
||
end
|
||
|
||
function plugin.provider_set_enabled(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 enabled = args.enabled and true or false
|
||
-- 对齐参考语义:关闭 = 全关模型 + 停用全部账号;打开 = 清空模型黑名单 + 启用全部账号。
|
||
local disabled = disabledModelsOf(plat.id)
|
||
if enabled then
|
||
for k in pairs(disabled) do disabled[k] = nil end
|
||
for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end
|
||
-- 旧路径也启用
|
||
local legacy = plugin.state.accounts[plat.id]
|
||
local legacyTok = credentialOf("channels:" .. plat.id)
|
||
if legacy and legacyTok then
|
||
__llmsproxy_kernel.call("add_source", { source = {
|
||
name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
||
api_key = legacyTok, 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)(),
|
||
} })
|
||
end
|
||
else
|
||
for _, m in ipairs(plat.models or {}) do disabled[m] = true end
|
||
for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end
|
||
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
|
||
end
|
||
logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id)
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 一键领取(对齐参考 credits.claimAll)
|
||
-- ============================================================================
|
||
function plugin.credits_claim_all(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 results = {}
|
||
local claimed = 0
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id, credential_ref = a.credentialRef })
|
||
local ok = r.json and r.json.ok and true or false
|
||
if ok then claimed = claimed + 1 end
|
||
results[#results+1] = { accountId = a.id, outcome = ok
|
||
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
|
||
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
|
||
end
|
||
-- 无池账号的走旧路径
|
||
if #poolList(plat.id) == 0 then
|
||
local r = plugin.claim_daily({ platform = plat.id })
|
||
local ok = r.json and r.json.ok and true or false
|
||
if ok then claimed = claimed + 1 end
|
||
results[#results+1] = { accountId = "legacy", outcome = ok
|
||
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
|
||
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
|
||
end
|
||
return { status = 200, json = { ok = true, summary = { claimed = claimed }, results = results } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 备份导出/导入(对齐参考 backup.*)
|
||
-- ============================================================================
|
||
function plugin.backup_export(req)
|
||
local payload = {}
|
||
payload.version = 1
|
||
payload.exportedAt = os.time and os.time() or 0
|
||
payload.accounts = {}
|
||
payload.credentials = {}
|
||
payload.modelDisabled = plugin.state.modelDisabled or {}
|
||
payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true
|
||
for _, p in ipairs(allPlatforms()) do
|
||
for _, a in ipairs(poolList(p.id)) do
|
||
payload.accounts[#payload.accounts+1] = a
|
||
local tok = credentialOf(a.credentialRef)
|
||
if tok then payload.credentials[a.credentialRef] = tok end
|
||
end
|
||
-- 旧路径单账号也备份
|
||
local lt = credentialOf("channels:" .. p.id)
|
||
if lt then payload.credentials["channels:" .. p.id] = lt end
|
||
end
|
||
return { status = 200, json = { ok = true, payload = payload } }
|
||
end
|
||
|
||
function plugin.backup_import(req)
|
||
local args = bodyOf(req)
|
||
local payload = args and args.payload
|
||
if type(payload) ~= "table" then return { status = 400, json = { error = "payload 必须是对象" } } end
|
||
local n = 0
|
||
-- 恢复凭据
|
||
if type(payload.credentials) == "table" then
|
||
for ref, tok in pairs(payload.credentials) do
|
||
if type(ref) == "string" and type(tok) == "string" and tok ~= "" then
|
||
__llmsproxy_secrets.set(ref, tok) n = n + 1
|
||
end
|
||
end
|
||
end
|
||
-- 恢复账号池
|
||
if type(payload.accounts) == "table" then
|
||
for _, a in ipairs(payload.accounts) do
|
||
if type(a) == "table" and a.id and a.provider then
|
||
poolAdd(tostring(a.provider), {
|
||
id = tostring(a.id), provider = tostring(a.provider),
|
||
nickname = a.nickname or a.id, enabled = a.enabled ~= false,
|
||
credentialRef = a.credentialRef or ("channels:" .. a.provider .. ":" .. a.id),
|
||
refreshable = a.refreshable or false, createdAt = a.createdAt or (os.time and os.time() or 0),
|
||
expiresAt = a.expiresAt, last_claim = 0, last_claim_status = 0, last_claim_summary = "",
|
||
})
|
||
end
|
||
end
|
||
end
|
||
-- 恢复模型黑名单
|
||
if type(payload.modelDisabled) == "table" then
|
||
plugin.state.modelDisabled = payload.modelDisabled
|
||
end
|
||
-- 恢复自动签到开关
|
||
if payload.autoCheckin ~= nil then
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
plugin.state.settings.auto_checkin = payload.autoCheckin and true or false
|
||
end
|
||
-- 全部账号重新同步源
|
||
for _, p in ipairs(allPlatforms()) do
|
||
for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end
|
||
end
|
||
logEvent("备份已导入,恢复 " .. n .. " 条凭据")
|
||
return { status = 200, json = { ok = true, restored = n } }
|
||
end
|
||
|
||
-- auto_checkin 开关读写
|
||
function plugin.auto_checkin(req)
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
local args = bodyOf(req)
|
||
if args.enabled ~= nil then
|
||
plugin.state.settings.auto_checkin = args.enabled and true or false
|
||
logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭"))
|
||
end
|
||
return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances)
|
||
-- ============================================================================
|
||
-- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。
|
||
local function fetchAccountJSON(spec, tok, credTable, plat)
|
||
local headers
|
||
-- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。
|
||
-- buddy 在平台层声明一次,签到与余额复用同一套身份头。
|
||
local hfn = spec.headers_fn or (plat and plat.headers_fn)
|
||
if hfn then
|
||
local okf, h = pcall(hfn, tok, credTable or {})
|
||
if okf and type(h) == "table" then headers = h end
|
||
end
|
||
if not headers then
|
||
headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
|
||
end
|
||
local resp, err = __llmsproxy_http({
|
||
url = spec.url, method = spec.method or "GET",
|
||
headers = headers, body = spec.body,
|
||
})
|
||
if err then return nil, err end
|
||
if resp.status ~= 200 then return nil, "HTTP " .. tostring(resp.status) end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then return nil, "响应无法解析" end
|
||
return rec, nil
|
||
end
|
||
|
||
-- credits_status:逐账号查签到状态(对齐参考 credits.status;
|
||
-- 无状态端点的平台如实返回 null,与参考对 CodeArts/LobsterAI 的处理一致)
|
||
function plugin.credits_status(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 out = {}
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local status = nil
|
||
local cs = plat.checkin_status
|
||
if cs then
|
||
local tok = credentialOf(a.credentialRef)
|
||
if tok then
|
||
local rec, err = fetchAccountJSON(cs, tok, credentialFull(a.credentialRef), plat)
|
||
if rec and plat.checkin_status.status_fn then
|
||
local okf, st = pcall(plat.checkin_status.status_fn, rec)
|
||
if okf then status = st end
|
||
elseif err then
|
||
status = { error = err }
|
||
end
|
||
end
|
||
end
|
||
out[#out+1] = { accountId = a.id, nickname = a.nickname, status = status }
|
||
end
|
||
return { status = 200, json = { ok = true, accounts = out } }
|
||
end
|
||
|
||
-- credits_balances:逐账号查余额(对齐参考 credits.balances;
|
||
-- 无 balance 端点的平台返回 {balance=null, error="平台未声明余额端点"},
|
||
-- 不编造数字 —— 与参考注释「不伪装成余额」同语义)
|
||
function plugin.credits_balances(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 cap = CHANNEL_CREDITS[plat.id] or {}
|
||
local out = {}
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local entry = { accountId = a.id, balance = nil, error = nil }
|
||
local b = plat.balance
|
||
if not b then
|
||
entry.error = "平台未声明余额端点"
|
||
else
|
||
local tok = credentialOf(a.credentialRef)
|
||
if not tok then
|
||
entry.error = "无凭据"
|
||
else
|
||
local rec, err = fetchAccountJSON(b, tok, credentialFull(a.credentialRef), plat)
|
||
if err then
|
||
entry.error = err
|
||
elseif b.total_fn then
|
||
local okf, total = pcall(b.total_fn, rec)
|
||
if okf and type(total) == "number" then
|
||
entry.balance = { total = total, unit = cap.unit or "credits" }
|
||
else
|
||
entry.error = "余额解析失败"
|
||
end
|
||
end
|
||
end
|
||
end
|
||
out[#out+1] = entry
|
||
end
|
||
return { status = 200, json = { ok = true, accounts = out } }
|
||
end
|
||
|
||
-- 永久积分锁定(对齐参考 credits.permanentLock:锁定后仅使用 windowDays 内到期的积分)
|
||
function plugin.credits_permanent_lock(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
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
if args.locked ~= nil then
|
||
if type(args.locked) ~= "boolean" then
|
||
return { status = 400, json = { error = "locked 必须是布尔值" } }
|
||
end
|
||
plugin.state.settings["lock_" .. plat.id] = args.locked
|
||
logEvent("永久积分" .. (args.locked and "锁定 " or "解除 ") .. plat.id)
|
||
end
|
||
return { status = 200, json = { ok = true, locked = plugin.state.settings["lock_" .. plat.id] == true,
|
||
windowDays = 3 } }
|
||
end
|
||
|
||
-- account.reset / resetAll:清除账号限流标记(对齐参考 account.reset)
|
||
function plugin.account_reset(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
if args.account_id == "__all__" then
|
||
for _, a in ipairs(poolList(plat.id)) do a.rateLimited = nil poolAdd(plat.id, a) end
|
||
return { status = 200, json = { ok = true, reset = #poolList(plat.id) } }
|
||
end
|
||
local acct = poolGet(plat.id, args and args.account_id)
|
||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||
acct.rateLimited = nil
|
||
poolAdd(plat.id, acct)
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- account.retest / retestAll:重测账号(对齐参考 account.retest:触发条件不同,实现同 test)
|
||
function plugin.account_retest(req)
|
||
local args = bodyOf(req)
|
||
if args.account_id == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
if args.account_id == "__all__" then
|
||
local results = {}
|
||
for _, a in ipairs(poolList(plat.id)) do
|
||
local r = plugin.account_test({ platform = plat.id, account_id = a.id })
|
||
results[#results+1] = { accountId = a.id, ok = r.json and r.json.ok and true or false,
|
||
error = r.json and r.json.error or nil }
|
||
end
|
||
return { status = 200, json = { ok = true, results = results } }
|
||
end
|
||
return plugin.account_test({ platform = plat.id, account_id = args.account_id })
|
||
end
|
||
|
||
-- model.setDisabledMany:批量开关(对齐参考 model.setDisabledMany)
|
||
function plugin.model_set_disabled_many(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 ids = args and args.model_ids
|
||
if type(ids) ~= "table" then return { status = 400, json = { error = "model_ids 必须是数组" } } end
|
||
local disabled = disabledModelsOf(plat.id)
|
||
for _, mid in ipairs(ids) do
|
||
if args.disabled then disabled[tostring(mid)] = true else disabled[tostring(mid)] = nil end
|
||
end
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- model.clearDead:清死模型(对齐参考 model.clearDead:从黑名单移除已下线的 id)
|
||
function plugin.model_clear_dead(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 disabled = disabledModelsOf(plat.id)
|
||
local removed = {}
|
||
for mid in pairs(disabled) do
|
||
local alive = false
|
||
for _, m in ipairs(plat.models or {}) do if m == mid then alive = true break end end
|
||
if not alive then disabled[mid] = nil removed[#removed+1] = mid end
|
||
end
|
||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||
return { status = 200, json = { ok = true, removed = removed } }
|
||
end
|
||
|
||
-- 供应商排序(对齐参考 provider.getOrder/setOrder)
|
||
function plugin.provider_get_order(req)
|
||
local order = plugin.state.providerOrder
|
||
if type(order) ~= "table" then order = {} end
|
||
return { status = 200, json = { ok = true, order = order } }
|
||
end
|
||
|
||
function plugin.provider_set_order(req)
|
||
local args = bodyOf(req)
|
||
local order = args and args.order
|
||
if type(order) ~= "table" then return { status = 400, json = { error = "order 必须是字符串数组" } } end
|
||
local cleaned = {}
|
||
for _, id in ipairs(order) do
|
||
if platformById(tostring(id)) then cleaned[#cleaned+1] = tostring(id) end
|
||
end
|
||
plugin.state.providerOrder = cleaned
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- 网关开关(对齐参考 gateway.getEnabled/setEnabled:本插件的一个上游源面开关)
|
||
-- 这里网关开关语义=channels 提供的源是否参与调度;用 set_plugin_enabled 内核动作
|
||
-- 不合适(那是插件级),我们用「全部模型黑名单+全部账号停用」已由 provider_set_enabled
|
||
-- 承担,这里改为读取当前接入状态做查询、写入复用 provider_set_enabled。
|
||
function plugin.gateway_get_enabled(req)
|
||
local any = false
|
||
for _, p in ipairs(allPlatforms()) do
|
||
local disabled = disabledModelsOf(p.id)
|
||
local total = #(p.models or {})
|
||
local allOff = total > 0
|
||
for _, m in ipairs(p.models or {}) do if not disabled[m] then allOff = false break end end
|
||
if not allOff then any = true break end
|
||
end
|
||
return { status = 200, json = { ok = true, enabled = any,
|
||
apiKey = nil, address = nil, models = { source = "channels" } } }
|
||
end
|
||
|
||
function plugin.gateway_set_enabled(req)
|
||
local args = bodyOf(req)
|
||
if type(args.enabled) ~= "boolean" then
|
||
return { status = 400, json = { error = "非空布尔 enabled 必填" } }
|
||
end
|
||
-- 全平台整体开关(复用 provider_set_enabled 语义)
|
||
for _, p in ipairs(allPlatforms()) do
|
||
plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled })
|
||
end
|
||
logEvent("网关整体" .. (args.enabled and "开启" or "关闭"))
|
||
return { status = 200, json = { ok = true } }
|
||
end
|
||
|
||
-- backup.status:备份健康度(对齐参考 backup.status:账号数/无过期时间账号数)
|
||
function plugin.backup_status(req)
|
||
local total, noExpiry = 0, 0
|
||
for _, p in ipairs(allPlatforms()) do
|
||
for _, a in ipairs(poolList(p.id)) do
|
||
total = total + 1
|
||
if a.expiresAt == nil then noExpiry = noExpiry + 1 end
|
||
end
|
||
end
|
||
return { status = 200, json = { ok = true, accounts = total, withoutExpiry = noExpiry } }
|
||
end
|
||
|
||
-- usage.tokenLedger:本网关的 token 用量快照(对齐参考 usage.tokenLedger)。
|
||
-- 数据源:网关 /api/stats(插件走回环取,与 usage 插件同思路)。
|
||
function plugin.usage_token_ledger(req)
|
||
local args = bodyOf(req)
|
||
local limit = tonumber(args and args.limit) or 50
|
||
local key = (req and req.key) or ""
|
||
local url = "http://127.0.0.1:8081/api/stats?period=all&limit=" .. tostring(limit)
|
||
if key ~= "" then
|
||
url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6)))
|
||
end
|
||
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000 })
|
||
if err or not resp or resp.status ~= 200 then
|
||
return { status = 200, json = { ok = true, snapshot = { available = false,
|
||
error = err or (resp and tostring(resp.status) or "no response") } } }
|
||
end
|
||
local ok, rec = pcall(json.decode, resp.body or "")
|
||
if not ok or type(rec) ~= "table" then
|
||
return { status = 200, json = { ok = true, snapshot = { available = false, error = "stats 无法解析" } } }
|
||
end
|
||
-- 汇总 token 总量(兼容 stats 的不同形状)
|
||
local prompt, completion, requests = 0, 0, 0
|
||
local rows = rec.requests or rec.rows or rec.data or {}
|
||
if type(rows) == "table" then
|
||
for _, r in ipairs(rows) do
|
||
if type(r) == "table" then
|
||
prompt = prompt + (tonumber(r.prompt_tokens) or 0)
|
||
completion = completion + (tonumber(r.completion_tokens) or 0)
|
||
requests = requests + 1
|
||
end
|
||
end
|
||
end
|
||
return { status = 200, json = { ok = true, snapshot = { available = true,
|
||
tokens = { prompt = prompt, completion = completion, total = prompt + completion },
|
||
requests = requests, raw = rec } } }
|
||
end
|
||
|
||
-- usage.badge:渠道徽标(对齐参考 usage.badge;用 credits_balances 的汇总)
|
||
function plugin.usage_badge(req)
|
||
local args = bodyOf(req)
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "provider 不能为空" } } end
|
||
local j = plugin.credits_balances({ platform = plat.id })
|
||
local accounts = asArrLua(j.json and j.json.accounts)
|
||
local total, known = 0, false
|
||
for _, a in ipairs(accounts) do
|
||
if type(a.balance) == "table" and type(a.balance.total) == "number" then
|
||
total = total + a.balance.total
|
||
known = true
|
||
end
|
||
end
|
||
return { status = 200, json = { ok = true, badge = known
|
||
and { kind = "credits", total = total, unit = (CHANNEL_CREDITS[plat.id] or {}).unit or "credits" }
|
||
or { kind = "unavailable" } } }
|
||
end
|
||
|
||
-- usage.badgePreference:徽标显示偏好(读/写,全局一个;非法值拒绝不回落)
|
||
function plugin.usage_badge_preference(req)
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
local args = bodyOf(req)
|
||
if args.preference ~= nil then
|
||
local valid = { auto = true, subscription = true, credits = true }
|
||
if not valid[args.preference] then
|
||
return { status = 400, json = { error = "preference 必须是 auto/subscription/credits 之一" } }
|
||
end
|
||
plugin.state.settings.badge_preference = args.preference
|
||
end
|
||
return { status = 200, json = { ok = true, preference = plugin.state.settings.badge_preference or "auto" } }
|
||
end
|
||
|
||
-- onboarding.status / claim:新人任务(对齐参考 onboarding.*;仅 raccoon 有实测端点)
|
||
function plugin.onboarding_status(req)
|
||
local args = bodyOf(req)
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local ob = plat.onboarding
|
||
if not ob then
|
||
return { status = 200, json = { ok = true, supported = false,
|
||
message = plat.name .. " 未声明新人任务端点(参考实现同样如实返回 null)" } }
|
||
end
|
||
-- 已领取判定:查 state
|
||
local claimed = plugin.state.settings and plugin.state.settings["onboard_" .. plat.id] == true
|
||
return { status = 200, json = { ok = true, supported = true,
|
||
tasks = { desktop_login_reward = claimed }, earned = claimed and ob.reward_points or 0,
|
||
total = ob.reward_points } }
|
||
end
|
||
|
||
function plugin.onboarding_claim(req)
|
||
local args = bodyOf(req)
|
||
local plat = platformById(args and args.platform)
|
||
if not plat or not plat.onboarding then
|
||
return { status = 400, json = { error = "该平台无新人任务" } }
|
||
end
|
||
-- raccoon 实测端点:POST {apiBase}/login/points/grant
|
||
local tok = credentialOf("channels:" .. plat.id)
|
||
if not tok then return { status = 400, json = { error = "未登录" } } end
|
||
local resp, err = __llmsproxy_http({
|
||
url = plat.base_url .. "/login/points/grant", method = "POST",
|
||
headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
|
||
body = "{}",
|
||
})
|
||
if err then return { status = 400, json = { error = err } } end
|
||
if resp.status ~= 200 then return { status = 400, json = { error = "HTTP " .. tostring(resp.status) } } end
|
||
plugin.state.settings = plugin.state.settings or {}
|
||
plugin.state.settings["onboard_" .. plat.id] = true
|
||
logEvent("新人奖励已领 " .. plat.id)
|
||
return { status = 200, json = { ok = true, points = plat.onboarding.reward_points } }
|
||
end
|
||
|
||
-- asArrLua(Lua 侧的空表规整):桥接层 Go→Lua 不涉及,但 handler 内部把
|
||
-- credits_balances 的 json.accounts 再传给别的 handler 时要能处理空表。
|
||
function asArrLua(t)
|
||
if type(t) ~= "table" then return {} end
|
||
if #t > 0 then return t end
|
||
return {}
|
||
end
|
||
|
||
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
|
||
-- checkin_status / balance:声明式端点覆盖。参考把各平台的计费/状态端点当
|
||
-- 产品配置,运维要能整体改指(自建代理、镜像、测试假上游)。此前只覆盖了
|
||
-- 旧的 plat.checkin,buddy 这类"声明 checkin_status/balance"的平台无从覆盖 ——
|
||
-- 自动化测试也就无法把它们的端点指到假上游。
|
||
if type(args.checkin_status) == "table" and plat.checkin_status then
|
||
for _, k in ipairs({ "url", "method", "body" }) do
|
||
if args.checkin_status[k] then
|
||
plat.checkin_status[k] = tostring(args.checkin_status[k])
|
||
changed[#changed+1] = "checkin_status." .. k
|
||
end
|
||
end
|
||
end
|
||
if type(args.balance) == "table" and plat.balance then
|
||
for _, k in ipairs({ "url", "method", "body" }) do
|
||
if args.balance[k] then
|
||
plat.balance[k] = tostring(args.balance[k])
|
||
changed[#changed+1] = "balance." .. 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(allPlatforms()) do
|
||
local acct = accountOf(p.id) or {}
|
||
local has = credentialOf("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
|
||
|
||
-- inject_platforms:**运行时**注入平台定义(测试夹具 / 运维扩展)。
|
||
--
|
||
-- 为何存在:测试夹具(打 example.com 的假平台、验证各 flow 的占位平台)
|
||
-- 绝不能写成生产源码 —— PLATFORMS 会被 UI/status/list_platforms 直接展示,
|
||
-- 夹具内联进去就是「生产 UI 里实际出现 4 个假平台」(真实踩过)。
|
||
-- 正确分层:生产目录只留真实厂商,夹具由测试运行时注入。
|
||
--
|
||
-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的
|
||
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
|
||
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
|
||
local INJECT_PLATFORM_LIMIT = 32
|
||
function plugin.inject_platforms(req)
|
||
local args = bodyOf(req)
|
||
if args.platforms == nil then args = req end
|
||
local list = args and (args.platforms or args.items)
|
||
if type(list) ~= "table" then
|
||
return { status = 400, json = { error = "platforms 必须是数组" } }
|
||
end
|
||
if #list > INJECT_PLATFORM_LIMIT then
|
||
return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } }
|
||
end
|
||
-- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。
|
||
local builtin = {}
|
||
for _, p in ipairs(PLATFORMS) do builtin[p.id] = true end
|
||
local clean, seen = {}, {}
|
||
for _, p in ipairs(list) do
|
||
if type(p) ~= "table" or type(p.id) ~= "string" or p.id == "" then
|
||
return { status = 400, json = { error = "每个平台必须有 id" } }
|
||
end
|
||
if builtin[p.id] then
|
||
return { status = 400, json = { error = "不得覆盖内建平台: " .. p.id } }
|
||
end
|
||
if seen[p.id] then
|
||
return { status = 400, json = { error = "重复的 id: " .. p.id } }
|
||
end
|
||
seen[p.id] = true
|
||
clean[#clean + 1] = p
|
||
end
|
||
plugin.state.injected_platforms = clean
|
||
if #clean == 0 then
|
||
logEvent("已清空注入平台")
|
||
else
|
||
local ids = {}
|
||
for _, p in ipairs(clean) do ids[#ids + 1] = p.id end
|
||
logEvent("已注入平台 " .. #clean .. " 个: " .. table.concat(ids, ","))
|
||
end
|
||
return { status = 200, json = { ok = true, injected = #clean } }
|
||
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
|
||
|
||
-- 与 poll_login 的两条完成分支同一纪律:页面流程先建了占位账号
|
||
-- (credentialRef=channels:<plat>:<aid>),完成时必须带 account_id,
|
||
-- 否则凭据落到与占位账号无关的 `channels:<plat>` 键 → 池账号永远
|
||
-- has_credential=false(同一缺陷在 callback 路径的漏网)。
|
||
local res = plugin.complete_login({ platform = s.platform, credential = credential,
|
||
account = account, account_id = s.account_id })
|
||
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">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||
<h2 style="margin:0">渠道控制台</h2>
|
||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||
<input id="ch-search" placeholder="搜索渠道或供应商…" style="padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);min-width:200px">
|
||
<div id="ch-filters" style="display:flex;gap:6px"></div>
|
||
<button class="small" onclick="chLedger()">Token 用量</button>
|
||
<button class="small" onclick="chBackup(1)">导出备份</button>
|
||
<button class="small" onclick="document.getElementById('ch-import-file').click()">导入备份</button>
|
||
<input type="file" id="ch-import-file" hidden accept=".json,application/json" onchange="chImportFile(event)">
|
||
<label style="display:flex;gap:6px;align-items:center;font-size:12px">
|
||
<input type="checkbox" id="ch-auto" onchange="chAutoToggle(this.checked)"> 每日自动签到
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="ch-summary" style="display:flex;gap:18px;margin:10px 0;font-size:13px;color:var(--muted,#888)"></div>
|
||
<div id="ch-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px"></div>
|
||
</div>
|
||
|
||
<!-- 渠道详情:账号池 + 模型开关(对齐参考 provider-detail) -->
|
||
<div class="card" id="ch-detail" style="display:none">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<button class="small" onclick="chSelect(undefined)">← 返回全部渠道</button>
|
||
<span class="muted" style="font-size:12px">账号凭据加密保存在服务端,页面永远拿不到明文。</span>
|
||
</div>
|
||
<h2 id="ch-detail-name" style="margin:10px 0 4px"></h2>
|
||
<p class="muted" id="ch-detail-org" style="margin:0 0 12px;font-size:13px"></p>
|
||
|
||
<div id="ch-detail-actions" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px"></div>
|
||
<p id="ch-notice" class="muted" style="font-size:13px;min-height:18px"></p>
|
||
|
||
<div id="ch-accts"></div>
|
||
|
||
<div style="display:flex;gap:8px;align-items:center;margin:14px 0 6px">
|
||
<b style="font-size:13px">模型</b>
|
||
<span id="ch-model-count" class="muted" style="font-size:12px"></span>
|
||
<button class="small" onclick="chModelAll(false)">全部启用</button>
|
||
<button class="small" onclick="chModelAll(true)">全部关闭</button>
|
||
</div>
|
||
<div id="ch-models" style="display:flex;flex-direction:column;gap:4px"></div>
|
||
|
||
<!-- 登录区:发起登录后显示(device-poll 轮询 / callback URL / vnc / paste / manual) -->
|
||
<div id="ch-login-box" style="display:none;margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
|
||
<h3 style="margin:4px 0">登录</h3>
|
||
<div id="ch-login-body"></div>
|
||
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
|
||
<p class="muted" style="font-size:12px">请在下面的窗口里完成登录,完成后本页会自动继续。</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>
|
||
|
||
<h3 style="margin:16px 0 6px;font-size:13px">最近日志</h3>
|
||
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px"></ul>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
const q = (s) => document.querySelector(s);
|
||
const esc = (v) => String(v == null ? "" : v).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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(() => ({}));
|
||
}
|
||
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
|
||
let state = { statuses: [], filter: "all", search: "", selected: undefined,
|
||
accounts: undefined, models: undefined, login: null, pollTimer: null };
|
||
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
|
||
|
||
async function loadStatuses() {
|
||
const j = await call("channels/provider/status", "POST", {});
|
||
state.statuses = j.statuses || [];
|
||
renderSummary(); renderGrid();
|
||
const sj = await call("channels/status");
|
||
const log = q("#ch-log"); if (log) log.innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
|
||
const aj = await call("channels/auto_checkin", "POST", {});
|
||
q("#ch-auto").checked = aj.autoCheckin !== false;
|
||
}
|
||
|
||
function renderSummary() {
|
||
const total = state.statuses.length;
|
||
const connected = state.statuses.filter((s) => !s.closed && s.accounts.enabled > 0).length;
|
||
const accounts = state.statuses.reduce((n, s) => n + (s.accounts.total || 0), 0);
|
||
q("#ch-summary").innerHTML =
|
||
"<span>已接入渠道 <b>" + connected + " / " + total + "</b></span>" +
|
||
"<span>本机账号 <b>" + accounts + "</b></span>";
|
||
const counts = { all: total, connected, pending: total - connected };
|
||
const defs = [{ id: "all", label: "全部" }, { id: "connected", label: "已接入" }, { id: "pending", label: "待接入" }];
|
||
q("#ch-filters").innerHTML = defs.map((d) =>
|
||
'<button class="small" data-f="' + d.id + '" style="' + (state.filter===d.id?"font-weight:bold;":"") + '" onclick="chFilter(\'' + d.id + '\')">' + d.label + ' (' + counts[d.id] + ')</button>').join("");
|
||
}
|
||
|
||
function renderGrid() {
|
||
const list = state.statuses.filter((s) => {
|
||
const hit = (s.id + " " + s.name).toLowerCase().includes(state.search);
|
||
const on = !s.closed && s.accounts.enabled > 0;
|
||
return hit && (state.filter === "all" || (state.filter === "connected") === on);
|
||
});
|
||
q("#ch-grid").innerHTML = list.map((s) => {
|
||
const on = !s.closed && s.accounts.enabled > 0;
|
||
const st = s.closed ? '<span class="tag">已关闭</span>'
|
||
: on ? '<span class="tag tag-green">已接入</span>' : '<span class="tag">待接入</span>';
|
||
return '<div class="card" style="margin:0;padding:12px;cursor:pointer" onclick="chSelect(\'' + esc(s.id) + '\')">' +
|
||
'<div style="display:flex;justify-content:space-between;align-items:center">' +
|
||
'<b>' + esc(s.name) + '</b>' + st + '</div>' +
|
||
'<div class="muted" style="font-size:12px;margin:6px 0">' + esc(s.id) + ' · ' + esc(s.flow) + '</div>' +
|
||
'<div style="font-size:12px">账号 <b>' + (s.accounts.enabled||0) + '/' + (s.accounts.total||0) + '</b>' +
|
||
' · 模型 <b>' + ((s.models.total||0)-(s.models.disabled||0)) + '/' + (s.models.total||0) + '</b></div>' +
|
||
'</div>';
|
||
}).join("") || '<p class="muted">没有匹配的渠道</p>';
|
||
}
|
||
|
||
async function renderDetail() {
|
||
const id = state.selected;
|
||
const box = q("#ch-detail");
|
||
if (!id) { box.style.display = "none"; q("#ch-grid").parentElement.style.display = "block"; return; }
|
||
box.style.display = "block";
|
||
q("#ch-grid").parentElement.style.display = "none";
|
||
const s = state.statuses.find((x) => x.id === id) || {};
|
||
q("#ch-detail-name").textContent = s.name || id;
|
||
q("#ch-detail-org").textContent = id + " · " + (s.flow || "");
|
||
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
|
||
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
|
||
const acts = [];
|
||
acts.push('<button class="small" onclick="chStartLogin()">添加账号(登录)</button>');
|
||
if ((s.accounts.total||0) > 0) acts.push('<button class="small" onclick="chRefreshAll()">刷新全部账号</button>');
|
||
acts.push('<button class="small" onclick="chClaimAll()">一键领取额度</button>');
|
||
acts.push('<button class="small" onclick="chResetAll()">重置限流标记</button>');
|
||
acts.push('<button class="small" onclick="chClearDead()">清死模型</button>');
|
||
acts.push('<button class="small" onclick="chProviderToggle(' + (!s.closed) + ')">' + (s.closed ? "启用渠道" : "关闭渠道") + '</button>');
|
||
q("#ch-detail-actions").innerHTML = acts.join(" ");
|
||
q("#ch-notice").textContent = "";
|
||
await Promise.all([renderAccounts(id), renderModels(id)]);
|
||
}
|
||
|
||
async function renderAccounts(id) {
|
||
const j = await call("channels/account/list", "POST", { platform: id });
|
||
const accts = j.accounts || [];
|
||
state.accounts = accts;
|
||
// 逐账号余额(credits.balances 对齐参考;无端点平台如实显示不可用)
|
||
let balances = {};
|
||
try {
|
||
const bj = await call("channels/credits/balances", "POST", { platform: id });
|
||
for (const row of (bj.accounts || [])) balances[row.accountId] = row;
|
||
} catch {}
|
||
q("#ch-accts").innerHTML = accts.length === 0
|
||
? '<p class="muted" style="font-size:13px">暂无账号。点「添加账号(登录)」开始。</p>'
|
||
: accts.map((a) => {
|
||
const badge = a.has_credential
|
||
? (a.enabled !== false ? '<span class="tag tag-green">启用</span>' : '<span class="tag">停用</span>')
|
||
: '<span class="tag">待凭据</span>';
|
||
const claimTxt = a.last_claim ? ('上次领取 ' + new Date(a.last_claim*1000).toLocaleString() + (a.last_claim_summary ? ' · ' + esc(a.last_claim_summary) : '')) : '';
|
||
const b = balances[a.id];
|
||
const balTxt = !b ? '' : b.balance && typeof b.balance.total === 'number'
|
||
? '<span class="tag tag-green">' + esc(b.balance.unit || 'credits') + ' ' + b.balance.total + '</span>'
|
||
: '<span class="muted" title="' + esc(b.error || '') + '" style="font-size:12px">余额不可用</span>';
|
||
return '<div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px dashed var(--line);flex-wrap:wrap">' +
|
||
'<b style="min-width:120px">' + esc(a.nickname || a.id) + '</b>' + badge + balTxt +
|
||
'<span class="muted" style="font-size:12px">' + claimTxt + '</span>' +
|
||
'<span style="flex:1"></span>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'test\')">测试</button>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'refresh\')">刷新</button>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'toggle\')">' + (a.enabled !== false ? "停用" : "启用") + '</button>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'priority\')">优先</button>' +
|
||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'delete\')">删除</button>' +
|
||
'</div>';
|
||
}).join("");
|
||
}
|
||
|
||
async function renderModels(id) {
|
||
const j = await call("channels/model/list", "POST", { platform: id });
|
||
const ms = j.models || [];
|
||
state.models = ms;
|
||
const on = ms.filter((m) => !m.disabled).length;
|
||
q("#ch-model-count").textContent = on + "/" + ms.length + " 启用";
|
||
q("#ch-models").innerHTML = ms.map((m) =>
|
||
'<div style="display:flex;gap:8px;align-items:center;padding:4px 0">' +
|
||
'<span style="flex:1;font-size:13px" title="' + esc(m.id) + '">' + esc(m.name || m.id) + '</span>' +
|
||
'<button class="small" onclick="chModel(\'' + esc(m.id) + '\',' + (!m.disabled) + ')">' + (m.disabled ? "开启" : "关闭") + '</button>' +
|
||
'</div>').join("") || '<p class="muted">—</p>';
|
||
}
|
||
|
||
// ---- 动作 ----
|
||
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
|
||
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
|
||
window.chSelect = async (id) => { state.selected = id; await renderDetail(); };
|
||
window.chAcct = async (aid, act) => {
|
||
const id = state.selected; if (!id) return;
|
||
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
|
||
if (act === "toggle") {
|
||
const a = (state.accounts || []).find((x) => x.id === aid);
|
||
await call("channels/account/update", "POST", { platform: id, account_id: aid, patch: { enabled: !(a && a.enabled !== false) } });
|
||
} else if (act === "priority") {
|
||
await call("channels/account/reorder", "POST", { platform: id, ordered_ids: [aid].concat((state.accounts||[]).map((x)=>x.id).filter((x)=>x!==aid)) });
|
||
} else {
|
||
await call("channels/account/" + act, "POST", { platform: id, account_id: aid });
|
||
}
|
||
await renderAccounts(id); await loadStatuses();
|
||
};
|
||
window.chModel = async (mid, disabled) => {
|
||
const id = state.selected; if (!id) return;
|
||
await call("channels/model/set_disabled", "POST", { platform: id, model_id: mid, disabled });
|
||
await renderModels(id); await loadStatuses();
|
||
};
|
||
window.chModelAll = async (disabled) => {
|
||
const id = state.selected; if (!id) return;
|
||
await call("channels/model/set_all_disabled", "POST", { platform: id, disabled });
|
||
await renderModels(id); await loadStatuses();
|
||
};
|
||
window.chProviderToggle = async (enable) => {
|
||
const id = state.selected; if (!id) return;
|
||
if (!enable && !confirm("关闭渠道会停用全部账号与模型,确认?")) return;
|
||
await call("channels/provider/set_enabled", "POST", { platform: id, enabled: enable });
|
||
await loadStatuses(); await renderDetail();
|
||
};
|
||
window.chRefreshAll = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
for (const a of state.accounts || []) await call("channels/account/refresh", "POST", { platform: id, account_id: a.id });
|
||
notice("已刷新全部账号"); await renderAccounts(id);
|
||
};
|
||
window.chClaimAll = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
notice("领取中…");
|
||
const j = await call("channels/credits/claim_all", "POST", { platform: id });
|
||
const res = j.results || [];
|
||
const okN = res.filter((r) => r.outcome && r.outcome.kind === "success").length;
|
||
const failN = res.filter((r) => r.outcome && r.outcome.kind === "failed");
|
||
notice("领取完成:成功 " + okN + " 个账号" + (failN.length ? ";失败:" + failN.map((r) => r.outcome.message).join(";") : ""));
|
||
await renderAccounts(id);
|
||
};
|
||
window.chAutoToggle = async (on) => { await call("channels/auto_checkin", "POST", { enabled: on }); notice(on ? "自动签到已开启" : "自动签到已关闭"); };
|
||
window.chResetAll = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
await call("channels/account/reset", "POST", { platform: id, account_id: "__all__" });
|
||
notice("已重置全部账号限流标记");
|
||
};
|
||
window.chClearDead = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
const j = await call("channels/model/clear_dead", "POST", { platform: id });
|
||
const removed = (j.removed || []).length;
|
||
notice(removed ? ("已清理 " + removed + " 个死模型") : "没有死模型");
|
||
await renderModels(id);
|
||
};
|
||
// 用量快照(usage.tokenLedger 对齐参考):弹窗展示 prompt/completion/请求数
|
||
window.chLedger = async () => {
|
||
const j = await call("channels/usage/token_ledger", "POST", { limit: 100 });
|
||
const s = j.snapshot || {};
|
||
if (!s.available) { alert("用量不可用:" + (s.error || "未知")); return; }
|
||
alert("本网关 token 用量\nPrompt: " + (s.tokens?.prompt ?? 0) + "\nCompletion: " + (s.tokens?.completion ?? 0) +
|
||
"\nTotal: " + (s.tokens?.total ?? 0) + "\nRequests: " + (s.requests ?? 0));
|
||
};
|
||
window.chBackup = async () => {
|
||
const j = await call("channels/backup/export", "POST", {});
|
||
const blob = new Blob([JSON.stringify(j.payload, null, 2)], { type: "application/json" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "channels-backup-" + new Date().toISOString().slice(0,10) + ".json";
|
||
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||
};
|
||
window.chImportFile = async (ev) => {
|
||
const f = ev.target.files[0]; ev.target.value = ""; if (!f) return;
|
||
if (f.size > 8*1024*1024) { alert("备份文件不能超过 8MB"); return; }
|
||
if (!confirm("导入会合并备份中的账号凭据、模型开关与自动签到设置,确认?")) return;
|
||
const payload = JSON.parse(await f.text());
|
||
const j = await call("channels/backup/import", "POST", { payload });
|
||
alert(j.error ? ("导入失败:" + j.error) : ("已导入,恢复 " + (j.restored||0) + " 条凭据"));
|
||
await loadStatuses();
|
||
};
|
||
function notice(msg) { q("#ch-notice").textContent = msg; }
|
||
|
||
// ---- 登录(对齐参考两步式:立即拿 URL/弹窗,后台轮询完成)----
|
||
window.chStartLogin = async () => {
|
||
const id = state.selected; if (!id) return;
|
||
// 池路径:先建占位账号,登录完成后回填凭据到这个账号
|
||
const aj = await call("channels/account/add", "POST", { platform: id });
|
||
if (aj.error) { alert(aj.error); return; }
|
||
const aid = aj.account_id;
|
||
const j = await call("channels/login", "POST", { platform: id, account_id: aid });
|
||
if (j.error) { alert(j.error); return; }
|
||
const box = q("#ch-login-box"); box.style.display = "block";
|
||
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) {
|
||
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='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
|
||
"<p class='muted' id='ch-login-state'></p>";
|
||
} else if (j.session && j.session.flow === "paste") {
|
||
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='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
|
||
"<p class='muted' id='ch-login-state'></p>";
|
||
} else {
|
||
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>";
|
||
}
|
||
stopPoll();
|
||
if (!(j.manual_hint || (j.session && j.session.flow === "paste"))) {
|
||
state.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 + (st.account ? "(" + st.account + ")" : "");
|
||
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";
|
||
if (st.state === "failed") notice("登录未完成:" + (st.message || st.state));
|
||
await renderAccounts(id); await loadStatuses();
|
||
}
|
||
}, 2000);
|
||
}
|
||
};
|
||
// paste / manual 的凭据提交:落到占位账号(池路径),完成即同步源
|
||
window.chPaste = async (aid) => {
|
||
const id = state.selected; if (!id || !aid) return;
|
||
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, account_id: aid });
|
||
if (j.error) { if (el) el.textContent = "失败:" + j.error; return; }
|
||
q("#ch-login-box").style.display = "none"; q("#ch-cred").value = "";
|
||
notice("账号已接入:" + aid);
|
||
await renderAccounts(id); await loadStatuses();
|
||
};
|
||
|
||
loadStatuses();
|
||
})();
|
||
</script>
|
||
]],
|
||
},
|
||
}
|
||
|
||
return plugin
|