Files
ModelRouter/internal/lua/plugins/channels.lua
JianFeeeee cf53d72af4 fix: 回退两处内核改动,channels 改为插件自持持久化(照参考实现)
用户指出「你又tm改内核?」—— 核对后确认两处本可避免:
  1. internal/lua/vm.go  +32 行  新增 base64_decode 全局函数
  2. internal/lua/plugin_http_handler.go +16 行  InvokeHandler 补 markDirty
工作区已还原到 v2.0.2(git diff v2.0.2 = 0 行)。

参考实现(dsh-our-free-model/vendor/channel-pack)对同一问题的做法:
- 解 JWT:用**语言自带**的 `Buffer.from(..., 'base64url')`,从不为此改宿主。
  → 我改成了插件的纯 Lua b64url_decode(约 20 行),实测解出真 JWT 的
    nickname/sub/exp 全对。
- 状态持久化:插件**自己 writeFileSync+renameSync** 写
  `channel-pack/state.json`,从不依赖宿主 settings。
  → 参考还记下了同构教训(Gitee IKI7WT):DSH 0.1.7 移除 settings.register()
    后账号列表退化为纯内存;正解是**插件探测宿主能力、自持文档**,不是要求
    宿主改。我的 markDirty 修法方向相反:把插件的数据安全外包给宿主某条
    分发路径的实现细节。

- `.channels-store.json`(tmp + rename 原子替换),只放**索引与配置**
  (账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
  凭据仍在 .secrets/(0600),两者分离:即便文档泄露也不含 token。
- 变更点同步落盘(不用「置标志+定时 flush」):本插件无 hooks、45 个端点
  各自变更状态,还有大量内部互调;挂在某个边界就必须保证**每条**路径都
  经过它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。
  状态实测 ~1.7KB,写入只发生在运维动作上,不在请求热路径。
- 黑名单写入收口到 setModelDisabled(写点曾有 5+ 处,散着写必漏)。
- 存量迁移:读内核遗留 `.channels.state.json`(v2.0.4 内核写的)→ 写自有
  文档 → **写成功才删**遗留(写失败还删=用一次迁移丢数据);自有存储已存在
  时也删遗留,否则内核 restore() 会在启动时用它覆盖。

1. **测试污染生产目录**(严重):插件 storeDir() 读 /etc/llmsproxy/config.yaml
   决定落盘位置,而既有测试都没隔离 → 一次全量测试把 26 个测试账号写进了
   **生产** .channels-store.json(含 fixture-a/example 等夹具 id)。
   prod 文件已删除(真实账号另有来源);kernelTestCore 现在隔离插件目录,
   且尊重调用方已设的值(多次「重启」需共用同一目录)。
2. model_set_disabled 改了写入口后仍引用已删除的局部变量(nil 索引崩溃)。

- TestChannelsStoreIsPluginOwned:状态写进**插件自己的**文件、不含凭据字段、
  新实例读回。变异(markDirty 空操作)→ FAIL。
- TestChannelsMigrationKeepsLegacyOnWriteFailure:**写失败必须保留遗留文件**。
  变异(无条件删)→ FAIL。(这条单独测:迁移成功后的删除有两条路径互相掩盖,
  只测「最终文件没了」区分不出「写失败也删了」。)
- TestChannelsMigratesLegacyKernelState:遗留账号迁入内存 + 自有存储写出 +
  遗留文件删除。
- 内核对照 v2.0.2:diff 为空。

go test ./internal/core/(78s)与 ./internal/lua/(63s)全绿;
全量测试后确认生产插件目录未被写入。
2026-10-13 09:34:13 +08:00

3766 lines
176 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

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

-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源)
--
-- 为什么需要它
-- ------------
-- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。
-- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是
-- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到
-- 凭据 → 定时领取额度 → 把端点当源用。
--
-- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的
-- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。
-- 新增一个平台是往 PLATFORMS 里加一条,不改内核。
--
-- 设计边界(与内核的约定)
-- ------------------------
-- 内核提供基础设施,插件自己不实现这些:
--
-- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它)
-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读)
-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…)
--
-- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。
-- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"——
-- 内核里出现厂商名就是分层被破坏了。登录会话、服务端浏览器栈也全归插件自持。
--
-- 权限(本插件需要,全部可在设置页看到"申请中")
-- ------------------------------------------------
-- add_source 把平台端点注册为上游源
-- list_sources 显示当前已接入的平台
-- probe_all 接入后立刻验证可达
-- reset_health 上游恢复后清冷却
-- status 面板显示运行状态
--
-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被
-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。
--
-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions,
-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用
-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。
-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调)
-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。
local plugin = {
name = "channels",
version = "1.0.0",
description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源",
author = "llmsproxy",
}
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "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:
-- 本插件声明 hooks = {},一切状态变更都走自己的 HTTP 路由。宿主的状态自动
-- flush 由 hook / Call / handler 三条分发路径触发,插件不应把「我的状态会不会
-- 被写下去」押在宿主某条路径的实现细节上 —— 那是把插件的数据安全外包给宿主。
-- 参考实现踩过同一课(channel-pack-store.ts):宿主 settings 能力一变,账号
-- 列表就退化为纯内存,必须改由插件自持文档。
--
-- 位置与凭据分离:凭据在 .secrets/(0600,跨插件不可读),这里只放**索引与
-- 配置**(账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
-- 即便这个文件泄露,也不含任何 token。
--
-- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。
local STORE_NAME = ".channels-store.json"
-- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。
-- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。
local function storeDir()
-- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置)
local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")
if type(env) == "string" and env ~= "" then return env end
local s = plugin.state.settings and plugin.state.settings.store_dir
if type(s) == "string" and s ~= "" then return s end
for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml" }) do
local f = io.open(path, "r")
if f then
local txt = f:read("*a")
f:close()
if txt then
local d = txt:match("\nplugin_dir:%s*([^\n]%S*)") or txt:match("^plugin_dir:%s*([^\n]%S*)")
if d and d ~= "" then return d end
end
end
end
return "/etc/llmsproxy/plugins"
end
-- persistKeys 是要落盘的字段。故意不含 sessions(登录会话是短命态,重启即失效,
-- 写下去只会留下过期垃圾)与 browser(进程 pid 跨重启无意义)。
local PERSIST_KEYS = {
"accounts", "pool", "modelDisabled", "settings",
"providerOrder", "log", "injected_platforms",
}
local function persistState()
local dir = storeDir()
local path = dir .. "/" .. STORE_NAME
local snap = {}
for _, k in ipairs(PERSIST_KEYS) do
if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end
end
local body = json.encode({ version = 1, state = snap })
if not body then return false, "json.encode 失败" end
local tmp = path .. ".tmp"
local w = io.open(tmp, "w")
if not w then return false, "无法写入 " .. tmp end
w:write(body)
w:close()
os.execute(("chmod 0600 %s 2>/dev/null"):format(tmp))
local ok = os.rename(tmp, path)
if not ok then
os.remove(tmp)
return false, "rename 失败"
end
return true
end
local function applySnapshot(snap)
for _, k in ipairs(PERSIST_KEYS) do
if snap[k] ~= nil then plugin.state[k] = snap[k] end
end
end
-- dropLegacyKernelState 删除内核遗留文件(若存在)。
--
-- 必须删:内核的 restore() 在插件源码执行**之后**运行,会把这个文件的内容整体
-- 覆盖到 plugin.state 上。自有存储已经是权威,留下它等于给"下次启动被旧数据
-- 覆盖"留一个后门。删它不需要先读它(迁移路径自己会读)。
local function dropLegacyKernelState()
local legacy = storeDir() .. "/.channels.state.json"
local f = io.open(legacy, "r")
if not f then return end
f:close()
os.remove(legacy)
io.stderr:write("[channels] 已删除内核遗留状态文件(自有存储为权威)\n")
end
-- loadState 读插件自有文档;缺失时**从内核遗留文件迁移一次**。
--
-- 历史背景:v2.0.4 曾让内核的 InvokeHandler 补 markDirty,于是状态被内核写到了
-- `.channels.state.json`(内核的 <plugin>.state.json 命名)。现在内核改动已回退,
-- 那个文件不会再被内核读写;但生产上已经存在一份(含真实账号池),必须迁移过来,
-- 否则升级即丢账号 —— 与本次要修的缺陷同类,不能自己再造一次。
-- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。
local function loadOwnSnapshot()
local dir = storeDir()
local f = io.open(dir .. "/" .. STORE_NAME, "r")
if f then
local txt = f:read("*a")
f:close()
if txt and txt ~= "" then
local ok, rec = pcall(json.decode, txt)
if ok and type(rec) == "table" and type(rec.state) == "table" then
applySnapshot(rec.state)
return true
end
-- 损坏文件不阻塞:用内置默认值(下次写入覆盖它)
io.stderr:write("[channels] 自有存储损坏,改用默认值\n")
end
end
return false
end
-- migrateLegacyKernelState 把内核遗留的 .channels.state.json 搬进自有文档。
local function migrateLegacyKernelState()
local dir = storeDir()
local legacy = dir .. "/.channels.state.json"
local f = io.open(legacy, "r")
if not f then return false end
local txt = f:read("*a")
f:close()
if not txt or txt == "" then return false end
local ok, rec = pcall(json.decode, txt)
if not ok or type(rec) ~= "table" or type(rec.state) ~= "table" then
return false
end
applySnapshot(rec.state)
-- 立即写成自有文档;**只有写成功才删遗留文件** —— 写失败还删它,
-- 等于用一次迁移把数据弄丢,与本次要修的缺陷同类。
local written, werr = persistState()
if written then
os.remove(legacy)
io.stderr:write("[channels] 已迁移遗留状态到 " .. STORE_NAME .. "\n")
else
io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): "
.. tostring(werr) .. "\n")
end
return true
end
local function loadState()
if loadOwnSnapshot() then
-- 自有存储已权威:遗留文件必须删掉,否则内核 restore() 会用它覆盖
dropLegacyKernelState()
return true
end
return migrateLegacyKernelState()
end
-- markDirty = 立即同步落盘(tmp + rename 原子替换)。
--
-- 为什么不用「置标志 + 定时 flush」:本插件没有 hooks,45 个端点各自
-- 直接变更状态,还有大量内部调用(syncAccountSource / claim_daily /
-- auto_checkin 互调)。把 flush 挂在某个边界上就必须保证**每一条**路径都经过
-- 它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。同步写在
-- 变更点,路径覆盖是结构性的,不依赖我数全 45 个入口。
--
-- 代价可接受:状态很小(实测 ~1.7KB),channels 没有 chat 钩子,写入只发生在
-- 运维动作(登录/领取/开关)与登录轮询(未完成时才有)上,不在请求热路径。
-- 参考实现同样是每次变更 writeFileSync + renameSync。
--
-- 失败处理:写失败**不从状态里回滚**内存变更(内存是对的),只留痕到 stderr
-- ——不递归调 logEvent,那会再次触发 markDirty。
local function markDirty()
local ok, err = persistState()
if not ok then
io.stderr:write("[channels] 状态落盘失败: " .. tostring(err) .. "\n")
end
end
plugin.state = plugin.state or {}
plugin.state.accounts = plugin.state.accounts or {}
plugin.state.log = plugin.state.log or {}
-- 启动时从插件自有文档恢复(必须在 plugin.state 建表之后:loadState 往表里填
-- 字段,先填再被 or {} 覆盖就白填了)。
loadState()
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
markDirty()
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
markDirty()
end
local function poolRemove(provider, accountId)
poolAccounts(provider)[accountId] = nil
markDirty()
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(调用方自行处理)。
-- b64url_decode 纯 Lua 的 base64url 解码(读 JWT payload 用)。
--
-- 为什么不用内核的 base64 能力:内核只提供 encode(适配器签名要用),没有 decode。
-- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 ——
-- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。
-- 接受标准/URL-safe 两种字母表,容忍缺失填充。
local B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local B64_LOOKUP = {}
for i = 1, #B64_ALPHABET do B64_LOOKUP[B64_ALPHABET:sub(i, i)] = i - 1 end
local function b64url_decode(input)
if type(input) ~= "string" or input == "" then return nil end
-- 归一化:URL-safe → 标准,去空白,去掉填充(下面按 4 位一组补零处理)
local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
local out, acc, bits = {}, 0, 0
for i = 1, #s do
local v = B64_LOOKUP[s:sub(i, i)]
if v == nil then return nil end -- 非法字符:如实失败,不猜
acc = acc * 64 + v
bits = bits + 6
if bits >= 8 then
bits = bits - 8
local byte = math.floor(acc / (2 ^ bits)) % 256
out[#out + 1] = string.char(byte)
acc = acc % (2 ^ bits)
end
end
return table.concat(out)
end
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 = b64url_decode(payload)
if not bin 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
-- setModelDisabled 是**唯一**的模型黑名单写入口:写完即落盘。
-- 之所以收成一个函数:黑名单的写点曾有 5+ 处(单个开关/批量/清死模型/供应商
-- 整体开关),散着写必然漏标脏。收口后只有这里需要保证。
local function setModelDisabled(provider, modelId, disabled)
local m = disabledModelsOf(provider)
if disabled then m[modelId] = true else m[modelId] = nil end
markDirty()
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 永远不会出现)。
--
-- 为什么需要它:有些平台的登录**必须**在服务端完成 —— 回环重定向落不到用户
-- 机器上、或授权页要求浏览器内交互(扫码后确认、短信验证码)。这时只有服务端
-- 浏览器能走完,VNC 是把那个浏览器呈现给运维的唯一途径。
--
-- 登录后的取凭据不走「截图 + 人眼看」:浏览器以 --remote-debugging-port 起,
-- 完成后经 CDP 读 document.cookie / localStorage / 显式取回的 token,
-- 落到保险库。人只负责在 VNC 窗口里完成交互,机器负责把结果取出来。
local function browserStack()
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
return plugin.state.browser
end
-- browserDeps 探测浏览器栈的外部依赖。返回 { ok=bool, missing={...}, present={...} }。
-- 依赖是**外部系统包**(发行版装的),插件不擅自 apt/dnf 安装 —— 那需要 root 且
-- 会改动运维的机器;如实报告缺什么,由运维或部署脚本装。deploy.sh 提供安装路径。
local BROWSER_DEPS = {
{ bin = "Xvfb", pkg = "xvfb", why = "虚拟显示" },
{ bin = "x11vnc", pkg = "x11vnc", why = "VNC 服务端" },
{ bin = "websockify", pkg = "websockify", why = "VNC→WebSocket 网关" },
{ bin = "python3", pkg = "python3", why = "CDP 取凭据" },
}
local function which(bin)
local h = io.popen("command -v " .. bin .. " 2>/dev/null")
if not h then return false end
local p = h:read("*l")
h:close()
return p ~= nil and p ~= ""
end
-- reapBrowser 回收本插件拉起的整组进程。进程是插件起的,插件负责杀 ——
-- 留一个孤儿 chromium 会一直占着显示与内存,且下次 spawn 会撞端口。
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
local function browserDeps()
local missing, present = {}, {}
for _, d in ipairs(BROWSER_DEPS) do
if which(d.bin) then present[#present + 1] = d.bin
else missing[#missing + 1] = { bin = d.bin, pkg = d.pkg, why = d.why } end
end
-- chromium 的二进制名随发行版变化(chromium / chromium-browser / google-chrome)
local chromium = nil
for _, c in ipairs({ "chromium", "chromium-browser", "google-chrome", "google-chrome-stable" }) do
if which(c) then chromium = c break end
end
if chromium then present[#present + 1] = chromium
else missing[#missing + 1] = { bin = "chromium", pkg = "chromium", why = "浏览器" } end
-- noVNC 静态资源(websockify --web 指向它)
local novnc = nil
for _, p in ipairs({ "/usr/share/novnc", "/usr/share/webapps/novnc", "/opt/novnc" }) do
local f = io.open(p .. "/vnc.html", "r")
if f then f:close() novnc = p break end
end
if not novnc then
missing[#missing + 1] = { bin = "novnc", pkg = "novnc", why = "浏览器内嵌页面" }
end
-- CDP 需要 websocket 客户端
local pyws = false
if which("python3") then
local h = io.popen("python3 -c 'import websocket' 2>/dev/null && echo yes")
if h then local r = h:read("*l") h:close() pyws = (r == "yes") end
end
if which("python3") and not pyws then
missing[#missing + 1] = { bin = "python3-websocket", pkg = "python3-websocket", why = "CDP 取凭据(websocket-client)" } end
return {
ok = #missing == 0,
missing = missing,
present = present,
chromium = chromium,
novnc = novnc,
}
end
-- gatewayPort 问内核要本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点,
-- 端口不能硬编码(非默认部署会全错)。内核 status 里带 listen(如 "0.0.0.0:8081")。
-- gatewayPort 求本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点,
-- 端口不能硬编码(非默认部署会全错)。
--
-- 端口由插件**自己**取得,不问内核:插件在信任模型里就是可信、有完整 OS 能力的
-- (能拉浏览器栈、能在磁盘上读写),读一下自己的 config.yaml 是它能力范围内的事。
-- 为了一个端口给内核加只读动作,既不必要,也污染了内核的「零厂商/零部署知识」边界。
-- 优先级:settings 显式覆盖 > config.yaml 的 listen > 环境变量 > 默认值。
local function gatewayPort()
-- ① settings:运维可显式指定(前置代理 / 反代场景,对外端口与 listen 不同)
local s = plugin.state.settings and plugin.state.settings.gateway_port
if tonumber(s) then return tonumber(s) end
-- ② 配置文件:插件与内核同机,直接读自己的配置
for _, path in ipairs({
"/etc/llmsproxy/config.yaml",
"/usr/local/etc/llmsproxy/config.yaml",
}) do
local f = io.open(path, "r")
if f then
local listen = f:read("*a")
f:close()
if listen then
local addr = listen:match("\nlisten:%s*([^\n]%S*)") or listen:match("^listen:%s*([^\n]%S*)")
if addr then
local p = addr:match(":(%d+)")
if p then return tonumber(p) end
end
end
end
end
-- ③ 实听端口:从内核自己的监听 socket 反查(配置文件缺失/被换时兜底)
do
local h = io.popen("ss -ltnp 2>/dev/null | grep 'pid=' | grep -o ':[0-9]*' | head -1")
if h then
local line = h:read("*l")
h:close()
local p = line and line:match(":(%d+)")
if p then return tonumber(p) end
end
end
return 8081
end
-- waitPort 等端口就绪(就绪探针),避免「进程起来了但还不能连」。
-- 返回 true/false,最多等 timeout_ms。
local function waitPort(host, port, timeoutMs)
local deadline = (os.time and os.time() or 0) * 1000 + timeoutMs
while true do
local h = io.popen(("bash -c 'exec 3<>/dev/tcp/%s/%d' 2>/dev/null && echo ok"):format(host, port))
local r = h and h:read("*l")
if h then h:close() end
if r == "ok" then return true end
local now = (os.time and os.time() or 0) * 1000
if now >= deadline then return false end
os.execute("sleep 0.3")
end
end
-- CDP 取凭据的 Python 桥。argv: <script> <cdp_port> <expr_file>
-- 打印一行 JSON: {"ok":true,"value":<...>} 或 {"ok":false,"error":"..."}
-- 用文件传表达式,避免 shell 引号地狱(表达式里常有引号/换行)。
local CDP_PY = [[
import json, sys, urllib.request
try:
import websocket
except Exception as e:
print(json.dumps({"ok": False, "error": "websocket-client 未安装: %s" % e}))
sys.exit(0)
port = int(sys.argv[1])
with open(sys.argv[2]) as f:
expr = f.read()
def http(path):
with urllib.request.urlopen("http://127.0.0.1:%d%s" % (port, path), timeout=10) as r:
return json.load(r)
try:
targets = http("/json/list")
except Exception as e:
print(json.dumps({"ok": False, "error": "CDP 不可达: %s" % e})); sys.exit(0)
page = None
for t in targets:
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
page = t; break
if page is None:
print(json.dumps({"ok": False, "error": "没有可用的 page target"})); sys.exit(0)
try:
ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=20)
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expr, "returnByValue": True,
"awaitPromise": True}}))
while True:
msg = json.loads(ws.recv())
if msg.get("id") == 1:
break
ws.close()
except Exception as e:
print(json.dumps({"ok": False, "error": "CDP 求值失败: %s" % e})); sys.exit(0)
res = msg.get("result", {})
if "exceptionDetails" in res:
print(json.dumps({"ok": False, "error": res["exceptionDetails"].get("text", "JS 异常")}))
sys.exit(0)
print(json.dumps({"ok": True, "value": res.get("result", {}).get("value")}))
]]
local function cdpScriptPath()
local p = "/tmp/llmsproxy-cdp-eval.py"
local f = io.open(p, "r")
if f then f:close() return p end
local w = io.open(p, "w")
if not w then return nil end
w:write(CDP_PY)
w:close()
return p
end
-- cdpEval 在服务端浏览器的当前页上求值一个 JS 表达式。
-- 返回 (value, err)。value 是 JS 返回值(经 returnByValue 序列化)。
local function cdpEval(cdpPort, expr)
local script = cdpScriptPath()
if not script then return nil, "无法写入 CDP 脚本" end
local exprFile = ("/tmp/llmsproxy-cdp-expr-%d.js"):format(cdpPort)
local w = io.open(exprFile, "w")
if not w then return nil, "无法写入表达式文件" end
w:write(expr)
w:close()
local h = io.popen(("python3 %s %d %s 2>/dev/null"):format(script, cdpPort, exprFile))
if not h then os.remove(exprFile) return nil, "python3 执行失败" end
local out = h:read("*a")
h:close()
os.remove(exprFile)
if not out or out == "" then return nil, "CDP 无输出" end
local ok, rec = pcall(json.decode, out)
if not ok or type(rec) ~= "table" then return nil, "CDP 输出无法解析: " .. tostring(out):sub(1, 200) end
if rec.ok ~= true then return nil, tostring(rec.error or "CDP 失败") end
return rec.value, nil
end
-- spawnBrowser 拉起一组服务端浏览器。返回
-- { view_url, password, cdp_port, display, vnc, ws } 或 nil, err。
-- start_url 是浏览器要打开的登录页;callback_path 是拼 redirect_uri 用的路径。
--
-- 与早期实现的差别(都是真实踩过的坑):
-- 1. **就绪探针**:不再 sleep 2 拍脑袋,各端口逐个等(X 起来 → 浏览器起 →
-- VNC 起 → websockify 起),否则 view_url 先于服务可用,iframe 白屏。
-- 2. **CDP 端口**:浏览器带 --remote-debugging-port,登录完成后可读凭据
-- (见 cdpEval / vncCollect),而不是只能人眼看屏幕。
-- 3. **网关端口来自内核**:回调地址拼的是本实例真实端口,不硬编码 8081。
-- 4. **依赖预检**:缺依赖直接给出清单(谁、干什么用、装什么包),
-- 而不是一个含糊的"进程启动失败"。
local function spawnBrowser(start_url, callback_path)
local b = browserStack()
if b.session then return nil, "已有浏览器会话在跑,请先停止" end
local deps = browserDeps()
if not deps.ok then
local miss = {}
for _, m in ipairs(deps.missing) do
miss[#miss + 1] = ("%s(%s,装 %s)"):format(m.bin, m.why, m.pkg)
end
return nil, "缺少浏览器栈依赖: " .. table.concat(miss, ", ")
end
b.seq = b.seq + 1
local seq = b.seq
-- display / 端口取号:**探测空闲**,不能按 seq 取模。
--
-- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X<n>-lock)在
-- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接
-- `Fatal: Server is already active for display 91` 退出,VNC 兜底永远起不来,
-- 而报错只显示"Xvfb 未就绪",看不出是被残留锁挡住。
-- 现在从 91 起找第一个既无锁文件、也无 socket 的号;端口同理探测空闲。
local function pickFree(start, isFree)
for n = start, start + 40 do
if isFree(n) then return n end
end
return start
end
-- 判存在必须用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,
-- io.open 对它返回 nil(打不开)→ 探测会误判"空闲"→ 又选中被残留 socket
-- 占住的号 → Xvfb `Cannot establish any listening sockets` 退出。
-- 这个坑真实踩过:残留 X91 socket 让显示器探测完全失效。
local function pathExists(path)
local ok = os.execute('[ -e "' .. path .. '" ] 2>/dev/null')
-- Lua 5.1 返回数字(0=成功),5.2+ 返回 true/nil
return ok == true or ok == 0
end
local disp = pickFree(91, function(n)
return not pathExists("/tmp/.X" .. n .. "-lock")
and not pathExists("/tmp/.X11-unix/X" .. n)
end)
local function portFree(p)
-- 用 /proc/net/tcp + tcp6 查是否有 LISTEN(十六进制端口)
for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do
local h = io.open(f, "r")
if h then
local hex = ("%04X"):format(p)
for line in h:lines() do
local lp = line:match("^%s*%d+:%s*[0-9A-F]+:([0-9A-F]+)")
if lp == hex then h:close() return false end
end
h:close()
end
end
return true
end
local vport = pickFree(59100, portFree)
local wport = pickFree(60100, portFree)
local cdpPort = pickFree(61100, portFree)
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 gwPort = gatewayPort()
-- 端口占位换成网关真实回环地址(浏览器在服务端,回调直接命中网关挂载点)
local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+",
"redirect_uri=127.0.0.1:" .. gwPort .. callback_path)
-- 每步起一个进程并等它的端口就绪(X 用 socket 探,别的用 TCP)。
local pids = {}
local function spawn(name, cmd, host, port, timeoutMs)
local h = io.popen(cmd)
local pid = h and h:read("*l")
if h then h:close() end
if not pid or pid == "" then
return false, name .. " 启动失败(cmd=" .. tostring(cmd):sub(1,80) .. ")"
end
pids[#pids + 1] = pid
-- 立刻回写到 plugin.state,**不能只在函数末尾赋值**:
-- 任何一步中途失败都要 reapBrowser(),而它读的是 b.pids。若此时 b.pids 还是
-- 旧值(空),已经起来的进程就不会被杀 → 孤儿 Xvfb/chromium 常驻。
-- 真实踩过:4 个失败的 VNC 会话留下 4 个 orphan Xvfb。
b.pids = pids
if port and port > 0 then
if not waitPort(host or "127.0.0.1", port, timeoutMs or 8000) then
return false, name .. " 端口未就绪(:" .. port .. ")"
end
end
return true, pid
end
local chromium = deps.chromium
local novnc = deps.novnc
local steps = {
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%d.log 2>&1 & echo $!"):format(disp, seq), nil, 0, 0 },
{ "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d "
.. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* "
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%d.log 2>&1 & echo $!"):format(
chromium, seq, cdpPort, disp, url, seq), "127.0.0.1", cdpPort, 15000 },
{ "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet "
.. ">/tmp/llmsproxy-x11vnc-%d.log 2>&1 & echo $!"):format(
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, seq), "127.0.0.1", vport, 8000 },
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%d.log 2>&1 & echo $!"):format(
novnc, bind, wport, vport, seq), bind, wport, 8000 },
}
-- ① 先起 Xvfb(其余三步依赖 X display)
local xvfbStep = steps[1]
local xok, xerr = spawn(xvfbStep[1], xvfbStep[2], nil, 0, 0)
if not xok then
reapBrowser()
return nil, xerr
end
-- X 的 socket 在 /tmp/.X11-unix/X<n>,等它出现再起浏览器
local unixSock = ("/tmp/.X11-unix/X%d"):format(disp)
local xdeadline = (os.time and os.time() or 0) + 10
while true do
-- 同 pathExists 的理由:X socket 是 socket 文件,io.open 打不开,
-- 用它判"就绪"会永远等不到(这个坑在上面选号处也踩过一次)。
if pathExists(unixSock) then break end
if (os.time and os.time() or 0) >= xdeadline then
reapBrowser()
return nil, "Xvfb 未就绪(" .. unixSock .. " 未出现)"
end
os.execute("sleep 0.3")
end
-- ② 起浏览器/VNC/websockify(三步都有 TCP 端口可探)
for i = 2, #steps do
local st = steps[i]
local ok, err = spawn(st[1], st[2], st[3], st[4], st[5])
if not ok then
reapBrowser()
return nil, err
end
end
b.pids = pids
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq }
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,
cdp_port = cdpPort,
display = disp,
}
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 = "服务端浏览器不可用;运行 channels/deps/status 看缺什么依赖"
logEvent("服务端浏览器启动失败: " .. berr)
else
out.view_url = bs.view_url
out.session.cdp_port = bs.cdp_port
sess.cdp_port = bs.cdp_port
-- 登录页需要从服务端浏览器完成;用户交互后由 collect_credentials 取凭据
sess.collect_expr = plat.collect_expr
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
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 = "注入测试/扩展平台定义(运行时,不落盘)" },
-- 服务端浏览器栈(VNC 兜底)的运维面
{ path = "channels/deps/status", method = "GET", handler = "deps_status",
roles = { "admin" }, description = "浏览器栈依赖预检(缺什么、装哪个包)" },
{ path = "channels/browser/status", method = "GET", handler = "browser_status",
roles = { "admin" }, description = "服务端浏览器会话状态" },
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
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 want = args.disabled and true or false
setModelDisabled(plat.id, mid, want)
-- 重同步该平台的全部源,让模型开关立即生效
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true, disabled = want } }
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
markDirty()
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
markDirty()
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
markDirty()
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
markDirty()
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
markDirty()
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
markDirty()
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
markDirty()
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
markDirty()
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
-- ============================================================================
-- 服务端浏览器栈的运维面(依赖预检 / 取凭据 / 停栈)
-- ============================================================================
-- deps_status:如实报告浏览器栈缺什么依赖。插件**不擅自**安装系统包(需要 root
-- 且会改动运维的机器);如实报告 + 给出安装命令,由运维或 deploy.sh 执行。
function plugin.deps_status(req)
local d = browserDeps()
local list = {}
for _, m in ipairs(d.missing) do
list[#list + 1] = { bin = m.bin, pkg = m.pkg, why = m.why }
end
return { status = 200, json = {
ok = d.ok,
missing = list,
present = d.present,
chromium = d.chromium,
novnc = d.novnc,
-- 安装入口:发行包自带脚本(识别 apt/dnf/yum/apk),插件**不执行**它
-- (装系统包需要 root,且 chromium 是安全面较大的组件,不该由插件代劳)。
install_hint = d.ok and "" or
"sudo bash /usr/share/llmsproxy/install-browser-stack.sh"
.. " 或沿源码部署时: sudo bash packaging/install-browser-stack.sh"
.. "(脚本会自动识别 apt/dnf/yum/apk;也可手工装 Xvfb x11vnc websockify noVNC chromium python3-websocket)",
} }
end
-- collect_credentials:从**运行中的**服务端浏览器取凭据(CDP)。
--
-- 为什么需要它:VNC 只把浏览器呈现给人,登录完成后必须把结果**取出来**才能
-- 变成上游源。取法分两种:
-- ① 平台声明了 collect_expr —— 在该页上求值该 JS(返回 token 字符串或对象)
-- ② 未声明 —— 回退到通用提取:localStorage / sessionStorage / cookie 全量
-- 查到什么存什么,由调用方/运维判断哪个是凭据(不猜语义,不编造)。
-- 取到的凭据经 complete_login 走正常入库路径(含 account_id → 池路径)。
function plugin.collect_credentials(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 b = browserStack()
if not b.session then return { status = 400, json = { error = "没有运行中的浏览器会话" } } end
local cdp = b.session.cdp
if not cdp then return { status = 400, json = { error = "浏览器会话未启用 CDP" } } end
local expr = (args and args.expr) or plat.collect_expr
local value, err
if expr then
value, err = cdpEval(cdp, expr)
if err then return { status = 400, json = { error = "取值失败: " .. err } } end
end
-- 通用提取:无论有无 collect_expr 都跑一遍,作为兜底与补充。
local generic, gerr = cdpEval(cdp, [[
(function(){
var out = {localStorage:{}, sessionStorage:{}, cookie:document.cookie, url:location.href};
try { for (var i=0;i<localStorage.length;i++){var k=localStorage.key(i);out.localStorage[k]=localStorage.getItem(k);} } catch(e){}
try { for (var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i);out.sessionStorage[k]=sessionStorage.getItem(k);} } catch(e){}
return JSON.stringify(out);
})()
]])
local rec = {}
if value ~= nil then
if type(value) == "table" then
for k, v in pairs(value) do rec[k] = v end
else
rec.collected = value
end
end
if generic then
local ok, parsed = pcall(json.decode, generic)
if ok and type(parsed) == "table" then
rec.browser = parsed
-- 常见位置的启发式提取(找到就用,找不到不强求)
local ls = parsed.localStorage or {}
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token", "Authorization" }) do
if type(ls[k]) == "string" and ls[k] ~= "" and rec.token == nil then rec.token = ls[k] end
end
local ss = parsed.sessionStorage or {}
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token" }) do
if type(ss[k]) == "string" and ss[k] ~= "" and rec.token == nil then rec.token = ss[k] end
end
if rec.token == nil and type(parsed.cookie) == "string" then
rec.token = parsed.cookie:match("(access_token=[^;]+)")
or parsed.cookie:match("(token=[^;]+)")
end
end
elseif gerr then
rec.generic_error = gerr
end
if next(rec) == nil then
return { status = 200, json = { ok = false, error = "没有取到任何凭据(登录是否真的完成了?)" } }
end
-- 不自动入库:取到什么原样返回,由调用方确认后再 complete_login。
-- 自动写入会把"页面上恰好存在的某个 token"当成凭据,那是最糟的猜测。
return { status = 200, json = { ok = true, collected = rec } }
end
-- stop_browser:回收服务端浏览器栈(会话结束/用户放弃时)。
function plugin.stop_browser(req)
local had = browserStack().session ~= nil
reapBrowser()
if had then logEvent("服务端浏览器已回收") end
return { status = 200, json = { ok = true, stopped = had } }
end
-- browser_status:当前浏览器会话状态(给 UI 显示)。
function plugin.browser_status(req)
local b = browserStack()
local s = b.session
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
end
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
markDirty()
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
async function call(path, method, body) {
const r = await fetch("/api/plugins/http/" + path, {
method: method || "GET",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
});
return await r.json().catch(() => ({}));
}
// 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