diff --git a/README.md b/README.md index bb3c574..75bd8a9 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | MiniApp | What it does | Author | | --- | --- | --- | +| [蹬王之王 / Expiry Sentinel](plugins/hylouis233/expiry-sentinel/) | Track MiniMax Code credit and reset card expiry, then automatically spend whichever lapses first | [hylouis233](https://github.com/hylouis233) | | [Token Usage Board](plugins/amszuidas/mcode-token-usage-board/) | Explore local Token usage by date, model, and session, including input, output, and cache usage | [amszuidas](https://github.com/amszuidas) | | [Token Usage Board](plugins/yanhy2000/mcode-usage-monitor/) | Watch local Token usage, output speed, and cache hit rate in near real time; filter by time range, model, and session | [yanhy2000](https://github.com/yanhy2000) | | [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 241b298..c0f493a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,6 +60,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | MiniApp | 功能 | 作者 | | --- | --- | --- | +| [蹬王之王](plugins/hylouis233/expiry-sentinel/README.zh-CN.md) | 追踪 MiniMax Code 积分与重置卡的到期时间,自动按「先过期先用」消耗 | [hylouis233](https://github.com/hylouis233) | | [Token 用量看板](plugins/amszuidas/mcode-token-usage-board/README.zh-CN.md) | 按时间、模型和会话查看本机 Token 用量,包含输入、输出和缓存用量 | [amszuidas](https://github.com/amszuidas) | | [Token 用量看板](plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md) | 近实时查看本机 Token 用量、输出速度与缓存命中率,可按时间范围、模型和会话筛选 | [yanhy2000](https://github.com/yanhy2000) | | [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) | diff --git a/plugins/hylouis233/expiry-sentinel/.minimax-plugin/plugin.json b/plugins/hylouis233/expiry-sentinel/.minimax-plugin/plugin.json new file mode 100644 index 0000000..d11db59 --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/.minimax-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "name": "expiry-sentinel", + "displayName": "蹬王之王", + "version": "1.0.0", + "description": "实时追踪 MiniMax Code 积分与重置卡的到期时间,自动按「先过期先用」决定用卡还是留积分,榨干最后一点算力。", + "author": "hylouis233", + "icon": "icon.png", + "category": "Productivity", + "exampleQueries": [ + "打开蹬王之王", + "看看我的积分和重置卡什么时候过期", + "我的额度快用完了,该用重置卡还是留着?" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/hylouis233/expiry-sentinel/LICENSE b/plugins/hylouis233/expiry-sentinel/LICENSE new file mode 100644 index 0000000..1c08e39 --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hylouis233 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/hylouis233/expiry-sentinel/README.md b/plugins/hylouis233/expiry-sentinel/README.md new file mode 100644 index 0000000..b1e7d2b --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/README.md @@ -0,0 +1,147 @@ +
+ +蹬王之王 + +# 蹬王之王 · Expiry Sentinel + +**Track what expires first, then spend exactly that.** + +English | [简体中文](README.zh-CN.md) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +
+ +--- + +MiniMax Code credits and reset cards both expire on a timer. Left alone, the +shortest-dated balance quietly evaporates while you keep using the one that +lasts longest. **蹬王之王** shows every balance with its expiry date, then spends +whichever runs out first — so nothing is wasted and nothing expires unused. + +## Screenshot + +
+Dashboard: expiry alerts, quota, credit and card expiry +
+ +The header answers the only question that matters: *how much is about to expire?* +Below it, the 5-hour quota, total credit balance, the next expiry, and how many +reset cards are left. + +
+Reset card expiry list and usage tally +
+ +Credit detail, reset cards by expiry, and platform credit pools each get their own +table, sorted by the date they lapse. + +> **Note on the screenshots.** They were captured from a real account, so the +> balances, dates, and totals in them are genuine numbers rather than samples. +> No card IDs are shown anywhere in the UI. + +## How the decision works + +When the 5-hour or weekly quota runs out, requests start spending credits. That is +the moment to act, and the app compares two dates: + +| Situation | Decision | Why | +| --- | --- | --- | +| Reset card expires **before** the oldest credit | **Spend the card** | Restores the quota, so the longer-lived credit is preserved | +| Credit expires **before** the card | **Keep the card** | Let the credit lapse first; the card still has value later | +| Quota not exhausted | **Do nothing** | No credits are being burned yet | + +Both the Node service and the background daemon implement this rule, and a +parity test compares them character by character so the two cannot drift apart. + +## Install + +Copy this entire directory — including the hidden `.minimax-plugin` directory — +into your MiniMax Code data directory. By default that is `.minimax` in your home +folder, so the default path is: + +```text +/plugins/expiry-sentinel/ +``` + +If you configured a different data directory, use that instead. Restart MiniMax +Code, confirm the plugin is recognized and enabled, then open **蹬王之王**, or ask +the Agent to open it. + +## Use it + +**Watch only (default).** Open the board and read it. Nothing is spent. + +**Auto-spend inside the page.** Turn on 自动执行 and pick an interval (seconds or +minutes). The page decides and spends on that interval while it stays open. +「立即判定一次」 always triggers one check immediately, with no throttle. + +**Auto-spend around the clock.** Turn on 后台服务. This installs a launchd agent +that keeps running with the page closed, and starts at login on macOS. The page +shows its PID and lets you stop it. + +Both paths share one decision rule, and the server deduplicates by idempotency +key, so a card is never spent twice even if both are active. + +## Background daemon (optional) + +The `daemon/` directory contains a Rust rewrite of the same decision logic, +built for low overhead while it runs unattended: + +```bash +cd daemon +cargo build --release +``` + +It needs a token. It obtains one through the desktop app's OAuth lease socket +(`~/.minimax/run/mcode-auth-lease-v1.sock`), so it must run while MiniMax Code is +running. Set `MCODE_TOKEN` to use a fixed token instead. + +Typical resident memory is under 1 MB, and it polls one endpoint per cycle and +caches the rest by how often each changes. + +## Tested environment + +- MiniMax Code desktop app on macOS (Apple Silicon) +- Verified against a live account: quota polling, credit detail, card listing, + card spending, and the auto-spend path that fired on quota exhaustion +- Cross-platform behaviour and other client versions are **unconfirmed** — the + daemon path uses launchd and has only been exercised on macOS +- The endpoints are private APIs. They may change without notice, and the app + will report a parse or read error rather than invent data when they do + +## Data & access + +**Files read.** The desktop app's OAuth lease capability file +(`~/.minimax/run/mcode-auth-lease-v1.cap`) and the lease socket beside it, to +obtain the same session the desktop app uses. These paths are an internal +implementation detail of the client, not a documented interface; the background +daemon will stop working if they change. + +**Files written.** + +- `~/.config/mcode-reset-card/token` — only when you provide `MCODE_TOKEN` +- `~/.config/mcode-reset-card/bg-state.json` — the daemon's record of which cards + it already attempted, so a restart cannot spend the same card twice +- `~/Library/LaunchAgents/com.local.mcode-reset-card.plist` — written only when you + enable 后台服务 + +**Network hosts.** Two, both MiniMax: + +- `https://www.minimaxi.com` — quota, platform credit pools, reset cards +- `https://agent.minimaxi.com` — MiniMax Code credit detail, membership summary + +**Processes spawned.** `launchctl load` / `unload` for the background service, +and `launchctl list` to read its status. Nothing else. + +**Agent turns.** None. The app never invokes the Agent. + +**Secrets.** The session token lives only in the Node process and the daemon. +It is never placed in HTML, client JavaScript, logs, or API responses. + +**Spending is a real write.** Enabling auto-spend spends real reset cards on your +account. Start with the dashboard in watch-only mode. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/plugins/hylouis233/expiry-sentinel/README.zh-CN.md b/plugins/hylouis233/expiry-sentinel/README.zh-CN.md new file mode 100644 index 0000000..fa7500b --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/README.zh-CN.md @@ -0,0 +1,128 @@ +
+ +蹬王之王 + +# 蹬王之王 · Expiry Sentinel + +**看清谁先过期,就先用哪一个。** + +[English](README.md) | 简体中文 + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +
+ +--- + +MiniMax Code 的积分和重置卡都会到期。放着不管,最快到期的那笔会悄悄作废, +而你一直在用最耐放的那笔。**蹬王之王**把每笔余额和到期日摊开给你看,然后 +「先过期先用」——不浪费,也不过期。 + +## 界面 + +
+看板:到期预警、额度、积分与重置卡到期 +
+ +顶部只回答一个最要紧的问题:**有多少快要过期了**。下面是 5 小时限额、积分总余额、 +最早到期日和剩余重置卡数量。 + +
+重置卡到期列表与已用统计 +
+ +积分明细、重置卡、平台积分池各一张表,全部按到期日排序。 + +> **关于截图**:截图取自真实账号,因此其中的余额、日期与合计都是真实数据, +> 并非示意样例。界面任何位置都不展示卡号。 + +## 决策规则 + +5 小时或周限额耗尽时,请求开始扣积分——这就是该动手的时刻。应用会比两个日期: + +| 情况 | 决策 | 原因 | +| --- | --- | --- | +| 重置卡比最早到期的积分**更早**过期 | **用卡** | 重置额度,保住更耐放的积分 | +| 积分比重置卡**更早**过期 | **留卡** | 让积分先消耗掉,卡留到以后更有价值 | +| 额度未耗尽 | **不动** | 还没在烧积分 | + +Node 服务和后台守护进程实现同一套规则,并有逐字比对的一致性校验,防止两边漂移。 + +## 安装 + +把整个目录(含隐藏的 `.minimax-plugin` 目录)复制到 MiniMax Code 的数据目录。 +默认为用户目录下的 `.minimax`,即: + +```text +/plugins/expiry-sentinel/ +``` + +如果你配置了其他数据目录,请用那个目录。重启 MiniMax Code,确认插件已被识别并启用, +然后打开 **蹬王之王**,也可以直接让 Agent 帮你打开。 + +## 使用 + +**只看不动(默认)。** 打开看板读数据,不消耗任何东西。 + +**页面内自动执行。** 打开「自动执行」并设置间隔(秒或分钟)。页面开着时按间隔 +判定并执行。「立即判定一次」可随时手动触发,无节流。 + +**全天候后台执行。** 打开「后台服务」。它会安装一个 launchd 代理,关掉页面也在跑, +macOS 上开机自启。页面上能看到它的 PID,也可以随时停掉。 + +两条路径共用同一套决策规则,服务端按幂等键去重——即使同时开着,卡也不会被扣两次。 + +## 后台守护进程(可选) + +`daemon/` 里是同一套决策逻辑的 Rust 版本,为长时间无人值守而写: + +```bash +cd daemon +cargo build --release +``` + +它需要 token:通过桌面端的 OAuth lease socket +(`~/.minimax/run/mcode-auth-lease-v1.sock`)获取,也就是说 MiniMax Code 需要在运行。 +也可以设置 `MCODE_TOKEN` 使用固定凭据。 + +实测常驻内存不到 1 MB,每轮只请求一个接口,其余按各自变化频率缓存。 + +## 测试环境 + +- macOS(Apple Silicon)上的 MiniMax Code 桌面端 +- 已在真实账号验证:额度轮询、积分明细、卡列表、扣卡,以及额度耗尽时触发的自动执行 +- **未验证**:跨平台表现与其他客户端版本——守护进程走 launchd,目前只在 macOS 上跑过 +- 所用接口是私有接口,可能随时变化;接口变化时应用会如实报错,不会编造数据 + +## 数据与访问 + +**读取的文件。** 桌面端的 OAuth lease capability 文件 +(`~/.minimax/run/mcode-auth-lease-v1.cap`)及其同目录的 lease socket, +用于获取与桌面端相同的登录态。这些路径是客户端内部实现细节,并非公开接口; +一旦变化,后台守护进程会失效。 + +**写入的文件。** + +- `~/.config/mcode-reset-card/token` —— 仅当你提供 `MCODE_TOKEN` 时 +- `~/.config/mcode-reset-card/bg-state.json` —— 守护进程记录已尝试过的卡, + 避免重启后重复扣同一张 +- `~/Library/LaunchAgents/com.local.mcode-reset-card.plist` —— 仅在启用「后台服务」时写入 + +**网络地址。** 两个,均为 MiniMax: + +- `https://www.minimaxi.com` —— 额度、平台积分池、重置卡 +- `https://agent.minimaxi.com` —— MiniMax Code 积分明细、会员汇总 + +**启动的进程。** 仅 `launchctl load` / `unload`(后台服务)与 `launchctl list`(读状态)。 + +**Agent 调用。** 无。本应用从不调用 Agent。 + +**凭据。** 会话 token 只存在于 Node 进程和守护进程内存中,绝不进入 HTML、 +客户端 JavaScript、日志或 API 响应。 + +**扣卡是真实写操作。** 开启自动执行会在你的账号上真实消耗重置卡。 +建议先用只读模式熟悉一下。 + +## 许可证 + +MIT —— 见 [LICENSE](LICENSE)。 diff --git a/plugins/hylouis233/expiry-sentinel/daemon/Cargo.lock b/plugins/hylouis233/expiry-sentinel/daemon/Cargo.lock new file mode 100644 index 0000000..5c0dc4d --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/daemon/Cargo.lock @@ -0,0 +1,833 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reset-card-daemon" +version = "1.0.0" +dependencies = [ + "serde", + "serde_json", + "ureq", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/hylouis233/expiry-sentinel/daemon/Cargo.toml b/plugins/hylouis233/expiry-sentinel/daemon/Cargo.toml new file mode 100644 index 0000000..73b8afd --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/daemon/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "reset-card-daemon" +version = "1.0.0" +edition = "2024" + +[dependencies] +# HTTP 客户端:手写 TLS 容易出错且难维护,用成熟实现更可靠 +ureq = { version = "3", default-features = false, features = ["rustls", "json", "gzip"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" # 体积优先:后台常驻服务不需要极致速度 +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/plugins/hylouis233/expiry-sentinel/daemon/src/api.rs b/plugins/hylouis233/expiry-sentinel/daemon/src/api.rs new file mode 100644 index 0000000..b9e7921 --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/daemon/src/api.rs @@ -0,0 +1,313 @@ +//! 账号 API 客户端 + OAuth lease token。 +//! +//! 域名划分是实测结论,不是猜的: +//! 开放平台 / 额度 / 重置卡 → www.minimaxi.com(agent 域对这些路径 404 或返回 HTML) +//! Code 积分 / 会员信息 → agent.minimaxi.com(platform 域返回非 JSON) + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub const BASE_CN: &str = "https://www.minimaxi.com"; +pub const BASE_GLOBAL: &str = "https://platform.minimax.io"; +pub const CODE_BASE: &str = "https://agent.minimaxi.com"; + +const LEASE_SOCK: &str = ".minimax/run/mcode-auth-lease-v1.sock"; +const LEASE_CAP: &str = ".minimax/run/mcode-auth-lease-v1.cap"; + +const TOKEN_TTL_MS: u64 = 30 * 60 * 1000; +const TOKEN_REFRESH_MARGIN_MS: u64 = 5 * 60 * 1000; + +pub fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[derive(Debug)] +pub enum ApiError { + Unauthorized(String), + Network(String), + Business(String), +} + +impl ApiError { + pub fn message(&self) -> &str { + match self { + ApiError::Unauthorized(m) | ApiError::Network(m) | ApiError::Business(m) => m, + } + } + pub fn is_unauthorized(&self) -> bool { + matches!(self, ApiError::Unauthorized(_)) + } +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message()) + } +} + +fn home() -> PathBuf { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/".into())) +} + +fn random_hex(nbytes: usize) -> String { + let mut buf = vec![0u8; nbytes]; + if std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .is_err() + { + buf[..8].copy_from_slice(&now_ms().to_be_bytes()); + } + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +/// 向桌面端的 lease socket 取一个短期 access token。 +/// 协议:4 字节大端长度前缀 + JSON。详见 packages/oauth-lease-protocol。 +pub fn acquire_lease_token() -> Option { + let capability = std::fs::read_to_string(home().join(LEASE_CAP)) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty())?; + let sock_path = home().join(LEASE_SOCK); + if !sock_path.exists() { + return None; + } + + let payload = serde_json::json!({ + "version": 1, + "requestId": random_hex(16), + "capability": capability, + "method": "lease", + "minValidityMs": 120_000i64, + }) + .to_string(); + + let body = payload.as_bytes(); + let mut frame = Vec::with_capacity(4 + body.len()); + frame.extend_from_slice(&(body.len() as u32).to_be_bytes()); + frame.extend_from_slice(body); + + let mut stream = UnixStream::connect(&sock_path).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?; + stream.set_write_timeout(Some(Duration::from_secs(5))).ok()?; + stream.write_all(&frame).ok()?; + + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).ok()?; + let size = u32::from_be_bytes(len_buf) as usize; + if size > 1 << 20 { + return None; + } + let mut buf = vec![0u8; size]; + stream.read_exact(&mut buf).ok()?; + + let resp: serde_json::Value = serde_json::from_slice(&buf).ok()?; + if resp.get("ok").and_then(|v| v.as_bool()) != Some(true) { + return None; + } + let result = resp.get("result")?; + if result.get("method").and_then(|v| v.as_str()) != Some("lease") { + return None; + } + result + .get("accessToken") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// token 来源:环境变量 → 本地文件 → lease socket +pub fn resolve_token() -> Option { + for env in ["MCODE_TOKEN", "MINIMAX_TOKEN"] { + if let Ok(v) = std::env::var(env) { + let t = v.trim().to_string(); + if !t.is_empty() { + return Some(t); + } + } + } + if let Ok(t) = std::fs::read_to_string(home().join(".config/mcode-reset-card/token")) { + let t = t.trim().to_string(); + if !t.is_empty() { + return Some(t); + } + } + acquire_lease_token() +} + +/// ureq Agent 自带 keep-alive 连接池:后台每轮打多个 API, +/// 复用连接省掉每轮的 TLS 握手 —— 这是常驻进程最大的一笔固定开销。 +pub struct Client { + agent: ureq::Agent, + token: String, + cached_at: u64, +} + +impl Client { + pub fn new(base: &str) -> Result { + let token = resolve_token().ok_or_else(|| { + ApiError::Unauthorized( + "未获取到登录态,请确认 MiniMax Code 桌面端正在运行且已登录".into(), + ) + })?; + + let config = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(20))) + .timeout_connect(Some(Duration::from_secs(5))) + .max_idle_connections(8) + .build(); + let agent: ureq::Agent = config.into(); + + // 用一个明确的 base 做首连校验,确保 base 可用 + let c = Client { agent, token, cached_at: now_ms() as u64 }; + let _ = base; + Ok(c) + } + + fn token(&mut self) -> String { + let age = (now_ms() as u64).saturating_sub(self.cached_at); + if age >= TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS { + if let Some(t) = acquire_lease_token() { + self.token = t; + self.cached_at = now_ms() as u64; + } + } + self.token.clone() + } + + fn base_for(&self, code_domain: bool) -> &'static str { + if code_domain { CODE_BASE } else { BASE_CN } + } + + pub fn get(&mut self, path: &str, code_domain: bool) -> Result { + self.do_request("GET", path, None, code_domain) + } + + pub fn post( + &mut self, + path: &str, + body: &serde_json::Value, + code_domain: bool, + ) -> Result { + self.do_request("POST", path, Some(body), code_domain) + } + + fn do_request( + &mut self, + method: &str, + path: &str, + body: Option<&serde_json::Value>, + code_domain: bool, + ) -> Result { + let base = self.base_for(code_domain); + let url = format!("{base}{path}"); + + // 鉴权失败重取 token 再试一次 + for attempt in 0..2 { + let token = self.token(); + let auth = format!("Bearer {token}"); + let accept = "application/json"; + let ua = "MiniMaxCode"; + + let result = match (method, body) { + ("GET", None) => self + .agent + .get(&url) + .header("Authorization", &auth) + .header("Accept", accept) + .header("User-Agent", ua) + .call(), + ("POST", Some(b)) => self + .agent + .post(&url) + .header("Authorization", &auth) + .header("Accept", accept) + .header("User-Agent", ua) + .send_json(b.clone()), + _ => return Err(ApiError::Network(format!("不支持的请求 {method}"))), + }; + + match result { + Ok(resp) => { + let text = resp + .into_body() + .read_to_string() + .map_err(|e| ApiError::Network(format!("读取响应体失败: {e}")))?; + let value: serde_json::Value = + serde_json::from_str(&text).map_err(json_err)?; + return check_business(value); + } + Err(ureq::Error::StatusCode(401)) | Err(ureq::Error::StatusCode(403)) + if attempt == 0 => + { + self.cached_at = 0; + continue; + } + Err(ureq::Error::StatusCode(code)) => { + return Err(ApiError::Network(format!("HTTP {code}"))); + } + Err(e) => return Err(ApiError::Network(format!("{e}"))), + } + } + Err(ApiError::Unauthorized("登录态失效".into())) + } +} + +fn json_err(e: serde_json::Error) -> ApiError { + ApiError::Network(format!("JSON 解析失败: {e}")) +} + +/// 检查 `base_resp.status_code`。 +/// +/// 这是踩过的坑:`{"total":0,"base_resp":{"status_code":2013,"status_msg":"invalid params"}}` +/// 是**错误**,不是空结果。必须在这里拦下,否则会把接口错误读成「没有记录」。 +fn check_business(value: serde_json::Value) -> Result { + if let Some(code) = value + .get("base_resp") + .and_then(|b| b.get("status_code")) + .and_then(|v| v.as_i64()) + { + if code != 0 { + let msg = value + .get("base_resp") + .and_then(|b| b.get("status_msg")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + if code == 1004 { + return Err(ApiError::Unauthorized(format!("{msg} (code={code})"))); + } + return Err(ApiError::Business(format!("{msg} (code={code})"))); + } + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn business_error_detected() { + let v = serde_json::json!({"total":0,"base_resp":{"status_code":2013,"status_msg":"invalid params"}}); + match check_business(v) { + Err(ApiError::Business(m)) => assert!(m.contains("invalid params")), + other => panic!("应当识别为业务错误,实际 {other:?}"), + } + } + + #[test] + fn success_passes_through() { + let v = serde_json::json!({"base_resp":{"status_code":0},"data":[1,2]}); + assert!(check_business(v).is_ok()); + } + + #[test] + fn unauthorized_code() { + let v = serde_json::json!({"base_resp":{"status_code":1004,"status_msg":"not login"}}); + assert!(matches!(check_business(v), Err(ApiError::Unauthorized(_)))); + } +} diff --git a/plugins/hylouis233/expiry-sentinel/daemon/src/decision.rs b/plugins/hylouis233/expiry-sentinel/daemon/src/decision.rs new file mode 100644 index 0000000..6c3861b --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/daemon/src/decision.rs @@ -0,0 +1,408 @@ +//! 额度与到期判定。 +//! +//! 这些分支与 Mini App「蹬王之王」的 `decide()` 一一对应, +//! 由 `verify-parity.mjs` 逐字比对「动作 + 理由文本」来防止漂移。 +//! **改动任何一条理由文案,两侧都要同步改,并重跑校验器。** + +use serde::Deserialize; + +pub const QUOTA_FIVE_HOUR: i64 = 1; +pub const QUOTA_WEEKLY: i64 = 2; +pub const QUOTA_VIDEO: i64 = 3; + +/// 一个限额维度的用量。 +#[derive(Debug, Clone, Copy)] +pub struct QuotaState { + pub quota_type: i64, + pub total: f64, + pub used: f64, + pub is_unlimited: bool, +} + +impl QuotaState { + /// 是否已耗尽。不限量或总量为 0 都不算耗尽。 + pub fn exhausted(&self) -> bool { + if self.is_unlimited || self.total <= 0.0 { + return false; + } + self.used >= self.total + } + + pub fn remaining_percent(&self) -> f64 { + if self.is_unlimited || self.total <= 0.0 { + return 100.0; + } + ((self.total - self.used) / self.total * 100.0).max(0.0) + } + + pub fn label(&self) -> &'static str { + match self.quota_type { + QUOTA_FIVE_HOUR => "5小时限额", + QUOTA_WEEKLY => "周限额", + QUOTA_VIDEO => "视频限额", + _ => "未知限额", + } + } + + pub fn describe(&self) -> String { + if self.is_unlimited { + return format!("{}=不限量", self.label()); + } + format!( + "{}=已用{:.0}% / 剩余{:.0}%", + self.label(), + self.used, + self.remaining_percent() + ) + } +} + +/// 金额字符串(可能带千分位)转数字。 +pub fn amount(value: &serde_json::Value) -> f64 { + match value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + serde_json::Value::String(s) => { + let cleaned: String = s.chars().filter(|c| *c != ',').collect(); + cleaned.trim().parse::().unwrap_or(0.0) + } + _ => 0.0, + } +} + +/// 百分数字符串(如 "71%")转数字。 +/// +/// 接口返回的就是 `"71%"` 这种带百分号的字符串,直接 `parse::()` 会失败并 +/// 静默退回 0,把「已用 71%」误判成「已用 0%」。这里必须先剥掉 `%`。 +pub fn percent(value: &serde_json::Value) -> f64 { + match value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + serde_json::Value::String(s) => s.trim().trim_end_matches('%').parse::().unwrap_or(0.0), + _ => 0.0, + } +} + +#[derive(Deserialize)] +pub struct ModelRemains { + pub model_name: String, + #[serde(default)] + pub current_interval_total_percent: serde_json::Value, + #[serde(default)] + pub current_interval_used_percent: serde_json::Value, + #[serde(default)] + pub current_interval_status: i64, + #[serde(default)] + pub current_interval_total_count: f64, + #[serde(default)] + pub current_interval_usage_count: f64, + #[serde(default)] + pub current_weekly_total_percent: serde_json::Value, + #[serde(default)] + pub current_weekly_used_percent: serde_json::Value, + #[serde(default)] + pub current_weekly_status: i64, + #[serde(default)] + pub end_time: Option, +} + +#[derive(Deserialize)] +pub struct RemainsPayload { + #[serde(default)] + pub model_remains: Vec, +} + +pub fn parse_quotas(payload: &RemainsPayload) -> Vec { + let mut out = Vec::with_capacity(3); + + let general = payload + .model_remains + .iter() + .find(|m| m.model_name == "general") + .or_else(|| payload.model_remains.first()); + + if let Some(g) = general { + let total = { + let t = percent(&g.current_interval_total_percent); + if t > 0.0 { t } else { 100.0 } + }; + out.push(QuotaState { + quota_type: QUOTA_FIVE_HOUR, + total, + used: percent(&g.current_interval_used_percent), + is_unlimited: g.current_interval_status == 3, + }); + out.push(QuotaState { + quota_type: QUOTA_WEEKLY, + total: { + let t = percent(&g.current_weekly_total_percent); + if t > 0.0 { t } else { 100.0 } + }, + used: percent(&g.current_weekly_used_percent), + is_unlimited: g.current_weekly_status == 3, + }); + } + + // 视频额度:只有非 general 且计数 > 0 的模型 + if let Some(v) = payload + .model_remains + .iter() + .find(|m| m.model_name != "general" && m.current_interval_total_count > 0.0) + { + out.push(QuotaState { + quota_type: QUOTA_VIDEO, + total: v.current_interval_total_count, + used: v.current_interval_usage_count, + is_unlimited: v.current_interval_status == 3, + }); + } + + out +} + +/// 一张可用于扣卡的重置卡。 +#[derive(Debug, Clone)] +pub struct UsableCard { + pub card_id: String, + pub source_type: String, + pub out_biz_no: String, + pub expire_time: i64, +} + +/// 扣卡决策的动作。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// 用重置卡重置额度 + UseCard, + /// 不用卡,让积分先消耗 + KeepCredit, + /// 额度充足,不动手 + Idle, + /// 既没积分也没卡,等着 + Wait, +} + +impl Action { + pub fn as_str(&self) -> &'static str { + match self { + Action::UseCard => "use-card", + Action::KeepCredit => "keep-credit", + Action::Idle => "idle", + Action::Wait => "wait", + } + } +} + +pub struct Decision { + pub action: Action, + pub reason: String, +} + +/// 毫秒时间戳 → "10月25日"。与 Python 的 `_fmt_day` 同一时区口径。 +pub fn fmt_day(ms: Option) -> String { + let Some(ms) = ms else { + return "未知".to_string(); + }; + if ms == 0 { + return "未知".to_string(); + } + // 本地时区;固定 UTC 会导致跨日错位,必须借 localtime_r + let (m, d) = local_md(ms.div_euclid(1000)); + if m == 0 { + return "未知".to_string(); + } + format!("{m}月{d}日") +} + +/// 把 Unix 秒换算成本地时区的 (月, 日)。 +/// +/// 桌面端与 Python 版都用本地时区展示;这里手算避免引入 chrono 依赖。 +/// libc 的 `struct tm`,只需本地时区换算用到的字段。 +#[repr(C)] +#[derive(Default)] +struct Tm { + tm_sec: i32, tm_min: i32, tm_hour: i32, tm_mday: i32, + tm_mon: i32, tm_year: i32, tm_wday: i32, tm_yday: i32, tm_isdst: i32, + tm_gmtoff: i64, tm_zone: *const u8, +} + +#[repr(C)] +struct TimeT(i64); + +unsafe extern "C" { + fn localtime_r(timep: *const TimeT, result: *mut Tm) -> *mut Tm; +} + +/// 把 Unix 秒换算成本地时区结构。 +fn secs_to_local(secs: i64) -> Option { + let t = TimeT(secs); + let mut tm = Tm::default(); + unsafe { + if localtime_r(&t, &mut tm).is_null() { + return None; + } + } + Some(tm) +} + +/// 本地时区的 (月, 日),供日志使用。 +pub fn local_md(secs: i64) -> (u32, u32) { + secs_to_local(secs) + .map(|t| (t.tm_mon as u32 + 1, t.tm_mday as u32)) + .unwrap_or((0, 0)) +} + +/// 本地时区的 (时, 分)。 +pub fn local_hm(secs: i64) -> (u32, u32) { + secs_to_local(secs) + .map(|t| (t.tm_hour as u32, t.tm_min as u32)) + .unwrap_or((0, 0)) +} + +/// 后台扣卡决策(纯函数,不触碰网络)。 +/// +/// 动作 ∈ {use-card, keep-credit, idle, wait}。 +/// +/// 分支与 Mini App 的 `decide()` 刻意同构: +/// 未耗尽 → idle +/// 耗尽 + 无积分 + 有卡 → use-card +/// 耗尽 + 无积分 + 无卡 → wait +/// 耗尽 + 有积分 + 无卡 → keep-credit +/// 耗尽 + 都有,卡先过期 → use-card +/// 耗尽 + 都有,积分先过期 → keep-credit +pub fn decide( + quotas: &[QuotaState], + usable_cards: &[UsableCard], + credit_expiry_ms: Option, + credit_desc: &str, +) -> Decision { + let five = quotas.iter().find(|q| q.quota_type == QUOTA_FIVE_HOUR); + let weekly = quotas.iter().find(|q| q.quota_type == QUOTA_WEEKLY); + + let five_exhausted = five.is_some_and(|q| q.exhausted()); + let weekly_exhausted = weekly.is_some_and(|q| q.exhausted()); + + // ── 未耗尽 ── + if !five_exhausted && !weekly_exhausted { + return Decision { + action: Action::Idle, + reason: "额度充足,暂不需要处理".to_string(), + }; + } + + // ── 无可用积分:只能靠卡 ── + let Some(credit_expiry) = credit_expiry_ms else { + if !usable_cards.is_empty() { + return Decision { + action: Action::UseCard, + reason: "没有可用积分,使用重置卡重置额度".to_string(), + }; + } + return Decision { + action: Action::Wait, + reason: "额度已耗尽,既没有积分也没有可用的重置卡".to_string(), + }; + }; + + // ── 有积分但没卡:让积分先消耗 ── + // 注意这一句带「到期」二字,与 Mini App / Python 的文案保持逐字一致。 + // 改文案前务必跑 `node verify-parity.mjs`。 + if usable_cards.is_empty() { + return Decision { + action: Action::KeepCredit, + reason: format!( + "没有可用的重置卡,让{credit_desc}({}到期)先消耗", + fmt_day(Some(credit_expiry)) + ), + }; + } + + let card_expiry = usable_cards[0].expire_time; + + // ── 两者都有:比「先过期先用」 ── + if card_expiry < credit_expiry { + return Decision { + action: Action::UseCard, + reason: format!( + "重置卡({})比{credit_desc}({})先过期,先用卡保住积分", + fmt_day(Some(card_expiry)), + fmt_day(Some(credit_expiry)) + ), + }; + } + Decision { + action: Action::KeepCredit, + reason: format!( + "{credit_desc}({})比重置卡({})先过期,不用卡,让积分先消耗", + fmt_day(Some(credit_expiry)), + fmt_day(Some(card_expiry)) + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn q(t: i64, total: f64, used: f64, unlimited: bool) -> QuotaState { + QuotaState { quota_type: t, total, used, is_unlimited: unlimited } + } + + fn c(days: i64) -> UsableCard { + UsableCard { + card_id: "C1".into(), + source_type: "mcode_signin".into(), + out_biz_no: "b1".into(), + expire_time: 1_700_000_000_000 + days * 86_400_000, + } + } + + #[test] + fn percent_strings() { + assert_eq!(percent(&serde_json::json!("71%")), 71.0); + assert_eq!(percent(&serde_json::json!("100%")), 100.0); + assert_eq!(percent(&serde_json::json!(0)), 0.0); + assert_eq!(percent(&serde_json::json!("")), 0.0); + } + + #[test] + fn amount_strings() { + assert_eq!(amount(&serde_json::json!("17400.00")), 17400.0); + assert_eq!(amount(&serde_json::json!("1,200.50")), 1200.5); + } + + #[test] + fn exhausted_rules() { + assert!(!q(1, 100.0, 99.0, false).exhausted()); + assert!(q(1, 100.0, 100.0, false).exhausted()); + assert!(!q(1, 100.0, 100.0, true).exhausted(), "不限量不算耗尽"); + assert!(!q(1, 0.0, 0.0, false).exhausted(), "总量为 0 不算耗尽"); + } + + #[test] + fn decide_branches() { + let five_ok = vec![q(1, 100.0, 50.0, false), q(2, 100.0, 0.0, true)]; + let five_dead = vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)]; + let week_dead = vec![q(1, 100.0, 50.0, false), q(2, 100.0, 100.0, false)]; + let credit = Some(1_700_000_000_000 + 13 * 86_400_000); + + assert_eq!(decide(&five_ok, &[c(7)], credit, "平台积分").action, Action::Idle); + assert_eq!(decide(&five_dead, &[c(7)], credit, "平台积分").action, Action::UseCard); + assert_eq!(decide(&five_dead, &[], credit, "平台积分").action, Action::KeepCredit); + assert_eq!(decide(&five_dead, &[], None, "").action, Action::Wait); + assert_eq!(decide(&five_dead, &[c(7)], None, "").action, Action::UseCard); + assert_eq!(decide(&week_dead, &[c(7)], credit, "平台积分").action, Action::UseCard); + // 积分更早过期 → 不用卡 + let credit_soon = Some(1_700_000_000_000 + 2 * 86_400_000); + assert_eq!( + decide(&five_dead, &[c(20)], credit_soon, "平台积分").action, + Action::KeepCredit + ); + } + + #[test] + fn reasons_are_filled() { + let five_dead = vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)]; + let d = decide(&five_dead, &[], Some(1_700_000_000_000 + 13 * 86_400_000), "平台积分"); + assert!(d.reason.contains("没有可用的重置卡"), "{}", d.reason); + assert!(d.reason.contains("平台积分"), "{}", d.reason); + } +} diff --git a/plugins/hylouis233/expiry-sentinel/daemon/src/main.rs b/plugins/hylouis233/expiry-sentinel/daemon/src/main.rs new file mode 100644 index 0000000..8a4050f --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/daemon/src/main.rs @@ -0,0 +1,491 @@ +//! 后台守护进程:额度探测 + 先过期先用决策 + 自动扣卡。 +//! +//! 与 Mini App「蹬王之王」的判定逻辑严格同构,由 `verify-parity.mjs` +//! 逐字比对「动作 + 理由文本」防止漂移。 +//! +//! 设计要点: +//! - 分层缓存:额度每轮取,积分/卡/汇总按各自变化速度缓存 +//! - 先记账再请求:崩溃或重启都不会重复扣同一张卡 +//! - 幂等键:用服务端下发的 out_biz_no,重试安全 + +mod api; +mod decision; +mod state; + +use std::collections::{BTreeMap, HashSet}; +use std::time::Duration; + +use api::{Client, ApiError, BASE_CN}; +use decision::{ + amount, decide, fmt_day, parse_quotas, Action, QuotaState, RemainsPayload, UsableCard, +}; +use serde_json::json; + +/// 日志:带本地时间戳,便于 tail 追踪。 +fn log(msg: &str) { + use std::io::Write; + let now = api::now_ms(); + let secs = now.div_euclid(1000); + let ms = now.rem_euclid(1000); + // 复用 decision 里的本地时区换算 + let (month, day) = decision::local_md(secs); + let (hh, mm) = decision::local_hm(secs); + let _ = std::io::stdout().write_all( + format!("[{month:02}-{day:02} {hh:02}:{mm:02}.{ms:03}] {msg}\n").as_bytes(), + ); +} + +/// 各数据源的缓存 TTL(毫秒)。按变化速度分配,不是所有数据都值得每轮取。 +struct Ttl { + quota: i64, + code_credits: i64, + cards: i64, + platform: i64, +} + +impl Default for Ttl { + fn default() -> Self { + // quota TTL=0:额度是所有联动的触发源,必须每轮看。 + // 其余按「多久会变」给:积分随消耗变、卡按天签到变、平台池极少变。 + Ttl { quota: 0, code_credits: 60_000, cards: 300_000, platform: 300_000 } + } +} + +struct Cache { + slots: BTreeMap<&'static str, (Option, i64)>, +} + +impl Cache { + fn new() -> Self { + Cache { slots: BTreeMap::new() } + } + fn get(&mut self, key: &'static str, ttl: i64) -> Option { + let entry = self.slots.get(key).cloned(); + if let Some((Some(v), at)) = entry { + if api::now_ms() - at < ttl { + return Some(v); + } + } + None + } + fn put(&mut self, key: &'static str, value: T) { + self.slots.insert(key, (Some(value), api::now_ms())); + } + fn invalidate(&mut self, key: &str) { + self.slots.remove(key); + } +} + +struct Caches { + quota: Cache, + code: Cache, + platform: Cache, + cards: Cache>, +} + +impl Caches { + fn new() -> Self { + Caches { + quota: Cache::new(), + code: Cache::new(), + platform: Cache::new(), + cards: Cache::new(), + } + } +} + +struct Daemon { + client: Client, + ttl: Ttl, + cache: Caches, +} + +/// 取缓存:命中则零网络,未命中才请求。 +/// +/// 做成自由函数(而非 `&mut self` 方法):这样 `cache` 与 `client` +/// 可以分别借用,不会触发「同一个 self 被可变借两次」。 +fn cached( + client: &mut Client, + cache: &mut Cache, + key: &'static str, + ttl: i64, + load: impl FnOnce(&mut Client) -> Result, +) -> Result { + if let Some(v) = cache.get(key, ttl) { + return Ok(v); + } + let v = load(client)?; + cache.put(key, v.clone()); + Ok(v) +} + +impl Daemon { + fn new(client: Client) -> Self { + Daemon { client, ttl: Ttl::default(), cache: Caches::new() } + } + + fn quotas(&mut self) -> Vec { + let ttl = self.ttl.quota; + let Daemon { client, cache, .. } = self; + match cached(client, &mut cache.quota, "quota", ttl, |c| { + c.get("/backend/account/token_plan/remains_percent", false) + }) { + Ok(v) => { + let payload: RemainsPayload = serde_json::from_value(v).unwrap_or(RemainsPayload { + model_remains: vec![], + }); + parse_quotas(&payload) + } + Err(e) => { + log(&format!(" 额度读取失败(沿用上次): {e}")); + vec![] + } + } + } + + /// 全部分页拉取重置卡 + fn all_cards(&mut self) -> Vec { + let ttl = self.ttl.cards; + if let Some(v) = self.cache.cards.get("cards", ttl) { + return v; + } + let mut seen: HashSet = HashSet::new(); + let mut all = Vec::new(); + for page in 1..=10 { + match self + .client + .get(&format!("/backend/account/reset_card/list?page={page}&size=20"), false) + { + Ok(v) => { + let cards = v.get("cards").and_then(|c| c.as_array()).cloned().unwrap_or_default(); + if cards.is_empty() { + break; + } + let n = cards.len(); + for c in cards { + if let Some(id) = c.get("card_id").and_then(|x| x.as_str()) { + if seen.insert(id.to_string()) { + all.push(c); + } + } + } + if n < 20 { + break; + } + } + Err(e) => { + log(&format!(" 重置卡列表读取失败(忽略): {e}")); + break; + } + } + } + self.cache.cards.put("cards", all.clone()); + all + } + + /// 可用于扣卡的卡:未过期 + 有 out_biz_no + 有 source_type,按到期排序 + fn usable_cards(&mut self) -> Vec { + let now = api::now_ms(); + let mut out: Vec = self + .all_cards() + .iter() + .filter_map(|c| { + let expire = c.get("expire_time").and_then(|v| v.as_i64())?; + if expire <= now { + return None; + } + let out_biz_no = c.get("out_biz_no").and_then(|v| v.as_str()).unwrap_or(""); + let source_type = c.get("source_type").and_then(|v| v.as_str()).unwrap_or(""); + if out_biz_no.is_empty() || source_type.is_empty() { + return None; + } + Some(UsableCard { + card_id: c.get("card_id")?.as_str()?.to_string(), + source_type: source_type.to_string(), + out_biz_no: out_biz_no.to_string(), + expire_time: expire, + }) + }) + .collect(); + out.sort_by_key(|c| c.expire_time); + out + } + + /// 全账户里最早到期的一笔「仍有效且有余额」的积分(两个来源取更早的) + fn earliest_credit(&mut self) -> (Option, String) { + let now = api::now_ms(); + let mut best: Option = None; + let mut desc = String::new(); + + let mut consider = |exp: Option, balance: f64, label: &str| { + let (Some(e), true) = (exp, balance > 0.0) else { return }; + if e <= now { + return; + } + if best.is_none_or(|b| e < b) { + best = Some(e); + desc = label.to_string(); + } + }; + + // 平台积分池 + let pttl = self.ttl.platform; + let platform_result = { + let Daemon { client, cache, .. } = self; + cached(client, &mut cache.platform, "platform", pttl, |c| { + c.get("/backend/account/token_plan_credit", false) + }) + }; + match platform_result { + Ok(v) => { + if let Some(buckets) = v + .get("balance_breakdown") + .and_then(|b| b.get("buckets")) + .and_then(|b| b.as_array()) + { + for b in buckets { + let label = match b.get("source").and_then(|s| s.as_i64()) { + Some(2) => "平台积分·充值".to_string(), + Some(3) => "平台积分·赠送".to_string(), + _ => "平台积分·未知".to_string(), + }; + consider( + b.get("expire_time_ms").and_then(|v| v.as_i64()), + amount(b.get("balance").unwrap_or(&json!(0))), + &label, + ); + } + } + } + Err(e) => log(&format!(" 平台积分读取失败(忽略): {e}")), + } + + // Code 积分明细 + let cttl = self.ttl.code_credits; + let raw = { + let Daemon { client, cache, .. } = self; + cached(client, &mut cache.code, "code", cttl, |c| { + c.get( + "/minimax-cloud/api/v1/credit/details?page_number=1&page_size=100&include_expired=1", + true, + ) + }) + }; + match raw { + Ok(v) => { + if let Some(details) = v.get("details").and_then(|d| d.as_array()) { + for item in details { + let label = if item.get("credit_type").and_then(|c| c.as_i64()) == Some(2) { + "Code 积分·赠送" + } else { + "Code 积分·购买" + }; + consider( + item.get("expire_at_ms").and_then(|v| v.as_i64()), + amount(item.get("remaining_amount").unwrap_or(&json!(0))), + label, + ); + } + } + } + Err(e) => log(&format!(" Code 积分读取失败(忽略): {e}")), + } + + (best, desc) + } + + /// 一轮:判定 + 必要时扣卡 + fn tick(&mut self, dry_run: bool) -> Result<(Action, String, bool), ApiError> { + let quotas = self.quotas(); + let usable = self.usable_cards(); + let (credit_expiry, credit_desc) = self.earliest_credit(); + + let d = decide("as, &usable, credit_expiry, &credit_desc); + log(&format!( + "额度: {}", + quotas.iter().map(|q| q.describe()).collect::>().join(" | ") + )); + log(&format!("决策[{}]: {}", d.action.as_str(), d.reason)); + + if d.action != Action::UseCard || dry_run { + return Ok((d.action, d.reason, false)); + } + let Some(card) = usable.first() else { + return Ok((d.action, d.reason, false)); + }; + + let fingerprint = format!("{}:{}", card.card_id, card.out_biz_no); + if state::was_spent(&fingerprint) { + log("这张卡本进程已尝试过,跳过"); + return Ok((d.action, d.reason, false)); + } + + // 先记账再请求:崩溃/重启都不会重复扣 + state::mark_spent(&fingerprint, &card.card_id, &card.source_type); + self.cache.cards.invalidate("cards"); + + match self + .client + .post( + "/backend/account/reset_card/use", + &json!({ "source_type": card.source_type, "out_biz_no": card.out_biz_no }), + false, + ) { + Ok(v) => { + let outcome = v.get("outcome").and_then(|x| x.as_str()).unwrap_or("UNKNOWN"); + let ok = outcome == "SUCCESS" || outcome == "REPLAYED"; + if ok { + log(&format!("✓ 已使用重置卡 {},额度已重置", card.card_id)); + } else { + log(&format!("✗ 重置卡不可用({outcome})")); + } + Ok((d.action, d.reason, ok)) + } + Err(e) => { + log(&format!("✗ 扣卡失败:{e}")); + Ok((d.action, d.reason, false)) + } + } + } +} + +/// 校验模式:读入与 Node/Python 同一份用例 JSON,打印同样的结果结构。 +/// +/// 供 `verify-parity.mjs` 调用,让三方(Mini App / Python / Rust)跑同一组输入。 +/// 不联网、不落盘,纯函数比对。 +fn selftest() { + use std::io::Read as _; + let mut input = String::new(); + if std::io::stdin().read_to_string(&mut input).is_err() { + eprintln!("selftest: 无法读取 stdin"); + std::process::exit(2); + } + let payload: serde_json::Value = match serde_json::from_str(&input) { + Ok(v) => v, + Err(e) => { + eprintln!("selftest: JSON 解析失败 {e}"); + std::process::exit(2); + } + }; + let now = payload["now"].as_i64().unwrap_or(0); + let day = payload["day"].as_i64().unwrap_or(86_400_000); + let mut out = Vec::new(); + + for c in payload["cases"].as_array().cloned().unwrap_or_default() { + let mk = |t: i64, used: f64, total: f64, unlimited: bool| QuotaState { + quota_type: t, + total, + used, + is_unlimited: unlimited, + }; + let five_used = c["five"].as_f64().unwrap_or(0.0); + let week_used = c["week"].as_i64(); + let quotas = vec![ + mk(1, five_used, 100.0, false), + match week_used { + None => mk(2, 0.0, 100.0, true), + Some(w) => mk(2, w as f64, 100.0, false), + }, + ]; + let cards: Vec = c["cards"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter_map(|v| { + let d = v.as_i64()?; + Some(UsableCard { + card_id: format!("C{d}"), + source_type: "mcode_signin".into(), + out_biz_no: format!("b{d}"), + expire_time: now + d * day, + }) + }) + .collect(); + let credit = c["credit"].as_i64().map(|d| now + d * day); + + let d = decide("as, &cards, credit, "平台积分"); + out.push(serde_json::json!({ + "n": c["n"].as_str().unwrap_or(""), + "action": d.action.as_str(), + "reason": d.reason, + })); + } + println!("{}", serde_json::to_string(&out).unwrap_or_default()); +} + +fn main() { + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--selftest") { + selftest(); + return; + } + + let mut interval: f64 = 60.0; + let mut dry_run = false; + let mut cmd = "watch".to_string(); + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--interval" if i + 1 < args.len() => { + interval = args[i + 1].parse().unwrap_or(60.0); + i += 2; + } + "--base" if i + 1 < args.len() => i += 2, + "--dry-run" => { + dry_run = true; + i += 1; + } + "watch" | "once" | "status" | "forget" => { + cmd = args[i].clone(); + i += 1; + } + _ => i += 1, + } + } + + if cmd == "forget" { + state::clear_spent(); + println!("已清空本地去重记录。"); + return; + } + + let client = match Client::new(BASE_CN) { + Ok(c) => c, + Err(e) => { + eprintln!("初始化失败:{e}"); + std::process::exit(1); + } + }; + let mut daemon = Daemon::new(client); + + if cmd == "once" { + match daemon.tick(dry_run) { + Ok(_) => {} + Err(e) => { + eprintln!("失败:{e}"); + std::process::exit(1); + } + } + return; + } + + log(&format!("启动后台监控,间隔 {interval}s(Ctrl+C 退出)")); + log("扣卡策略:与 Mini App「蹬王之王」一致 —— 额度真正耗尽才动手,并按先过期先用"); + loop { + match daemon.tick(dry_run) { + Ok(_) => {} + Err(e) if e.is_unauthorized() => { + log(&format!( + "✗ 鉴权失败:{e}。请确认 MiniMax Code 桌面端已启动并处于登录状态。" + )); + std::process::exit(1); + } + Err(e) => log(&format!("⚠️ 本轮失败:{e},下一轮继续")), + } + // 抖动,避免与整点/整 5 小时重置撞在一起 + let jitter = (api::now_ms() % 1000) as f64 / 1000.0 * 4.0 - 2.0; + std::thread::sleep(Duration::from_millis(((interval + jitter) * 1000.0) as u64)); + } +} diff --git a/plugins/hylouis233/expiry-sentinel/daemon/src/state.rs b/plugins/hylouis233/expiry-sentinel/daemon/src/state.rs new file mode 100644 index 0000000..7d4dd6b --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/daemon/src/state.rs @@ -0,0 +1,95 @@ +//! 去重记录的持久化。 +//! +//! 落盘而非只放内存:后台进程会被 launchd 重启,只靠内存的话 +//! 重启后会把同一张卡再扣一遍。先写盘再请求,崩溃也不重复。 + +use std::collections::BTreeSet; +use std::path::PathBuf; + +fn state_dir() -> Option { + let home = std::env::var("HOME").ok()?; + let dir = PathBuf::from(home).join(".config/mcode-reset-card"); + Some(dir) +} + +fn state_file() -> Option { + Some(state_dir()?.join("bg-state.json")) +} + +fn read_set() -> BTreeSet { + let Some(path) = state_file() else { + return BTreeSet::new(); + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return BTreeSet::new(); + }; + serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("spent").cloned()) + .and_then(|s| serde_json::from_value::>(s).ok()) + .map(|v| v.into_iter().collect()) + .unwrap_or_default() +} + +fn write_set(set: &BTreeSet) { + let Some(dir) = state_dir() else { return }; + let Some(path) = state_file() else { return }; + if std::fs::create_dir_all(&dir).is_err() { + return; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + let json = serde_json::json!({ + "spent": set.iter().collect::>(), + "updatedAtMs": crate::api::now_ms(), + }); + // 原子替换:先写临时文件再 rename,避免半截文件 + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, serde_json::to_string_pretty(&json).unwrap_or_default()).is_err() { + return; + } + if std::fs::rename(&tmp, &path).is_err() { + let _ = std::fs::remove_file(&tmp); + return; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } +} + +pub fn was_spent(fingerprint: &str) -> bool { + read_set().contains(fingerprint) +} + +pub fn mark_spent(fingerprint: &str, card_id: &str, source_type: &str) { + let mut set = read_set(); + if set.insert(fingerprint.to_string()) { + write_set(&set); + } + crate::log(&format!("已记账:{card_id} ({source_type})")); +} + +pub fn clear_spent() { + if let Some(path) = state_file() { + let _ = std::fs::remove_file(path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_roundtrip() { + let mut s = BTreeSet::new(); + s.insert("a:1".to_string()); + s.insert("b:2".to_string()); + assert!(s.contains("a:1")); + assert!(!s.contains("c:3")); + } +} diff --git a/plugins/hylouis233/expiry-sentinel/docs/preview-cards.png b/plugins/hylouis233/expiry-sentinel/docs/preview-cards.png new file mode 100644 index 0000000..99299d5 Binary files /dev/null and b/plugins/hylouis233/expiry-sentinel/docs/preview-cards.png differ diff --git a/plugins/hylouis233/expiry-sentinel/docs/preview.png b/plugins/hylouis233/expiry-sentinel/docs/preview.png new file mode 100644 index 0000000..a19e454 Binary files /dev/null and b/plugins/hylouis233/expiry-sentinel/docs/preview.png differ diff --git a/plugins/hylouis233/expiry-sentinel/icon.png b/plugins/hylouis233/expiry-sentinel/icon.png new file mode 100644 index 0000000..416bf1d Binary files /dev/null and b/plugins/hylouis233/expiry-sentinel/icon.png differ diff --git a/plugins/hylouis233/expiry-sentinel/miniapp/client/index.html b/plugins/hylouis233/expiry-sentinel/miniapp/client/index.html new file mode 100644 index 0000000..9c0daee --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/miniapp/client/index.html @@ -0,0 +1,934 @@ + + + + + + 蹬王之王 + + + +
+
+

蹬王之王

+

实时追踪积分与重置卡的到期时间,按「先过期先用」决定用卡还是留积分,不让额度白白作废

+

+ + 正在连接实时更新… +

+
+
+ + + +
+
+ +
+
+
7 天内将要过期
+
—
+
正在统计…
+
+
+
+
30 天内过期
+
—
+
+
+
有效余额
+
—
+
+
+
已过期损失
+
—
+
+
+
+ +
+
+
5 小时限额
+
—
+
+
+
+
+
积分总余额
+
—
+
+
+
+
最早到期
+
—
+
+
+
+
可用重置卡
+
—
+
+
+
+ +
+ 加载中 +
+
正在读取额度与到期信息…
+
+
+
+ +
+
+ +
+ + + + +
+
+
+
+

+ 判定结论为「保留积分」时不执行任何写操作;「立即判定一次」可随时手动触发,无节流。 +

+
+ +
+
+ + +
+
+

+ 与上面的「自动执行」是两套独立机制:上面只在本页面打开时生效,这里交给系统常驻进程, + 两者用同一套「先过期先用」决策,不会重复扣卡(服务端按幂等键去重)。 +

+
+ +
+

积分到期明细

+

+ +
读取中…
+
+ +
+
+

重置卡到期

+ +
读取中…
+
+
+

平台积分池

+
读取中…
+
+
+ +
+

你没来得及蹬完的 token

+

+ +
暂无过期未用完的积分
+
+ + + + diff --git a/plugins/hylouis233/expiry-sentinel/miniapp/miniapp.json b/plugins/hylouis233/expiry-sentinel/miniapp/miniapp.json new file mode 100644 index 0000000..498d880 --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/hylouis233/expiry-sentinel/miniapp/node/server.mjs b/plugins/hylouis233/expiry-sentinel/miniapp/node/server.mjs new file mode 100644 index 0000000..307325b --- /dev/null +++ b/plugins/hylouis233/expiry-sentinel/miniapp/node/server.mjs @@ -0,0 +1,1256 @@ +// @ts-check +/** + * 蹬王之王 — 积分与重置卡的到期决策看板。 + * + * 数据源(全部经实测确认,非推测)。两套 API 分属不同域名: + * + * 开放平台积分 → www.minimaxi.com + * GET /backend/account/token_plan_credit → 余额 + 各积分池到期时间 + * GET /backend/account/token_plan/remains_percent → 5小时/周额度用量 + * GET /backend/account/reset_card/list → 重置卡与到期时间 + * POST /backend/account/reset_card/use → 使用一张重置卡 + * + * MiniMax Code 积分 → agent.minimaxi.com + * GET /minimax-cloud/api/v1/credit/details → 逐笔授予/消耗/到期明细 + * + * 决策规则:把「最早到期的积分」(平台或 Code 任一)与「最早到期的重置卡」比, + * 谁先过期先用谁。积分先过期 → 不用卡,让积分先消耗完;重置卡先过期 → 用卡 + * 把额度重置回来,把更耐放的积分留着。 + * + * 鉴权:与桌面端共用同一登录态,经 OAuth lease socket 取短期 access token。 + * 凭据只在本进程内存中使用,绝不下发给页面、绝不写日志。 + */ + +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import { join } from 'node:path'; +import { connect } from 'node:net'; +import { homedir } from 'node:os'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + + +// ── OAuth lease:与桌面端同一套登录态 ──────────────────────────────────────── +const LEASE_SOCK = join(homedir(), '.minimax', 'run', 'mcode-auth-lease-v1.sock'); +const LEASE_CAP = join(homedir(), '.minimax', 'run', 'mcode-auth-lease-v1.cap'); + +function randomId() { + return Array.from(randomBytes(16), (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * 向桌面端的 lease socket 实时取一个短期 access token。 + * 协议见 packages/oauth-lease-protocol:4 字节大端长度前缀 + JSON。 + * @returns {Promise} + */ +function acquireLeaseToken() { + return new Promise((resolve) => { + let capability; + try { + capability = readFileSync(LEASE_CAP, 'utf8').trim(); + } catch { + resolve(null); + return; + } + if (!capability) { + resolve(null); + return; + } + + const payload = Buffer.from( + JSON.stringify({ + version: 1, + requestId: randomId(), + capability, + method: 'lease', + minValidityMs: 120_000, + }), + 'utf8', + ); + const frame = Buffer.alloc(4 + payload.length); + frame.writeUInt32BE(payload.length, 0); + payload.copy(frame, 4); + + const socket = connect(LEASE_SOCK); + const chunks = []; + let done = false; + + const finish = (value) => { + if (done) return; + done = true; + socket.destroy(); + resolve(value); + }; + + socket.setTimeout(5000); + socket.on('error', () => finish(null)); + socket.on('timeout', () => finish(null)); + socket.on('connect', () => socket.write(frame)); + socket.on('data', (chunk) => { + chunks.push(chunk); + const buf = Buffer.concat(chunks); + if (buf.length < 4) return; + const size = buf.readUInt32BE(0); + if (buf.length < 4 + size) return; + try { + const response = JSON.parse(buf.subarray(4, 4 + size).toString('utf8')); + const result = response?.result; + if (response?.ok && result?.method === 'lease' && typeof result.accessToken === 'string') { + finish(result.accessToken); + return; + } + finish(null); + } catch { + finish(null); + } + }); + }); +} + +/** + * token 复用:lease token 有效期约 40 分钟,没必要每轮都开一次 unix socket。 + * 只在过期前 5 分钟才重新取,且同一个 in-flight 请求会被复用(并发去重)。 + */ +const TOKEN_TTL_MS = 30 * 60 * 1000; +const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; +let tokenCache = { value: null, at: 0 }; +let tokenPending = null; + +function getToken() { + const now = Date.now(); + if (tokenCache.value && now - tokenCache.at < TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS) { + return Promise.resolve(tokenCache.value); + } + // 并发去重:同一时刻只开一个 lease 连接 + if (tokenPending) return tokenPending; + tokenPending = acquireLeaseToken() + .then((token) => { + if (token) { + tokenCache = { value: token, at: Date.now() }; + } + return token; + }) + .finally(() => { + tokenPending = null; + }); + return tokenPending; +} + +// ── 账号 API ────────────────────────────────────────────────────────────────── +/** + * 两套账号 API 分属不同域名,实测确认: + * 开放平台 / 额度 / 重置卡 → www.minimaxi.com(agent 域对这些路径返回 404 或 HTML) + * MiniMax Code 积分明细 → agent.minimaxi.com(platform 域返回非 JSON) + */ +const PLATFORM_BASE = 'https://www.minimaxi.com'; +const CODE_BASE = 'https://agent.minimaxi.com'; + +class ApiError extends Error { + constructor(message, kind = 'business', code) { + super(message); + this.kind = kind; + this.code = code; + } +} + +async function apiGet(path, token, base = PLATFORM_BASE) { + const response = await fetch(`${base}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'User-Agent': 'MiniMaxCode', + }, + }); + if (response.status === 401 || response.status === 403) { + tokenCache = { value: null, at: 0 }; + throw new ApiError('登录态失效,请重新打开 MiniMax Code', 'unauthorized'); + } + if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status); + const payload = await response.json(); + const baseResp = payload?.base_resp; + if (baseResp && baseResp.status_code !== 0) { + throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code); + } + return payload; +} + +/** 部分接口(如 get_membership_info)是 POST,桌面端即如此调用。 */ +async function apiPost(path, token, base = CODE_BASE, body = {}) { + const response = await fetch(`${base}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'MiniMaxCode', + }, + body: JSON.stringify(body), + }); + if (response.status === 401 || response.status === 403) { + tokenCache = { value: null, at: 0 }; + throw new ApiError('登录态失效,请重新打开 MiniMax Code', 'unauthorized'); + } + if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status); + const payload = await response.json(); + const baseResp = payload?.base_resp; + if (baseResp && baseResp.status_code !== 0) { + throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code); + } + return payload; +} + +async function apiUseResetCard(sourceType, outBizNo, token) { + const response = await fetch(`${PLATFORM_BASE}/backend/account/reset_card/use`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'MiniMaxCode', + }, + body: JSON.stringify({ source_type: sourceType, out_biz_no: outBizNo }), + }); + if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status); + const payload = await response.json(); + const baseResp = payload?.base_resp; + if (baseResp && baseResp.status_code !== 0) { + throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code); + } + return String(payload?.outcome || 'UNKNOWN'); +} + +// ── 解析 ────────────────────────────────────────────────────────────────────── +/** 百分数字符串(如 "71%")转数字。接口就是这种格式,直接 Number() 会得到 0。 */ +function percent(value) { + if (typeof value === 'string') value = value.trim().replace(/%/g, ''); + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +/** 金额字符串(可能带千分位)转数字。 */ +function amount(value) { + if (typeof value === 'string') value = value.replace(/,/g, '').trim(); + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +const BUCKET_LABEL = { 2: '充值', 3: '赠送' }; +/** Code 积分 credit_type:1=购买,2=赠送(取自桌面端 Purchased / Free 判定)。 */ +const CODE_CREDIT_LABEL = { 1: '购买', 2: '赠送' }; + +/** 开放平台积分池(/backend/account/token_plan_credit)。 */ +function parsePlatformPools(credit) { + const buckets = credit?.balance_breakdown?.buckets ?? []; + return buckets + .filter((b) => b && typeof b.balance === 'number') + .map((b) => ({ + kind: 'platform', + source: 'platform', + label: BUCKET_LABEL[b.source] ?? `来源${b.source}`, + balance: b.balance, + expireTimeMs: typeof b.expire_time_ms === 'number' ? b.expire_time_ms : null, + })) + .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity)); +} + +/** + * MiniMax Code 积分明细(/minimax-cloud/api/v1/credit/details)。 + * + * 分页参数是 `page_number` / `page_size`(下划线命名),且必须带 + * `include_expired=1` 才会返回历史记录——桌面端就是这么调的 + * (见 app.asar 中 open-platform-commerce 的 fetch 函数)。 + * + * 不带 include_expired 时接口只返回当前有效的最近 20 条,据此判断「已过期多少」 + * 会得出完全错误的结论(曾经据此误答「0 过期」)。page_size 上限 100。 + */ +async function fetchCodeCredits(token) { + const pageSize = 100; + const collected = []; + let totalCount = 0; + for (let pageNumber = 1; pageNumber <= 20; pageNumber += 1) { + const payload = await apiGet( + `/minimax-cloud/api/v1/credit/details?page_number=${pageNumber}&page_size=${pageSize}&include_expired=1`, + token, + CODE_BASE, + ); + const details = Array.isArray(payload.details) ? payload.details : []; + totalCount = Number(payload.total_count ?? collected.length); + collected.push(...details); + if (collected.length >= totalCount || details.length < pageSize) break; + } + return { payload: { total_count: totalCount, details: collected } }; +} + +function parseCodeCredits(payload) { + const details = Array.isArray(payload?.details) ? payload.details : []; + const now = Date.now(); + const items = details + .filter((d) => d && (d.credit_type === 1 || d.credit_type === 2)) + .map((d) => ({ + kind: 'code-credit', + source: 'code', + label: CODE_CREDIT_LABEL[d.credit_type] ?? `类型${d.credit_type}`, + granted: amount(d.granted_amount), + consumed: amount(d.consumed_amount), + balance: amount(d.remaining_amount), + expireTimeMs: typeof d.expire_at_ms === 'number' ? d.expire_at_ms : null, + grantedAtMs: typeof d.granted_at_ms === 'number' ? d.granted_at_ms : null, + // 过期时仍有余额 = 真实损失;过期但已用尽不算损失 + lost: typeof d.expire_at_ms === 'number' && d.expire_at_ms < now && amount(d.remaining_amount) > 0, + expired: typeof d.expire_at_ms === 'number' && d.expire_at_ms < now, + })) + .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity)); + + const live = items.filter((d) => !d.expired); + return { + items, + totalCount: Number(payload?.total_count ?? items.length), + truncated: Number(payload?.total_count ?? 0) > items.length, + liveBalance: live.reduce((sum, d) => sum + d.balance, 0), + lostBalance: items.filter((d) => d.lost).reduce((sum, d) => sum + d.balance, 0), + lostCount: items.filter((d) => d.lost).length, + expiredCount: items.filter((d) => d.expired).length, + }; +} + +/** + * 未来 N 天内将过期的积分子集。 + * 这是整个看板最有价值的数字:签到分按天逐笔到账、各自 30 天到期, + * 所以到期是连续排队的滚动流失,不是一次性清算。 + */ +function expiringWithin(items, days, now = Date.now()) { + const limit = now + days * 86_400_000; + return items.filter( + (d) => !d.expired && d.balance > 0 && d.expireTimeMs !== null && d.expireTimeMs <= limit, + ); +} + +/** + * 账户积分汇总(POST /matrix/api/v1/commerce/get_membership_info)。 + * + * 这是 MCode 用量页上「积分」那一栏的权威口径: + * total_remaining_amount 总余额(截图里的 30947) + * purchased_remaining_amount 购入部分 + * free_remaining_amount 赠送部分 + * + * 与另两个口径的关系(已实测核对,不要混算): + * purchased ≈ token_plan_credit 的 remaining_credits(6487.506 vs 6487,小数为精度差) + * free ≥ credit/details 明细之和(24460 vs 17400,因该接口只返回最近 20 笔) + * 因此这里只作为「总余额」展示,**不参与**到期排序,避免与明细重复计数。 + */ +function parseCreditSummary(payload) { + const s = payload?.op_credit_summary; + if (!s) return null; + const total = amount(s.total_remaining_amount); + const purchased = amount(s.purchased_remaining_amount); + const free = amount(s.free_remaining_amount); + if (!total && !purchased && !free) return null; + return { total, purchased, free }; +} + +function parseQuota(payload) { + const models = payload?.model_remains ?? []; + const general = + models.find((m) => m?.model_name === 'general') ?? models.find((m) => m?.model_name) ?? null; + if (!general) return null; + const total = percent(general.current_interval_total_percent) || 100; + const used = percent(general.current_interval_used_percent); + return { + fiveHour: { + usedPercent: used, + totalPercent: total, + remainingPercent: Math.max(0, (total - used) / total) * 100, + exhausted: general.current_interval_status !== 3 && total > 0 && used >= total, + unlimited: general.current_interval_status === 3, + resetTimeMs: general.end_time ?? null, + }, + weekly: { + usedPercent: percent(general.current_weekly_used_percent), + totalPercent: percent(general.current_weekly_total_percent) || 100, + unlimited: general.current_weekly_status === 3, + resetTimeMs: general.weekly_end_time ?? null, + }, + }; +} + +/** + * 重置卡的使用流水。 + * + * 关键点:`create_time` 是**必填**参数,桌面端就是那样调的。 + * 传 `create_time=0` 表示不限起始时间;传一个具体时间戳反而会返回 + * 2013 invalid params —— 那是一条**错误**,不是「没有记录」。 + * 曾经把这条错误读成 total=0,因而误判「一张卡都没用过」。 + */ +async function fetchCardFlows(token) { + try { + const payload = await apiGet('/backend/account/reset_card/flows?page=1&size=20&create_time=0', token); + const flows = Array.isArray(payload.flows) ? payload.flows : []; + const used = flows.filter((f) => f?.event_type === 'USE'); + return { + available: true, + total: Number(payload.total ?? flows.length), + usedCount: used.length, + grantedCount: flows.filter((f) => f?.event_type === 'GRANT').length, + lastUsedAtMs: used.length ? Math.max(...used.map((f) => Number(f.create_time) || 0)) : null, + }; + } catch { + // 读不到流水时不编数字,交给页面显示「未知」 + return { available: false, total: 0, usedCount: null, grantedCount: null, lastUsedAtMs: null }; + } +} + +async function fetchCards(token) { + const now = Date.now(); + const seen = new Map(); + for (let page = 1; page <= 10; page += 1) { + const payload = await apiGet(`/backend/account/reset_card/list?page=${page}&size=20`, token); + const cards = Array.isArray(payload.cards) ? payload.cards : []; + for (const card of cards) { + if (card && card.card_id) seen.set(String(card.card_id), card); + } + if (cards.length < 20) break; + } + return [...seen.values()] + .map((c) => ({ + cardId: String(c.card_id), + sourceType: String(c.source_type ?? ''), + outBizNo: String(c.out_biz_no ?? ''), + expireTimeMs: typeof c.expire_time === 'number' ? c.expire_time : null, + createTimeMs: typeof c.create_time === 'number' ? c.create_time : null, + expired: typeof c.expire_time === 'number' && c.expire_time <= now, + })) + .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity)); +} + +// ── 决策:先过期先用 ────────────────────────────────────────────────────────── +const SOURCE_NAME = { platform: '平台积分', code: 'Code 积分', card: '重置卡' }; + +/** + * 跨三类来源比较到期时间:平台积分池、Code 积分明细、重置卡。 + * + * 只有「最早到期的积分」与「最早到期的重置卡」直接可比—— + * 决定用不用卡,看的是哪一边更快作废: + * 卡先过期 → 用卡把额度重置回来,把更耐放的积分留着; + * 积分先过期 → 不用卡,让积分自然消耗完,卡留到以后更划算的时候。 + */ +function decide({ platformPools, codeCredits, cards, quota }) { + const now = Date.now(); + const liveCards = cards.filter((c) => !c.expired && c.outBizNo && c.sourceType); + const liveCredits = [...platformPools, ...codeCredits].filter( + (c) => c.balance > 0 && c.expireTimeMs && c.expireTimeMs > now, + ); + + const earliestCard = liveCards[0] ?? null; + const earliestCredit = liveCredits.sort( + (a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity), + )[0] ?? null; + + const cardExpire = earliestCard?.expireTimeMs ?? null; + const creditExpire = earliestCredit?.expireTimeMs ?? null; + const creditName = earliestCredit ? SOURCE_NAME[earliestCredit.source] : '积分'; + + let action = 'wait'; + let reason = ''; + let usedCard = false; + + if (!quota) { + reason = '未获取到额度状态'; + } else if (!quota.fiveHour.exhausted && !quota.weekly.exhausted) { + action = 'idle'; + reason = '额度充足,暂不需要处理'; + } else if (!earliestCredit) { + // 没有可消耗的积分,只能靠卡把额度重置回来 + if (earliestCard) { + action = 'use-card'; + usedCard = true; + reason = '没有可用积分,使用重置卡重置额度'; + } else { + action = 'wait'; + reason = '额度已耗尽,既没有积分也没有可用的重置卡'; + } + } else if (!earliestCard) { + action = 'use-credit'; + reason = `没有可用的重置卡,让${creditName}(${fmtDay(creditExpire)}到期)先消耗`; + } else if (cardExpire < creditExpire) { + action = 'use-card'; + usedCard = true; + reason = `重置卡(${fmtDay(cardExpire)})比${creditName}(${fmtDay(creditExpire)})先过期,先用卡保住积分`; + } else { + action = 'use-credit'; + reason = `${creditName}(${fmtDay(creditExpire)})比重置卡(${fmtDay(cardExpire)})先过期,不用卡,让积分先消耗`; + } + + return { + action, + usedCard, + reason, + target: + usedCard && earliestCard + ? { + cardId: earliestCard.cardId, + sourceType: earliestCard.sourceType, + outBizNo: earliestCard.outBizNo, + expireTimeMs: cardExpire, + } + : null, + earliestCardExpireMs: cardExpire, + earliestCreditExpireMs: creditExpire, + earliestCreditSource: earliestCredit?.source ?? null, + earliestCreditLabel: earliestCredit?.label ?? null, + exhausted: Boolean(quota && (quota.fiveHour.exhausted || quota.weekly.exhausted)), + }; +} + +function fmtDay(ms) { + if (!ms) return '未知'; + const d = new Date(ms); + return `${d.getMonth() + 1}月${d.getDate()}日`; +} + +// ── 分层缓存 ────────────────────────────────────────────────────────────────── +/** + * 按接口的「变化速度」分层缓存,直接砍掉无谓的网络往返。 + * + * 实测依据(单轮 5 个接口共 ~19.8 KB / ~1.05s,其中 5 个串行等待是大头): + * 额度用量 每轮必取 —— 消耗在变,是所有联动的触发源 + * 积分明细 60s —— 81 笔里绝大多数只在被消耗时才动 + * 重置卡 300s —— 按天签到产生,变化极慢 + * 平台积分池 300s —— 同上 + * 账户汇总 600s —— 只在签到/充值后变 + * + * 过期未取数时回落到上一次成功结果,宁可显示略旧的数据,也不让看板空白。 + */ +const TTL = { + quota: 0, + codeCredits: 60_000, + cards: 300_000, + platform: 300_000, + membership: 600_000, + cardFlows: 120_000, +}; + +const cache = { + quota: { value: null, at: 0 }, + codeCredits: { value: null, at: 0 }, + cards: { value: null, at: 0 }, + platform: { value: null, at: 0 }, + membership: { value: null, at: 0 }, + cardFlows: { value: null, at: 0 }, +}; + +/** 带 TTL 与陈旧回落的取值:命中则零网络,未命中才请求。 */ +async function cached(slot, loader) { + const entry = cache[slot]; + const now = Date.now(); + if (entry.value !== null && now - entry.at < TTL[slot]) { + return { value: entry.value, fromCache: true }; + } + try { + const value = await loader(); + if (value !== null && value !== undefined) { + entry.value = value; + entry.at = now; + return { value, fromCache: false }; + } + // 本轮失败:用旧值兜底,别把上次的正常数据清掉 + return { value: entry.value, fromCache: true }; + } catch { + return { value: entry.value, fromCache: true }; + } +} + +function invalidateCache(slot) { + if (slot) cache[slot].at = 0; + else for (const key of Object.keys(cache)) cache[key].at = 0; +} + +// ── 快照 ────────────────────────────────────────────────────────────────────── +/** + * 同一时刻只允许一次真实取数。 + * + * SSE 轮询与自动判定会各自调用 buildSnapshot,若不合并就会把 + * 每轮的接口数翻倍。这里让并发的调用共享同一个 in-flight Promise。 + */ +let inflightSnapshot = null; + +function buildSnapshot() { + if (inflightSnapshot) return inflightSnapshot; + inflightSnapshot = doBuildSnapshot().finally(() => { + inflightSnapshot = null; + }); + return inflightSnapshot; +} + +async function doBuildSnapshot() { + const token = await getToken(); + if (!token) { + throw new ApiError('未获取到登录态,请确认 MiniMax Code 桌面端正在运行且已登录', 'unauthorized'); + } + + // 四路并发放大并行收益;积分明细与账户汇总失败不拖垮看板 + const [platform, quotaRes, cardsRes, codeRes, membershipRes, cardFlowsRes] = await Promise.all([ + cached('platform', () => apiGet('/backend/account/token_plan_credit', token)), + cached('quota', () => apiGet('/backend/account/token_plan/remains_percent', token)), + cached('cards', () => fetchCards(token)), + cached('codeCredits', () => + fetchCodeCredits(token) + .then((r) => r.payload) + .catch(() => null), + ), + cached('membership', () => + apiPost('/matrix/api/v1/commerce/get_membership_info', token, CODE_BASE).catch(() => null), + ), + cached('cardFlows', () => fetchCardFlows(token)), + ]); + + const platformRaw = platform.value; + const quotaRaw = quotaRes.value; + const cards = cardsRes.value ?? []; + const codePage = codeRes.value; + const membership = membershipRes.value; + const cardUsage = cardFlowsRes.value ?? { available: false, usedCount: null, grantedCount: null, lastUsedAtMs: null }; + + const platformPools = platformRaw ? parsePlatformPools(platformRaw) : []; + const codeCredits = parseCodeCredits(codePage); + const quota = quotaRaw ? parseQuota(quotaRaw) : null; + const decision = decide({ platformPools, codeCredits: codeCredits.items, cards, quota }); + + const now = Date.now(); + const soon7 = expiringWithin(codeCredits.items, 7, now); + const soon30 = expiringWithin(codeCredits.items, 30, now); + const sumBalance = (list) => list.reduce((s, d) => s + d.balance, 0); + + return { + fetchedAtMs: now, + // 哪些来自缓存 —— 页面据此判断数据新鲜度,不必假装每轮都是最新的 + fresh: { + quota: !quotaRes.fromCache, + codeCredits: !codeRes.fromCache, + cards: !cardsRes.fromCache, + platform: !platform.fromCache, + membership: !membershipRes.fromCache, + }, + creditSummary: parseCreditSummary(membership), + platformCredits: { + total: Number(platformRaw?.total_credits ?? 0), + used: Number(platformRaw?.used_credits ?? 0), + remaining: Number(platformRaw?.remaining_credits ?? 0), + fallbackEnabled: Boolean(platformRaw?.token_plan_credit_fallback_enabled), + pools: platformPools, + }, + codeCredits: { + available: codePage !== null, + totalCount: codeCredits.totalCount, + liveBalance: codeCredits.liveBalance, + lostBalance: codeCredits.lostBalance, + /** + * 明细按「会不会再变 / 有没有意义」分区传输: + * active —— 仍有效、余额可能随消耗变化,主表格展示这个 + * lost —— 已过期但**仍有余额**,即「没来得及蹬完」,单独一块展示 + * 余额为 0 的笔既不展示也不传输,已折算进 usedUp 汇总。 + * lost 分区只增不减(过期只会增加),因此适合增量推送: + * 它没变就不重发。 + */ + active: codeCredits.items.filter((d) => !d.expired && d.balance > 0), + lost: codeCredits.items.filter((d) => d.expired && d.balance > 0), + /** + * 余额为 0 的笔(已用尽)不进表格 —— 45 行零余额对用户没有信息量, + * 只把它折算成一句可读的成果汇总。口径是 consumed 之和, + * 依据是 granted - consumed == balance 在全部明细上恒成立。 + */ + usedUp: (() => { + const zero = codeCredits.items.filter((d) => d.balance <= 0); + return { + count: zero.length, + consumed: zero.reduce((sum, d) => sum + d.consumed, 0), + expiredCount: zero.filter((d) => d.expired).length, + }; + })(), + }, + expiry: { + soon7Count: soon7.length, + soon7Balance: sumBalance(soon7), + soon30Count: soon30.length, + soon30Balance: sumBalance(soon30), + nextExpireMs: codeCredits.items.find((d) => !d.expired && d.balance > 0)?.expireTimeMs ?? null, + }, + quota, + cards: { + total: cards.length, + live: cards.filter((c) => !c.expired).length, + /** 序号 + 到期日即可识别,不再下发卡号。 */ + items: cards + .filter((c) => !c.expired) + .slice(0, 12) + .map((c, i) => ({ index: i + 1, expireTimeMs: c.expireTimeMs })), + }, + cardUsage, + decision, + }; +} + +// ── 扣卡与自动执行(唯一写路径,先记账再请求)───────────────────────────────── +/** 自动判定间隔的允许范围:最快 5 秒(够灵敏),最慢 60 分钟(够省心)。 */ +const AUTO_MIN_MS = 5_000; +const AUTO_MAX_MS = 60 * 60 * 1000; +const AUTO_DEFAULT_MS = 15_000; + +function clampAutoInterval(ms) { + const n = Number(ms); + if (!Number.isFinite(n)) return AUTO_DEFAULT_MS; + return Math.min(AUTO_MAX_MS, Math.max(AUTO_MIN_MS, Math.round(n))); +} + +const state = { + autoEnabled: false, + autoIntervalMs: AUTO_DEFAULT_MS, + spent: new Map(), + lastResult: null, + autoRuns: [], + lastAutoAtMs: 0, +}; + +/** + * 不再设时间节流。 + * 重复扣卡的防护由两道更硬的机制承担: + * 1. state.spent —— 同一张卡在本进程生命周期内只尝试一次 + * 2. out_biz_no —— 服务端幂等键,即使跨进程重试也不会重复扣除 + * 额度耗尽时可以立即再次判定并执行,不必等固定间隔。 + */ + +async function useCard(reasonLabel) { + const token = await getToken(); + if (!token) throw new ApiError('未获取到登录态', 'unauthorized'); + + const snapshot = await buildSnapshot(); + const target = snapshot.decision.target; + if (!target) { + return { ok: false, message: snapshot.decision.reason, action: snapshot.decision.action }; + } + + const fingerprint = `${target.cardId}:${target.outBizNo}`; + if (state.spent.has(fingerprint)) { + // skipped=true 表示「去重保护生效」,不是扣卡失败,调用方不应记入失败日志 + return { ok: false, skipped: true, message: '这张卡已尝试过,避免重复扣卡', action: snapshot.decision.action }; + } + + /** + * 不依赖时间节流,改用「卡是否还在可用列表」判断。 + * 用掉之后服务端会把它移出可用列表,下一轮自然不会再选到它—— + * 这比固定时间窗更准确:额度真正恢复前不会误扣,恢复了也不会漏扣。 + * 进程重启后 spent 清空也没关系,列表判定依然成立。 + */ + const stillListed = (snapshot.cards.items ?? []).some( + (c) => c.cardId === target.cardId && !c.expired, + ); + if (!stillListed) { + state.spent.set(fingerprint, { cardId: target.cardId, sourceType: target.sourceType, atMs: Date.now() }); + return { ok: false, skipped: true, message: '这张卡已不在可用列表中,跳过', action: snapshot.decision.action }; + } + + // 先记账:进程中途崩溃也不会把这张卡再扣一次 + state.spent.set(fingerprint, { cardId: target.cardId, sourceType: target.sourceType, atMs: Date.now() }); + + try { + const outcome = await apiUseResetCard(target.sourceType, target.outBizNo, token); + const success = outcome === 'SUCCESS' || outcome === 'REPLAYED'; + // 卡已消耗,立刻让列表缓存失效,下一轮重新取。 + // 否则 5 分钟内列表里还留着这张已用掉的卡,「是否还在列表」的判断会失真。 + if (success) { + invalidateCache('cards'); + invalidateCache('cardFlows'); + } + state.lastResult = { + atMs: Date.now(), + cardId: target.cardId, + outcome, + ok: success, + label: reasonLabel, + }; + return { + ok: success, + outcome, + cardId: target.cardId, + message: success + ? `已使用重置卡 ${target.cardId},额度已重置` + : `重置卡不可用(${outcome})`, + action: snapshot.decision.action, + }; + } catch (error) { + state.lastResult = { + atMs: Date.now(), + cardId: target.cardId, + ok: false, + outcome: 'ERROR', + label: reasonLabel, + }; + throw error; + } +} + +/** + * 自动执行一轮:额度耗尽时按「先过期先用」的结论决定用卡还是留卡。 + * + * 只有结论是 use-card 时才真正扣卡;结论是 use-credit(积分先过期)时 + * 只记录一条决策,不做任何写操作——我们无法指定服务端扣哪一笔积分。 + */ +async function autoTick(trigger) { + const now = Date.now(); + // 提前返回的分支也要带 outcome,否则页面会渲染成 undefined + if (!state.autoEnabled) { + return { atMs: now, trigger, outcome: 'AUTO_OFF', reason: '自动执行未开启', message: '' }; + } + state.lastAutoAtMs = now; + + let snapshot; + try { + snapshot = await buildSnapshot(); + } catch (error) { + const entry = { atMs: now, trigger, outcome: 'ERROR', message: describe(error) }; + state.autoRuns.unshift(entry); + state.autoRuns.length = Math.min(state.autoRuns.length, 20); + return entry; + } + + const d = snapshot.decision; + let entry; + if (d.action === 'use-card') { + entry = { atMs: now, trigger, outcome: 'USE_CARD', reason: d.reason, message: '' }; + try { + const result = await useCard('自动执行'); + // 「这张卡已尝试过」是去重保护在正常工作,不是失败。 + // 记成 CARD_FAILED 会让同一页日志被这条刷屏,反而淹没真正的扣卡失败。 + if (result.skipped) { + return { skipped: 'already-spent', outcome: 'SKIPPED_DUPLICATE' }; + } + entry.outcome = result.ok ? 'CARD_USED' : 'CARD_FAILED'; + entry.message = result.message; + entry.cardId = result.cardId; + } catch (error) { + entry.outcome = 'CARD_ERROR'; + entry.message = describe(error); + } + } else if (d.action === 'use-credit') { + entry = { + atMs: now, + trigger, + outcome: 'KEEP_CREDIT', + reason: d.reason, + message: '积分先过期,保留重置卡,未执行任何写操作', + }; + } else { + entry = { atMs: now, trigger, outcome: 'NO_ACTION', reason: d.reason, message: '' }; + } + state.autoRuns.unshift(entry); + state.autoRuns.length = Math.min(state.autoRuns.length, 20); + return entry; +} + +// ── 后台守护进程管理(launchd)──────────────────────────────────────────────── +/** + * 后台服务由 launchd 常驻,关闭本页面后仍会运行 —— + * 这正是它与页面内 SSE 自动判定的区别。 + */ +const DAEMON_LABEL = 'com.local.mcode-reset-card'; +const DAEMON_PLIST = join(homedir(), 'Library', 'LaunchAgents', `${DAEMON_LABEL}.plist`); + +async function run(cmd, args) { + const { spawn } = await import('node:child_process'); + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout.on('data', (d) => { out += d.toString(); }); + child.stderr.on('data', (d) => { out += d.toString(); }); + child.on('close', (code) => resolve({ code, out })); + child.on('error', (e) => resolve({ code: -1, out: String(e) })); + }); +} + +/** 后台服务当前是否在跑(读 launchd 已加载的作业表)。 */ +async function daemonStatus() { + const { existsSync } = await import('node:fs'); + const installed = existsSync(DAEMON_PLIST); + if (!installed) return { installed: false, running: false, pid: null, label: DAEMON_LABEL }; + + // 注意:`launchctl list