From 38b595e6f7478e2227f77ab4e4eff9f084aa81c3 Mon Sep 17 00:00:00 2001 From: tursom Date: Tue, 23 Jun 2026 23:29:32 +0800 Subject: [PATCH] chore: migrate Steam Chat to TypeScript and expand tests --- .gitignore | 4 + AGENTS.md | 907 ------------- API.md | 643 ---------- FEATURES.zh-CN.md | 430 +++++++ README.md | 289 ----- README.zh-CN.md | 289 ----- UI_REFACTOR_TODO.md | 319 ----- agents/SELF_REVIEW.md | 558 -------- agents/plan-mode.md | 228 ---- chat.js | 1990 ----------------------------- client.js | 68 - config.example.js | 39 +- logger.js | 111 -- package-lock.json | 797 ++++-------- package.json | 24 +- public/app.js | 401 ------ public/app/AGENTS.md | 81 -- public/app/bootstrap.js | 103 -- public/app/composer.js | 997 --------------- public/app/dom.js | 68 - public/app/layout.js | 54 - public/app/lightbox.js | 459 ------- public/app/managed-images.js | 242 ---- public/app/message-bubble.js | 128 -- public/app/messages.js | 118 -- public/app/notifications.js | 96 -- public/app/preferences.js | 34 - public/app/rich-content.js | 159 --- public/app/session.js | 97 -- public/app/sidebar.js | 217 ---- public/app/status.js | 24 - public/app/utils.js | 169 --- public/app/websocket.js | 156 --- public/index.html | 169 --- public/style.css | 6 - public/styles/base.css | 215 ---- public/styles/composer.css | 375 ------ public/styles/messages.css | 264 ---- public/styles/overlays.css | 125 -- public/styles/responsive.css | 446 ------- public/styles/sidebar.css | 154 --- src/config/load.ts | 28 + src/index.ts | 184 +++ src/paths.ts | 22 + src/server/auth.ts | 93 ++ src/server/chat-service.ts | 781 +++++++++++ src/server/network.ts | 32 + src/steam/lifecycle.ts | 319 +++++ src/steam/message-logger.ts | 131 ++ src/storage/chat-log.ts | 227 ++++ src/storage/media-cache.ts | 167 +++ src/types.ts | 106 ++ steam-lifecycle.js | 325 ----- test/auth-network.test.ts | 100 ++ test/chat-service-helpers.test.ts | 107 ++ test/chat.test.js | 1072 ---------------- test/chat.test.ts | 210 +++ test/logger.test.ts | 68 + test/media-cache.test.ts | 135 ++ test/message-logger.test.ts | 105 ++ test/steam-lifecycle.test.js | 321 ----- test/steam-lifecycle.test.ts | 110 ++ tsconfig.json | 17 + web/app.ts | 1174 +++++++++++++++++ web/index.html | 100 ++ web/style.css | 691 ++++++++++ 66 files changed, 5604 insertions(+), 13074 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 API.md create mode 100644 FEATURES.zh-CN.md delete mode 100644 README.md delete mode 100644 README.zh-CN.md delete mode 100644 UI_REFACTOR_TODO.md delete mode 100644 agents/SELF_REVIEW.md delete mode 100644 agents/plan-mode.md delete mode 100644 chat.js delete mode 100644 client.js delete mode 100644 logger.js delete mode 100644 public/app.js delete mode 100644 public/app/AGENTS.md delete mode 100644 public/app/bootstrap.js delete mode 100644 public/app/composer.js delete mode 100644 public/app/dom.js delete mode 100644 public/app/layout.js delete mode 100644 public/app/lightbox.js delete mode 100644 public/app/managed-images.js delete mode 100644 public/app/message-bubble.js delete mode 100644 public/app/messages.js delete mode 100644 public/app/notifications.js delete mode 100644 public/app/preferences.js delete mode 100644 public/app/rich-content.js delete mode 100644 public/app/session.js delete mode 100644 public/app/sidebar.js delete mode 100644 public/app/status.js delete mode 100644 public/app/utils.js delete mode 100644 public/app/websocket.js delete mode 100644 public/index.html delete mode 100644 public/style.css delete mode 100644 public/styles/base.css delete mode 100644 public/styles/composer.css delete mode 100644 public/styles/messages.css delete mode 100644 public/styles/overlays.css delete mode 100644 public/styles/responsive.css delete mode 100644 public/styles/sidebar.css create mode 100644 src/config/load.ts create mode 100644 src/index.ts create mode 100644 src/paths.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/chat-service.ts create mode 100644 src/server/network.ts create mode 100644 src/steam/lifecycle.ts create mode 100644 src/steam/message-logger.ts create mode 100644 src/storage/chat-log.ts create mode 100644 src/storage/media-cache.ts create mode 100644 src/types.ts delete mode 100644 steam-lifecycle.js create mode 100644 test/auth-network.test.ts create mode 100644 test/chat-service-helpers.test.ts delete mode 100644 test/chat.test.js create mode 100644 test/chat.test.ts create mode 100644 test/logger.test.ts create mode 100644 test/media-cache.test.ts create mode 100644 test/message-logger.test.ts delete mode 100644 test/steam-lifecycle.test.js create mode 100644 test/steam-lifecycle.test.ts create mode 100644 tsconfig.json create mode 100644 web/app.ts create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore index be0054e..0d366c1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ coverage/ # OS .DS_Store + +.omc +.sisyphus +.claude diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 28fb466..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,907 +0,0 @@ -# Steam Chat - Project Knowledge Base - -**Generated:** 2026-05-06 -**Stack:** Node.js (CommonJS), ES6 Frontend Modules, WebSocket, Steam API - -## OVERVIEW -Real-time Steam chat service with HTTP/WebSocket API and built-in responsive web UI. Supports messaging, images, stickers, and emoticons. - -## STRUCTURE -``` -./ -├── client.js # Steam client wrapper, user cache -├── chat.js # HTTP server + WebSocket (core, 1800+ lines) -├── steam-lifecycle.js # Steam connection lifecycle with retry logic -├── logger.js # Winston logger + chat history logging -├── config.js # Runtime configuration -├── config.example.js # Configuration template -├── package.json # Dependencies: steam-user, ws, winston, axios -├── public/ # Frontend assets -│ ├── index.html # Main page (Chinese UI) -│ ├── app.js # Frontend entry (ES6 modules) -│ ├── app/ # Modular frontend components -│ └── styles/ # Modular CSS -├── logs/ # chat.jsonl, image/sticker cache -├── test/ # Node.js built-in test runner -└── scripts/ # Utility scripts -``` - -## WHERE TO LOOK -| Task | Location | Notes | -|------|----------|-------| -| Steam login/auth | `steam-lifecycle.js` | Refresh token support, retry with backoff | -| HTTP API routes | `chat.js` (lines 1-100) | `/message`, `/image`, `/history`, `/conversations` | -| WebSocket handlers | `chat.js` (search `wss.on`) | Types: send_message, get_history, get_conversations | -| Frontend state | `public/app/session.js` | Active conversation, friends, groups | -| WebSocket client | `public/app/websocket.js` | Auto-reconnect, request IDs | -| Message composer | `public/app/composer.js` | Input, attachments, emoticon picker | -| Message rendering | `public/app/messages.js`, `message-bubble.js` | Bubbles, separators, rich content | -| Chat logging | `logger.js` | JSONL format, imports history on first load | - -## CONVENTIONS - -### Backend (Node.js - CommonJS) -- `type: "commonjs"` in package.json -- `require()` for imports, `module.exports` for exports -- Async/await preferred over callbacks -- Winston logger with structured metadata: `logger.info("msg", { key: value })` -- Steam result codes: Use `EResult` from `steam-user/enums/EResult` - -### Frontend (Browser - ES6 Modules) -- Native ES6 modules: `import/export` (type="module" in script tag) -- Factory pattern: `createXController({ deps })` returns public API -- Event delegation over individual listeners -- DOM refs centralized in `dom.js` - -### File Naming -- Backend: camelCase (e.g., `steam-lifecycle.js`) -- Frontend: camelCase, descriptive (e.g., `message-bubble.js`) -- CSS: matches component name (e.g., `composer.css`) - -### Configuration -- `config.js` required at runtime (not in repo) -- `config.example.js` as template -- Environment: `STEAM_CHAT_DISABLE_AUTOSTART=1` for testing - -## ANTI-PATTERNS (THIS PROJECT) -- **DO NOT** mix CommonJS and ES6 modules within same file -- **DO NOT** use global variables in frontend (use module-scoped) -- **NEVER** commit `config.js` with real credentials -- **AVOID** modifying `chat.js` without checking WebSocket message type contracts - -## SPECIAL RULES - -### WebSocket Payload Naming (CRITICAL) -- `type: "message"` payloads MUST set `data.name` to sender nickname -- Incoming friend messages: `data.name` = friend's nickname -- Self-sent echo messages: `data.name` = current Steam account nickname - -### Steam Lifecycle -- Supports refresh token auth (auto-saves to `refresh.token`) -- Exponential backoff retry: starts 5s, max 5min -- Distinguishes recoverable vs non-recoverable errors (see `steam-lifecycle.js` sets) - -### Chat Log Format -- JSONL file: `logs/chat.jsonl` -- Fields: `date`, `echo` (boolean), `id` (SteamID64), `name`, `message`, `ordinal` -- Image cache: `logs/images/`, Sticker cache: `logs/stickers/` - -## COMMANDS -```bash -# Install dependencies -npm install - -# Run (requires config.js) -node client.js - -# Test (disables auto-start) -npm test -# or -STEAM_CHAT_DISABLE_AUTOSTART=1 node --test - -# Setup config -cp config.example.js config.js -# Edit config.js with your Steam credentials -``` - -## NOTES -- Default port: 3000 (configurable in `config.chat.port`) -- WebSocket path: `/ws` (configurable) -- HTTP Basic Auth optional but recommended for production -- Frontend is Chinese language (zh-CN) -- Mobile responsive: breakpoint at 900px -- Image proxy caches remote images locally -- Uses Node.js native test runner (no jest/mocha) -- Large files: `chat.js` (~1800 lines), `composer.js` (~600 lines) - ---- - -# Plan Mode 工作流 - -> 先计划、后执行。每次非平凡任务必须经过 P → R → A → E 四阶段闭环。 -> 建立日期:2026-05-09 - ---- - -## 1. 触发方式 - -### 方式 A — 显式触发 - -``` -ulw plan <需求描述> -``` - -### 方式 B — 隐式触发 - -以下条件任一满足时自动进入 Plan Mode: -- 涉及 2+ 个文件的改动 -- 涉及架构决策 -- 涉及跨服务修改 -- 涉及新的业务流程 - ---- - -## 2. 四阶段工作流 - -``` -P 阶段 (Plan) ──→ R 阶段 (Review) ──→ A 阶段 (Approve) ──→ E 阶段 (Execute) - │ │ │ │ - │ 我出计划 │ 你审反馈 │ 你签字审批 │ 我执行 - │ 结构化文档 │ 逐条过 │ 无遗留问题 │ 按计划推进 - └───────────────────┴────────────────────┴────────────────────┴────────── -``` - ---- - -## 3. P 阶段:计划制定 - -### 3.1 产出物 - -计划文档,写入 `.sisyphus/plans/{task-name}.md`,包含 6 个章节: - -| 章节 | 内容 | -|------|------| -| 1. 需求理解 | 我对需求的重新表述 + 待确认疑问 | -| 2. 范围界定 | In Scope / Out of Scope 清单 | -| 3. 技术方案 | 架构决策、修改文件、技术路线 | -| 4. 任务分解 | 按 Wave(并行批次)拆分的任务列表,每任务含:文件、改动描述、依赖 | -| 5. 风险点 | 潜在风险、回滚方案、需要关注的点 | -| 6. 验证计划 | 如何验证每个改动正确 | - -### 3.2 内部流程 - -``` -P0: 需求澄清(如有歧义 → 提问) -P1: 探索代码库(explore agent 并行搜索相关代码、模式、依赖) -P2: 复杂架构 → 咨询 Oracle(技术方案评审) -P3: 撰写计划文档(上述 6 个章节) -P4: 输出给用户审核 -``` - -### 3.3 执行完成标准(通用门禁) - -``` -- [ ] 所有任务标记完成 -- [ ] 项目构建通过(按项目实际工具:go build / npm run build / cargo build / 等) -- [ ] 项目测试通过(按项目实际工具:go test / npm test / pytest / 等) -- [ ] LSP 诊断无新增错误 -- [ ] 改动的代码通过了手动验证(见验证计划各条) -``` - ---- - -### 3.4 计划文档模板 - -```markdown -# Plan: {任务名称} - -## 1. 需求理解 -{我对需求的重新表述} - -## 2. 范围界定 -### In Scope -- {list} -### Out of Scope -- {list} - -## 3. 技术方案 -{架构图/决策说明/文件清单} - -## 4. 任务分解 - -| Wave | Task ID | 描述 | 文件 | 依赖 | 预期产出 | -|------|---------|------|------|------|----------| -| 1 | T1 | ... | ... | 无 | ... | -| 1 | T2 | ... | ... | 无 | ... | -| 2 | T3 | ... | ... | T1,T2 | ... | - -## 5. 风险点 -| 风险 | 概率 | 影响 | 缓解措施 | -|------|------|------|----------| -| ... | 高/中/低 | ... | ... | - -## 6. 验证计划 -| 验证项 | 方法(按项目实际工具填写) | 预期结果 | -|--------|---------------------------|----------| -| 编译 | {go build / npm run build / cargo build / ...} | exit 0 | -| 单元测试 | {go test / npm test / pytest / ...} | 全部通过 | -| Lint/类型检查 | {golangci-lint / tsc / mypy / ...} | 无新增问题 | -| 手动验证 | {按功能描述执行的操作步骤} | 符合预期 | -``` - ---- - -## 4. R 阶段:审核反馈 - -### 角色分工 - -- **你**:逐章节审阅计划,给出修改意见,指出遗漏,调整优先级 -- **我**:根据反馈更新计划文档,对每条反馈回应(接受/解释/替代方案),方案重大变更则重新咨询 Oracle - -### 你说话的格式 - -``` -plan 反馈: -1. [章节名] 第X条:建议改为... -2. [风险] 漏掉了YY场景 -3. [任务分解] Wave 2 应该先于 Wave 1 -``` - -### 我回应的格式 - -``` -计划更新 v2: -[章节] 已修改:... -[章节] 已采纳反馈:... -[章节] 关于第X点,我的考虑是... 是否保持原方案? -``` - -### 轮次 - -不限,直至你满意。 - ---- - -## 5. A 阶段:审批通过 - -### 触发词 - -你说以下之一即表示审批通过: - -``` -plan 通过 -审核通过 -批准执行 -Plan Approved -``` - -### 审批门禁(前置条件) - -- [ ] 所有疑问已澄清 -- [ ] 需求理解无误 -- [ ] 技术方案没有明显漏洞 -- [ ] 任务分解完整(没有遗漏步骤) -- [ ] 验证计划合理 - -审批后,计划文档进入**锁定状态**。执行阶段严格按计划推进。 - ---- - -## 6. E 阶段:执行 - -### 执行原则 - -1. 严格按照任务分解的 **Wave 顺序** 执行 -2. 每个 Wave 内任务**并行**执行 -3. 每个任务完成后:`lsp_diagnostics` + 验证步骤 -4. 每完成一个 Wave:运行一次 build + test -5. 每完成一个 Wave:向你更新进度 - -### 执行偏差处理 - -| 情况 | 处理方式 | -|------|----------| -| 执行中发现计划遗漏 | 暂停 → 报告偏差 → 等你决策 | -| 执行中遇到计划错误的假设 | 暂停 → 分析根因 → 提交方案变更请求 | -| 执行中发现更好方案 | 暂停 → 说明理由 → 等你决定是否改用新方案 | -| 执行一切顺利 | 继续推进,直到全部完成 | - -### 变更请求格式 - -``` -变更请求 #1: -- 原计划:[Wave X, Task Y] -- 发现的问题:[具体问题] -- 建议修改为:[新方案] -- 理由:[为什么不按原计划] -- 影响:[对其他任务的影响] -请确认是否批准此变更。 -``` - ---- - -## 7. Plan Mode 与直接执行模式的选择 - -| 维度 | 直接执行 | Plan Mode | -|------|----------|-----------| -| 开始前 | 直接开始编码 | 先出计划文档 | -| 用户参与 | 执行中提反馈 | 计划阶段先审核 | -| 变更处理 | 随时改 | 正式变更请求 | -| 范围控制 | 容易 scope creep | 严格按计划执行 | -| 风险把控 | 走一步看一步 | 提前识别风险 | -| 适用场景 | 1 个文件的简单修改 | 2+ 文件、新功能、架构变更 | - ---- - -## 8. 执行完成标准(通用) - -所有执行任务完成后,必须逐条验证: - -- [ ] 所有任务标记完成 -- [ ] 项目构建通过(按项目实际工具填写命令) -- [ ] 项目测试通过(按项目实际工具填写命令) -- [ ] LSP 诊断无新增错误 -- [ ] 改动的代码通过了手动验证(见验证计划各条) - -**以上门禁全部通过才算任务完成。** - ---- - -# 通用功能模块自评审流程 - -> 一键触发:`ulw 启动自评审流程,目标:<包路径 | 功能模块描述>` -> 交互触发:`ulw 我要自评审`(逐步问答,无需记参数) -> 示例:`ulw 启动自评审流程,目标:core/helper/search` -> 示例:`ulw 启动自评审流程,目标:订单结算流程` -> 示例:`ulw 启动自评审流程,目标:库存同步与发货` - -## 触发方式 - -支持两种触发模式: - -### 模式 1:直接触发(参数完整) - -适合熟悉格式、一次性写全的场景。 - -Sisyphus 将根据输入自动判断目标类型: - -- **Go 包路径**(包含 `/` 或 `.`,如 `core/helper/search`)→ 走包发现模式 -- **功能模块描述**(自然语言,如 `订单结算流程`)→ 走功能模块发现模式 -- **手动指定文件清单**:在上述参数后追加 `|` 分隔的文件路径,跳过自动发现 - -``` -ulw 启动自评审流程,目标: [| file1.go,file2.go] [可选: 额外上下文] -``` - -额外上下文示例: -- `该模块负责订单结算,依赖 MySQL + Redis` -- `该模块是新增的,需要特别注意错误处理` -- `重点审查并发安全性` -- `涉及多服务交互:go-game-trade-serve → go-goods-serve` - -### 模式 2:交互式触发(推荐) - -记不住格式、或者想一步步来的时候用。无需记忆任何参数。 - -``` -ulw 我要自评审 -``` - -Sisyphus 收到后将通过对话逐项询问: - -1. **评审目标** — 包路径?功能模块描述?还是直接给文件列表? -2. **额外上下文** — 业务背景、关注重点、涉及的服务等 -3. **确认** — 展示理解到的目标,让用户确认后再启动 - -相当于把一次性参数填写变成了问答式引导,降低心智负担。 - -## Sisyphus 自动执行流程 - -Sisyphus 收到此请求后将执行: - -### Phase 0: 文件发现 - -**模式 A — 按包路径发现:** -- 解析目标包路径 -- `glob` + `grep` 发现包内所有 `.go` 文件 -- `grep` 发现包间引用关系 -- **反向发现调用者**:grep 搜索服务目录下哪些文件调用了目标包的导出函数/类型(如 `Start`、`TryPublish`、`Handle` 等入口),将这些调用者文件加入覆盖清单 - - 例:审查 `consumer/goods` 包时,发现调用它的 `game_trade.go`(API handler)和 producer 定时任务文件,加入审查范围 - - 反向发现确保端到端链路完整:调用者的日志级别、返回信息是否与模块实际行为一致 -- 汇总为「覆盖文件清单」 - -**模式 B — 按功能模块描述发现:** -- 启动 2 个 `explore` Agent 并行探索: - - Agent 1:根据功能描述在相关服务目录下搜索关键词、结构体、函数 - - Agent 2:根据功能描述搜索配置、路由注册、API 入口等外围文件 -- 合并结果去重,形成「覆盖文件清单」 -- 如发现跨服务调用,在报告中注明涉及的外部服务 - -**模式 C — 手动指定文件清单:** -- 跳过自动发现,直接使用用户提供的文件路径列表 -- 对每个文件做存在性验证,不存在的文件报告警告 - -### Phase 1: 基线检查 - -- 若覆盖文件清单归属单一服务或单一包 → `go build ./...` + `go test ./...` -- 若跨多个包 → 对每个涉及的独立包分别运行 build + test -- 失败则先不进入审查,报告用户 - -### Phase 2: 审查循环 (直至质量达标) - -循环核心原则:**不设轮次上限,只以质量门禁是否全部通过为终止条件。** - -#### 质量门禁(必须全部通过) - -| # | 门禁 | 判定方式 | 一票否决 | -|---|------|----------|----------| -| G1 | 代码正确性审查 verdict = PASS | Oracle Agent 1 | 是 | -| G2 | 安全+边界审查 verdict = PASS | Oracle Agent 2 | 是 | -| G3 | 架构+模式审查 verdict = PASS | Oracle Agent 3 | 是 | -| G4 | 攻击者测试 verdict = PASS | Oracle Agent 4 | 是 | -| G5 | 交叉验证 verdict = PASS | Sisyphus 对比 4 个 Agent 发现 | 是 | -| G6 | 误判检测 verdict = NO_MISJUDGMENT | Sisyphus 逐条复审 findings | 是 | -| G7 | 零 CRITICAL/MAJOR 残留 | 汇总所有 findings 检查 | 是 | -| G8 | 零回归问题 | 对比上一轮 findings,新引入的算回归 | 是 | -| G9 | go build 通过 | bash 执行 | 是 | -| G10 | go test 通过 | bash 执行 | 是 | - -所有门禁通过(PASS)才算质量达标,否则继续循环。 - -#### 循环流程 - -``` -第 N 轮: - ├─ Phase 2a: 并行审查 - │ ├─ 启动 4 个 Oracle 并行审查(使用 Phase 0 发现的文件清单): - │ │ bg_1: 代码正确性 (oracle, Agent 1) - │ │ bg_2: 安全 + 边界条件 (oracle, Agent 2) - │ │ bg_3: 架构 + 模式 (oracle, Agent 3) - │ │ bg_4: 攻击者测试 (oracle, Agent 4) - │ └─ 等待全部完成 - ├─ Phase 2b: 交叉验证 - │ ├─ Agent 4 审查 Agent 1-3 的发现补充遗漏 - │ ├─ Agent 1-3 审查 Agent 4 的发现去重 - │ └─ Sisyphus 汇总生成完整 findings 清单 - ├─ Phase 2c: 质量门禁检查 - │ ├─ 全部 10 项 PASS → 输出最终报告,循环终止 - │ └─ 有 FAIL 项 → 进入修复流程 - ├─ 修复流程: - │ ├─ 回归问题优先修复(先还旧债,再修新债) - │ ├─ 误判优先复查(G6 FAIL 说明某条 finding 被错误判定,先纠正) - │ ├─ 按类型分流到修复 Agent: - │ │ 单文件修改 → category="quick" - │ │ 多文件/复杂 → category="deep" - │ └─ 修复后执行 build + test - ├─ 如果连续 3 轮同一门禁 FAIL(僵局处理): - │ ├─ 启动 Oracle 深度诊断,分析为什么反复修不好 - │ ├─ 输出根因分析 + 替代方案 - │ └─ 上报用户决策:继续修 / 接受现状 / 改方案 - └─ 进入第 N+1 轮 -``` - -#### 僵局处理(Escalation) - -当连续 3 轮同一门禁 FAIL 时,说明常规修复手段无效。此时: - -1. **暂停修复**,不自欺欺人继续打补丁 -2. **启动 Oracle 深度诊断**,分析根因: - - 是设计缺陷导致修不好?(如:当前架构本身就不安全) - - 是修复引入了新问题?(如:为了修 A 破坏了 B) - - 是审查标准不合理?(如:过于理想化,与现有代码风格冲突) -3. **输出根因分析报告**,给出 2-3 个可选方案 -4. **上报用户**,由用户决策下一步方向 - -### Phase 3: 输出报告 - -## 审查 Agent 提示词模板 - -Sisyphus 将「覆盖文件清单」和模块上下文代入以下模板。 - -**所有 Agent 的 prompt 均包含四个通用要求(在模板中已内置):** - -``` -通用要求(对所有维度均适用): -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件(如 producer 写入 → event bus → consumer 读取并释放) - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值?(例如:重获取锁超时 = 5 秒,假设 priority 在 5 秒内跑完;但 priority 实际耗时可达 30 分钟——正常路径和边界路径混用了同一套超时) - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码?(例如:`LoadInt64 > 0` 的检查与后续操作之间假设了状态不变) - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? - -``` - -### Agent 1: 代码正确性 - -``` -task(subagent_type="oracle", load_skills=[], run_in_background=true, - description="Review correctness of MODULE_NAME", - prompt=""" -CODE CORRECTNESS + QUALITY REVIEW -{MODULE_NAME} -{NEWLINE_SEPARATED_FILE_LIST_WITH_FULL_CONTENT} -{MODULE_SPECIFIC_CONTEXT_FROM_USER} - -通用要求: -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件 - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值? - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? -Review for: -- Logic errors -- Concurrency issues: 死锁、活锁、竞态条件、双释放、ABA 问题、原子操作误用 -- 共享的 sync.Map/atomic.Pointer 等原语:区分"操作本身安全"和"业务语义安全" - (例如:sync.Map.Delete 是幂等的,但如果当前存储的是其他 goroutine 的 token, - 删除它就破坏了其他持有者的锁 — 这种"跨 goroutine 的语义安全性") -- defer cleanup 的覆盖完整性:所有获取操作是否有对应的 defer/手动释放 -- Error handling gaps: 错误被吞没、错误类型误判(如用 ctx.Err() 代替被包裹的 err) -- Data integrity risks -- Nil pointer dereference potential -- Dead code - -OUTPUT: PASS or FAIL each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation -""") -``` - -### Agent 2: 安全 + 边界条件 - -``` -task(subagent_type="oracle", load_skills=[], run_in_background=true, - description="Review security of MODULE_NAME", - prompt=""" -SECURITY + EDGE CASE REVIEW -{MODULE_NAME} -{FILE_LIST} - -通用要求: -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件 - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值? - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? - -Review for: -- Input validation 的覆盖面和充分性 -- Injection risks (SQL/命令/prompt injection 等) -- Secrets exposure (API key 是否被意外记入日志) -- DoS vectors(无界 goroutine、内存爆炸、死循环、无穷递归) -- 操作幂等性 vs 业务语义安全性的区别 - (例如:db.Delete 不报错 ≠ 业务语义正确;map.Delete 不 panic ≠ 没破坏其他持有者的状态) -- Edge cases: empty inputs, max-size, unicode, zero values, negative values, context cancelled during write -- Panic paths: 是否有未 recover 的 panic 点 -- Resource leaks: HTTP body, goroutine, channel, semaphore -- Timeout handling: 内外层超时不匹配、超时后状态不一致 -- 攻击者视角:假设调用者可以控制输入参数,找到所有利用路径 - -OUTPUT: PASS or FAIL each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, and concrete explanation -""") -``` - -### Agent 3: 架构 + 模式 - -``` -task(subagent_type="oracle", load_skills=[], run_in_background=true, - description="Review architecture of MODULE_NAME", - prompt=""" -ARCHITECTURE + PATTERN REVIEW -{MODULE_NAME} -{FILE_LIST} - -通用要求: -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件 - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值? - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? - -Review for: -- Package structure: 依赖方向是否清晰,是否存在循环依赖 -- 对称性检查: 是否有类似的代码路径(如 A vs B、game vs price、producer vs consumer) - 它们的 cleanup/错误处理是否一致?不一致的差异是否有正当理由? -- 代码重复: 哪些可以抽象复用,哪些是必要差异 -- Over-engineering: 是否存在不必要复杂度 -- Dead code: 未使用的函数、类型、字段、常量 -- 常量组织: 分散或重复的常量、硬编码值 -- Interface design: 是否利于测试(mockable)、扩展 -- 全局状态依赖: 是否过度依赖全局变量,影响可测试性和并发安全性 -- 已知设计约束记录: 如无法避免的依赖倒置,标注为已知约束 - -OUTPUT: PASS or FAIL each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation -""") -``` - -### Agent 4: 攻击者测试 - -这是新增角色,专门从"破坏系统"角度审查。它不检查"代码好不好看",只检查"有什么方式能让系统坏掉"。 - -``` -task(subagent_type="oracle", load_skills=[], run_in_background=true, - description="Adversarial review of MODULE_NAME", - prompt=""" -ADVERSARIAL + DESTRUCTIVE TESTING -{MODULE_NAME} -{FILE_LIST} - -角色: 你是恶意攻击者/系统破坏者。你的目标是找到所有方式让这个模块出错、崩溃、数据损坏、或行为异常。 -你不关心代码风格或架构优雅性。只关心:**我怎么搞坏它?** - -通用要求: -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件 - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值? - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? - -找以下类别的破坏路径(每个类别给出具体时序): - -1. 并发破坏: - - 双释放:同一个资源被释放两次,第二次释放时已被其他人持有 - - 释放后使用:资源被释放后仍有代码路径访问它 - - 先读后写竞态:TOC/TOU (time-of-check vs time-of-use) - - 死锁/活锁/自旋:循环等待条件永远不满足 - - 优先级反转:高优先级任务被低优先级任务阻塞超过预期 - - ABA 问题:atomic.CompareAndSwap 的经典陷阱 - -2. 状态泄露: - - Defer/resource 泄露:某个 return 路径遗漏了资源释放 - - 对称性违反:A 路径有 cleanup,B 路径没有 - - 永久残留:某个 key/token 写入 map 后没有删除路径 - -3. 数据损坏: - - 并发写入同一条记录 - - 部分更新:一批操作中部分成功部分失败 - - 脏读:读到不完整的状态 - -4. 静默失败: - - 错误被 log 后继续执行(错误被吞没) - - 返回成功但实际未执行任何操作 - - 条件竞争导致跳过执行 - -5. 超时/取消不一致: - - 外层超时比内层短,导致内层操作被无故终止 - - 取消后状态未回滚 - - 超时后仍有 goroutine 在后台运行 - -OUTPUT: PASS or FAIL each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, concrete exploit scenario, and expected impact -""") -``` - -## 交叉验证 - -所有 Agent 返回 findings 后,Sisyphus 执行交叉验证: - -``` -交叉验证步骤: - -1. 收集 4 个 Agent 的所有 findings,去重合并 - -2. 让 Agent 4 审查 Agent 1-3 的 findings: - - 是否有 Agent 1-3 判定为 PASS 但 Agent 4 持怀疑态度的? - - 是否有 Agent 1-3 标记为 MINOR 但 Agent 4 认为可能是 MAJOR/CRITICAL 的? - - 输出: 补充遗漏、严重度修正建议 - -3. 让 Agent 1-3 审查 Agent 4 的 findings: - - 是否与 Agent 1-3 的已有发现重叠? - - 是否有 Agent 4 发现但其他 Agent 确实遗漏的关键问题? - - 输出: 去重后的新增 findings - -4. Sisyphus 逐条检查 findings 的判定质量(误判检测): - - 对每条 finding,检查是否有 Agent 给出了"底层操作安全,无实际 bug"类判定 - - 追问:该操作在 业务语义 上是否安全?(即是否可能破坏其他 goroutine 的状态) - - 如果发现误判 → 标注 MISJUDGMENT,该轮 G6 门禁 FAIL -``` - -交叉验证的输出格式: -``` - - - 严重度从 MINOR 修正为 MAJOR: ... - Agent 1-3 未发现的路径: ... - - - 与 agent1_finding_7 重复,合并 - 确认遗漏,补充到主清单 - - - - 原判定: "sync.Map.Delete 幂等安全,无实际 bug" - 纠正: 虽然 Delete 不 panic,但此时 map 中存的是其他 goroutine 的 token,删除它导致该 goroutine 状态泄露 - - - -``` - -## 修复 Agent 分流规则 - -| 问题规模 | Agent | 策略 | -|----------|-------|------| -| 单文件简单修改 | `category="quick"` | 逐一给出文件路径+行号+精确修改内容 | -| 多文件协调修改 | `category="quick"` 分批 | 按「修改的文件不重叠」原则并行 | -| 复杂逻辑重写 | `category="deep"` | 给出完整上下文和期望结果 | - -## 退出条件 - -循环终止条件(按优先级): - -| 优先级 | 条件 | 说明 | -|--------|------|------| -| 1 | **全部质量门禁通过** | 正常退出 — 质量达标 | -| 2 | **用户手动终止** | `stop` / `终止` / `暂停` | -| 3 | **僵局经用户决策终止** | 上报后用户选择「接受现状」或「改方案」 | - -**不存在「无新发现就自动停止」这条退路。** 只要门禁没全过,就继续循环。 -只有质量达标、用户叫停、或用户决策接受现状这三种情况才能终止。 - -## 最终报告模板 - -```markdown -# {MODULE_NAME} 自评审报告 - -## 总览 -- 模块: {MODULE_NAME} -- 发现模式: [包路径 / 功能模块探索 / 手动指定] -- 涉及包/服务: {PACKAGES / SERVICES} -- 轮次: {ROUNDS} -- 最终判定: PASS / FAIL -- 已修复: {FIXED_COUNT} 项 -- 已知设计约束: {CONSTRAINT_COUNT} 项 -- 审查 Agent: 4 个(正确性/安全+边界/架构+模式/攻击者测试) -- 交叉验证: [已执行 / 跳过] -- 误判检测: [无误判 / 发现 {N} 条误判并纠正] - -## 已修复问题 -| # | 严重度 | 描述 | 文件 | 修复方式 | -|---|--------|------|------|----------| - -## 误判纠正记录 -| # | 原判定 | 纠正后 | 描述 | -|---|--------|--------|------| - -## 已知设计约束 -| # | 描述 | 原因 | -|---|------|------| - -## 验证 -- build: {STATUS} -- test: {PASSED}/{TOTAL} -``` diff --git a/API.md b/API.md deleted file mode 100644 index 4b042cc..0000000 --- a/API.md +++ /dev/null @@ -1,643 +0,0 @@ -# Steam Chat 服务 API 文档 - -本文档基于当前仓库中的 `chat.js` 实现整理,说明该服务对外提供的 HTTP 与 WebSocket API。 - -## 1. 启用服务 - -在 `config.js` 中启用 `chat` 配置即可。 - -### 最简写法 - -```js -module.exports = { - // ... - chat: true, -}; -``` - -等价于: - -```js -chat: { - enabled: true, - host: '0.0.0.0', - port: 3000, - wsPath: '/ws', - auth: { - username: 'admin', - password: 'change-me', - realm: 'Steam Chat', - trustProxy: false, - }, -} -``` - -### 完整写法 - -```js -chat: { - enabled: true, - host: '0.0.0.0', - port: 3000, - wsPath: '/ws', -} -``` - -## 2. 基本说明 - -- 默认监听地址:`0.0.0.0:3000` -- 默认 WebSocket 路径:`/ws` -- 可选 HTTP Basic Auth:当请求来源不是局域网/回环地址,且配置了 `chat.auth.username` 与 `chat.auth.password` 时,会要求输入用户名和密码 -- 反向代理支持:将 `chat.auth.trustProxy` 设为 `true` 后,会优先解析 `Forwarded`、`X-Forwarded-For`、`X-Real-IP` -- 根页面:`GET /` 会返回内置聊天页面 -- 历史记录来源:本地日志文件 `logs/chat.jsonl` -- 贴纸缓存目录:`logs/stickers` -- 图片缓存目录:`logs/images` -- 错误响应统一为: - -```json -{ "error": "错误信息" } -``` - -未认证时返回: - -```json -{ "error": "Authentication Required" } -``` - -## 3. 数据结构 - -### 3.1 历史消息项 `HistoryItem` - -```json -{ - "type": "message", - "date": "2026-03-20 10:00:00.000", - "echo": false, - "id": "7656119xxxxxxxxxx", - "name": "Friend", - "message": "hello", - "imageUrl": null, - "ordinal": 1, - "sentAt": null -} -``` - -字段说明: - -- `type`: `message` 或 `image` -- `date`: 格式通常为 `yyyy-mm-dd HH:MM:ss.l` -- `echo`: 是否为自己发送的消息 -- `id`: 会话对象 SteamID -- `name`: 发送方昵称;收到好友消息时为好友昵称,自己发出的回显消息时为当前账号昵称 -- `message`: 文本消息内容;图片记录通常为空字符串 -- `imageUrl`: 图片消息的远程地址,没有则为 `null` -- `ordinal`: Steam 消息序号;图片记录通常为 `null` -- `sentAt`: 某些图片记录可能带 ISO 时间戳 - -### 3.2 会话摘要项 `ConversationSummary` - -```json -{ - "id": "7656119xxxxxxxxxx", - "name": "Friend", - "updatedAt": "2026-03-20 10:00:00.000", - "preview": "hello", - "lastType": "message", - "lastEcho": false, - "messageCount": 12 -} -``` - -字段说明: - -- `preview`: 最近一条消息的摘要,可能是普通文本,也可能是 `[图片]`、`[贴纸] xxx`、`[表情] xxx` -- `lastType`: `message` 或 `image` -- `lastEcho`: 最近一条是否为自己发送 -- `messageCount`: 当前日志窗口内该会话的消息数 - -## 4. HTTP API - -以下示例默认服务地址为 `http://127.0.0.1:3000`。 - -### 4.1 发送文本消息 - -**POST** `/message` - -兼容别名:**POST** `/` - -请求体: - -```json -{ - "id": "7656119xxxxxxxxxx", - "msg": "你好" -} -``` - -必填字段: - -- `id`: 对方 SteamID -- `msg`: 文本内容 - -成功响应:`200 OK` - -```json -{ - "type": "message", - "date": "2026-03-20 10:00:00.000", - "echo": true, - "id": "7656119xxxxxxxxxx", - "name": "MyName", - "message": "你好", - "imageUrl": null, - "ordinal": 42, - "sentAt": null -} -``` - -示例: - -```bash -curl -X POST http://127.0.0.1:3000/message \ - -H 'Content-Type: application/json' \ - -d '{"id":"7656119xxxxxxxxxx","msg":"hello"}' -``` - -### 4.2 发送图片 - -**POST** `/image` - -兼容别名:**POST** `/img` - -请求体支持两种方式: - -#### 方式 A:直接上传 base64 - -```json -{ - "id": "7656119xxxxxxxxxx", - "img": "iVBORw0KGgoAAAANSUhEUg..." -} -``` - -`img` 可以是: - -- 纯 base64 内容 -- `data:image/png;base64,...` 这种 Data URL - -#### 方式 B:让服务端下载远程图片后转发 - -```json -{ - "id": "7656119xxxxxxxxxx", - "url": "https://example.com/demo.png" -} -``` - -说明: - -- `id` 必填 -- `img` 与 `url` 至少提供一个 -- 如果两者同时提供,服务端优先使用 `url` - -成功响应:`200 OK` - -```json -{ - "type": "image", - "date": "2026-03-20 10:00:00.000", - "echo": true, - "id": "7656119xxxxxxxxxx", - "name": "MyName", - "message": "", - "imageUrl": "https://...", - "ordinal": null, - "sentAt": "2026-03-20T10:00:00.000Z" -} -``` - -示例: - -```bash -curl -X POST http://127.0.0.1:3000/image \ - -H 'Content-Type: application/json' \ - -d '{"id":"7656119xxxxxxxxxx","url":"https://example.com/demo.png"}' -``` - -### 4.3 获取历史记录 - -**GET** `/history` - -查询参数: - -- `id`:可选,仅返回指定 SteamID 的记录 -- `limit`:可选,返回条数上限,默认 `100`,最大 `500` - -示例: - -```bash -curl 'http://127.0.0.1:3000/history?id=7656119xxxxxxxxxx&limit=50' -``` - -成功响应: - -```json -{ - "items": [ - { - "type": "message", - "date": "2026-03-20 10:00:00.000", - "echo": false, - "id": "7656119xxxxxxxxxx", - "name": "Friend", - "message": "hello", - "imageUrl": null, - "ordinal": 1, - "sentAt": null - } - ] -} -``` - -说明: - -- 数据来自本地日志 `logs/chat.jsonl` -- 返回结果按时间升序排序;同一时间下按 `ordinal` 升序 - -### 4.4 获取最近会话摘要 - -**GET** `/conversations` - -查询参数: - -- `limit`:可选,默认 `500`,最大 `500` - -示例: - -```bash -curl 'http://127.0.0.1:3000/conversations?limit=200' -``` - -成功响应: - -```json -{ - "items": [ - { - "id": "7656119xxxxxxxxxx", - "name": "Friend", - "updatedAt": "2026-03-20 10:00:00.000", - "preview": "hello", - "lastType": "message", - "lastEcho": false, - "messageCount": 12 - } - ] -} -``` - -说明: - -- 这里的 `limit` 是“用于生成摘要的历史记录条数”,不是最终会话数上限 -- 返回结果按 `updatedAt` 倒序排列 - -### 4.5 代理贴纸图片 - -**GET** `/proxy/sticker/:type` - -示例: - -```bash -curl -o sticker.png 'http://127.0.0.1:3000/proxy/sticker/Sticker_MalteseCry' -``` - -说明: - -- 服务会尝试从 Steam 贴纸地址下载图片 -- 成功后缓存到 `logs/stickers` -- 成功响应内容类型固定为 `image/png` - -### 4.6 代理远程图片 - -**GET** `/proxy/image?url=...` - -示例: - -```bash -curl -o image.png 'http://127.0.0.1:3000/proxy/image?url=https%3A%2F%2Fexample.com%2Fa.png' -``` - -说明: - -- 服务会下载指定远程图片并缓存到 `logs/images` -- 响应 `Content-Type` 会尽量根据 URL 后缀或源响应头推断 -- `url` 必须是 `http://` 或 `https://` - -### 4.7 内置聊天页面 - -**GET** `/` - -返回一个内置 HTML 页面,页面内部通过 WebSocket 调用下文的实时接口。 - -## 5. WebSocket API - -连接地址: - -```text -ws://: -``` - -默认示例: - -```text -ws://127.0.0.1:3000/ws -``` - -### 5.1 连接建立后的消息 - -服务端在连接成功后会先主动发送: - -```json -{ - "type": "ready", - "data": { - "wsPath": "/ws" - } -} -``` - -### 5.2 客户端请求格式 - -所有请求均为 JSON。可选携带 `requestId`,服务端会原样带回,便于请求响应配对。 - -```json -{ - "type": "send_message", - "requestId": "req-1", - "id": "7656119xxxxxxxxxx", - "msg": "hello" -} -``` - -### 5.3 支持的请求类型 - -#### 发送文本消息 - -```json -{ - "type": "send_message", - "requestId": "req-1", - "id": "7656119xxxxxxxxxx", - "msg": "hello" -} -``` - -兼容别名:`type: "msg"` - -成功响应: - -```json -{ - "type": "message_sent", - "requestId": "req-1", - "data": { - "type": "message", - "date": "2026-03-20 10:00:00.000", - "echo": true, - "id": "7656119xxxxxxxxxx", - "name": "MyName", - "message": "hello", - "imageUrl": null, - "ordinal": 42, - "sentAt": null - } -} -``` - -#### 发送图片 - -```json -{ - "type": "send_image", - "requestId": "req-2", - "id": "7656119xxxxxxxxxx", - "url": "https://example.com/demo.png" -} -``` - -或: - -```json -{ - "type": "send_image", - "requestId": "req-2", - "id": "7656119xxxxxxxxxx", - "img": "iVBORw0KGgoAAAANSUhEUg..." -} -``` - -兼容别名:`type: "img"` - -成功响应: - -```json -{ - "type": "image_sent", - "requestId": "req-2", - "data": { - "type": "image", - "date": "2026-03-20 10:00:00.000", - "echo": true, - "id": "7656119xxxxxxxxxx", - "name": "MyName", - "message": "", - "imageUrl": "https://...", - "ordinal": null, - "sentAt": "2026-03-20T10:00:00.000Z" - } -} -``` - -#### 获取历史记录 - -```json -{ - "type": "get_history", - "requestId": "req-3", - "id": "7656119xxxxxxxxxx", - "limit": 50 -} -``` - -兼容别名:`type: "history"` - -成功响应: - -```json -{ - "type": "history", - "requestId": "req-3", - "data": { - "items": [] - } -} -``` - -#### 获取会话摘要 - -```json -{ - "type": "get_conversations", - "requestId": "req-4", - "limit": 200 -} -``` - -兼容别名:`type: "conversations"` - -成功响应: - -```json -{ - "type": "conversations", - "requestId": "req-4", - "data": { - "items": [] - } -} -``` - -#### 心跳 - -```json -{ - "type": "ping", - "requestId": "ping-1" -} -``` - -成功响应: - -```json -{ - "type": "pong", - "requestId": "ping-1", - "data": { - "now": "2026-03-20T10:00:00.000Z" - } -} -``` - -### 5.4 服务端主动推送事件 - -#### 文本消息广播 - -当服务收到 Steam 好友消息,或通过 HTTP / WebSocket 成功发送文本消息后,会广播: - -```json -{ - "type": "message", - "data": { - "type": "message", - "date": "2026-03-20 10:00:00.000", - "echo": false, - "id": "7656119xxxxxxxxxx", - "name": "Friend", - "message": "hello", - "imageUrl": null, - "ordinal": 1, - "sentAt": null - } -} -``` - -其中 `data.name` 始终表示这条文本消息的发送方昵称。 - -#### 图片发送广播 - -当通过服务成功发送图片后,会广播: - -```json -{ - "type": "image", - "data": { - "type": "image", - "date": "2026-03-20 10:00:00.000", - "echo": true, - "id": "7656119xxxxxxxxxx", - "name": "MyName", - "message": "", - "imageUrl": "https://...", - "ordinal": null, - "sentAt": "2026-03-20T10:00:00.000Z" - } -} -``` - -#### 错误消息 - -请求失败时,服务端会返回: - -```json -{ - "type": "error", - "requestId": "req-1", - "message": "错误信息" -} -``` - -若收到非法 JSON,则返回: - -```json -{ - "type": "error", - "message": "Invalid JSON" -} -``` - -## 6. 行为细节 - -### 6.1 去重策略 - -服务在本地发送文本消息后,会记录一个短期去重键;如果随后从 Steam 收到同一条 `friendMessageEcho`,15 秒内会避免重复广播。 - -### 6.2 图片发送 - -发送图片时依赖 Steam Web Session: - -- 服务会先等待 Steam 登录和 Web Session 就绪 -- 如果首次上传失败,会尝试刷新一次 Web Session 后重试 - -### 6.3 历史记录来源 - -`/history` 和 `/conversations` 都基于本地日志文件,不会主动向 Steam 拉取远端历史消息。 - -## 7. 快速示例 - -### HTTP 发送消息 - -```bash -curl -X POST http://127.0.0.1:3000/message \ - -H 'Content-Type: application/json' \ - -d '{"id":"7656119xxxxxxxxxx","msg":"hello"}' -``` - -### WebSocket 发送消息 - -```js -const ws = new WebSocket('ws://127.0.0.1:3000/ws'); - -ws.onmessage = (event) => { - console.log(JSON.parse(event.data)); -}; - -ws.onopen = () => { - ws.send(JSON.stringify({ - type: 'send_message', - requestId: 'req-1', - id: '7656119xxxxxxxxxx', - msg: 'hello', - })); -}; -``` diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md new file mode 100644 index 0000000..db49087 --- /dev/null +++ b/FEATURES.zh-CN.md @@ -0,0 +1,430 @@ +# Steam Chat 功能说明 + +最后更新:2026-06-23 + +本文档基于当前仓库实现整理,目标是说明 `steam-chat` 已具备的功能边界、主要数据流、前后端能力和运行限制。接口的字段级示例仍以 [API.md](./API.md) 为准;本文侧重完整功能总览。 + +## 1. 项目定位 + +`steam-chat` 是一个基于 Steam 账号的实时聊天服务。它在后端维护 Steam 登录、Web Session、聊天日志和 HTTP/WebSocket 服务,并提供一个内置的中文 Web UI,用于收发 Steam 好友消息、图片、表情和贴纸。 + +核心能力: + +- 通过 Steam Chat 接收好友消息,并实时推送给浏览器客户端。 +- 通过 HTTP API 或 WebSocket API 发送文本消息和图片。 +- 将聊天记录写入本地 JSONL 文件,并基于本地日志查询历史和最近会话。 +- 代理并缓存远程图片、Steam 表情图片和 Steam 贴纸图片。 +- 提供响应式 Web UI,支持桌面端和移动端聊天操作。 + +## 2. 功能总览 + +| 功能域 | 已实现能力 | 主要文件 | +|--------|------------|----------| +| Steam 账号连接 | 凭据登录、refresh token 登录、自动保存 refresh token、断线重试、Web Session 刷新 | `client.js`, `steam-lifecycle.js` | +| 聊天服务 | HTTP Server、WebSocket Server、静态资源服务、可选 Basic Auth | `chat.js` | +| 文本消息 | 发送好友消息、接收好友消息、接收自己消息回显、短期去重 | `chat.js`, `logger.js` | +| 图片消息 | Base64 图片发送、远程 URL 图片发送、发送队列、发送回执、图片回显去重 | `chat.js`, `public/app/composer.js` | +| 富内容 | Steam 表情、Steam 贴纸、BBCode 图片、HTML 图片、OpenGraph 卡片、普通链接 | `chat.js`, `public/app/rich-content.js`, `public/app/message-bubble.js` | +| 历史与会话 | 本地 JSONL 日志、历史查询、最近会话摘要、预览文本生成 | `chat.js`, `logger.js` | +| 好友与群组 | Steam 好友列表、群组列表、在线状态、游戏状态展示 | `chat.js`, `public/app/sidebar.js` | +| Web UI | 会话侧栏、消息列表、发送区、附件菜单、表情/贴纸选择器、图片预览、通知、移动端侧栏 | `public/index.html`, `public/app.js`, `public/app/` | +| 图片代理缓存 | 远程图片代理、贴纸代理、内容类型推断、并发请求合并、磁盘缓存 | `chat.js`, `public/app/managed-images.js` | +| 配置与测试 | `config.chat` 配置、禁用自动启动环境变量、Node 内置测试 | `config.example.js`, `package.json`, `test/` | + +## 3. 后端服务功能 + +### 3.1 服务启动与模块关系 + +- `client.js` 创建 `SteamUser`、`SteamCommunity` 和 Winston logger。 +- `client.js` 调用 `createSteamLifecycle()`,启动 Steam 登录生命周期,并导出 `steamLoginPromise` 与 `steamWebLoginPromise`。 +- `logger.js` 监听 Steam 消息事件,将好友消息和自己消息回显写入 `logs/chat.jsonl`。 +- `logger.js` 在 `config.chat` 启用时加载 `chat.js`,启动聊天 HTTP/WebSocket 服务。 +- `chat.js` 在没有设置 `STEAM_CHAT_DISABLE_AUTOSTART=1` 时会自动创建默认聊天服务并启动。 +- 测试环境通过 `STEAM_CHAT_DISABLE_AUTOSTART=1` 禁用自动启动,改为直接构造可注入依赖的 `createChatService()`。 + +### 3.2 HTTP 服务 + +后端使用 Node.js 原生 `http` 创建服务,默认监听 `0.0.0.0:3000`。主要能力: + +- 服务内置前端静态资源,`GET /` 返回 `public/index.html`。 +- 支持 `/style.css`、`/app.js` 和模块化 CSS/JS 静态资源。 +- 所有 JSON 响应统一设置 `Content-Type: application/json; charset=utf-8`。 +- 请求体最大限制为 10 MB,超过会返回 `Request body too large`。 +- 非 POST 的未知路径返回 `404` JSON 错误。 + +### 3.3 WebSocket 服务 + +后端使用 `ws` 建立 WebSocket Server,默认路径为 `/ws`。主要能力: + +- 连接成功后主动发送 `ready` 事件,携带当前 `wsPath`。 +- 单服务实例最多允许 100 个 WebSocket 连接,超过后关闭连接并返回 `1013 Too many connections`。 +- 每个客户端请求可携带 `requestId`,服务端响应会原样带回。 +- 支持非法 JSON 检测,解析失败时返回 `type: "error"`。 +- 未支持的 WebSocket 类型返回错误消息。 +- 收到 Steam 新消息时向所有已连接 WebSocket 客户端广播。 + +### 3.4 HTTP Basic Auth + +聊天服务支持可选 HTTP Basic Auth: + +- 只有 `chat.auth.username` 和 `chat.auth.password` 同时配置时才启用。 +- 局域网和回环地址请求默认不要求认证。 +- 非局域网请求需要认证,认证失败返回 `401` 和 `WWW-Authenticate`。 +- 设置 `chat.auth.trustProxy: true` 后,会优先解析 `Forwarded`、`X-Forwarded-For`、`X-Real-IP`。 +- WebSocket 握手也走同一套认证判断。 +- 凭据比较使用 SHA-256 后的 timing-safe 比较,避免直接字符串比较。 + +## 4. API 与实时通信功能 + +### 4.1 HTTP API + +| 方法 | 路径 | 功能 | 说明 | +|------|------|------|------| +| `GET` | `/` | 内置 Web UI | 返回 `public/index.html` | +| `GET` | `/api/config` | 前端配置 | 当前返回 `{ "wsPath": "..." }` | +| `GET` | `/api/emoticons` | 表情和贴纸库存 | 需要 Steam 登录和 Web Session | +| `GET` | `/api/friends` | 好友列表 | 返回好友 SteamID、昵称、头像、在线状态、游戏名 | +| `GET` | `/api/groups` | 群组列表 | 返回群组 SteamID/Clan ID 和名称 | +| `GET` | `/history?id=&limit=` | 本地历史记录 | `id` 可选,`limit` 默认 100,最大 500 | +| `GET` | `/conversations?limit=` | 最近会话摘要 | `limit` 是生成摘要时读取的历史条数 | +| `GET` | `/proxy/sticker/:type` | 贴纸图片代理 | 下载 Steam 贴纸并缓存到本地 | +| `GET` | `/proxy/image?url=` | 远程图片代理 | 下载远程图片并缓存到本地 | +| `POST` | `/message` | 发送文本消息 | 请求体 `{ "id": "...", "msg": "..." }` | +| `POST` | `/` | 发送文本消息别名 | 与 `/message` 相同 | +| `POST` | `/image` | 发送图片 | 支持 `img` Base64 或 `url` | +| `POST` | `/img` | 发送图片别名 | 与 `/image` 相同 | + +### 4.2 WebSocket 请求类型 + +| 请求 `type` | 兼容别名 | 功能 | 成功响应 | +|-------------|----------|------|----------| +| `send_message` | `msg` | 发送文本消息 | `message_sent` | +| `send_image` | `img` | 发送图片 | `image_sent` | +| `get_history` | `history` | 获取本地历史记录 | `history` | +| `get_conversations` | `conversations` | 获取最近会话摘要 | `conversations` | +| `get_emoticons` | `emoticons` | 获取 Steam 表情和贴纸库存 | `emoticons` | +| `get_friends` | `friends` | 获取 Steam 好友列表 | `friends` | +| `get_groups` | `groups` | 获取 Steam 群组列表 | `groups` | +| `ping` | 无 | 心跳检测 | `pong` | + +### 4.3 服务端主动推送事件 + +| 推送 `type` | 触发条件 | 数据 | +|-------------|----------|------| +| `ready` | WebSocket 连接建立 | `{ wsPath }` | +| `message` | 收到 Steam 好友消息,或服务端成功发送文本消息后广播 | `HistoryItem` 格式的文本消息 | +| `image` | 服务端成功发送图片后广播给其他客户端 | `HistoryItem` 格式的图片消息 | +| `error` | 请求失败或非法 JSON | 错误消息,可能包含 `requestId` | + +### 4.4 数据结构 + +历史消息项统一规范化为: + +- `type`: `message` 或 `image`。 +- `date`: 本地格式化时间,通常是 `yyyy-mm-dd HH:MM:ss.l`。 +- `echo`: 是否为自己发送。 +- `id`: 会话对象 SteamID64。 +- `name`: 发送方昵称。收到好友消息时为好友昵称,自己发出的消息为当前 Steam 账号昵称。 +- `message`: 文本消息内容,图片记录通常为空字符串。 +- `imageUrl`: 图片 URL,没有则为 `null`。 +- `ordinal`: Steam 消息序号,图片记录通常为 `null`。 +- `sentAt`: 某些图片记录会带 ISO 时间戳。 + +会话摘要项包含: + +- `id`: 会话 SteamID64。 +- `name`: 会话名称,优先使用非自己消息的发送方昵称,缺失时尝试查询 Steam 用户信息。 +- `updatedAt`: 最近消息时间。 +- `preview`: 最近消息摘要,可为普通文本、`[图片]`、`[贴纸] xxx`、`[表情] xxx` 或 OpenGraph 标题。 +- `lastType`: 最近消息类型。 +- `lastEcho`: 最近消息是否为自己发送。 +- `messageCount`: 当前读取日志窗口内该会话消息数。 + +## 5. Steam 生命周期与账号能力 + +### 5.1 登录模式 + +Steam 生命周期优先读取 `refresh.token`: + +- 如果 `refresh.token` 存在且非空,使用 refresh token 登录。 +- 如果 refresh token 不存在或不可读,使用 `config.accountName` 和 `config.password` 登录。 +- 登录参数同时带上 `config.logonID` 和 `config.steamID`。 +- SteamUser 启用 `renewRefreshTokens`,收到新 refresh token 时写回 `refresh.token`。 + +### 5.2 自动重连 + +`steam-lifecycle.js` 区分可恢复错误和不可恢复错误: + +- 可恢复错误包括网络中断、Steam 服务不可用、超时、部分 socket/TLS 错误等。 +- 不可恢复错误包括密码错误、Steam Guard/两步验证问题、账号禁用、登录节流、需要验证码等。 +- 可恢复错误会安排重试,初始延迟 5 秒,指数退避,最大 5 分钟。 +- 如果已经安排重试,不会重复安排多个重试定时器。 +- `loggedOn` 会清理待重试定时器并重置退避。 + +### 5.3 Web Session + +- 登录成功后调用 `steamUser.webLogOn()` 获取 Web Session。 +- 收到 `webSession` 后将 cookies 注入 `SteamCommunity`。 +- 如果配置了 `identitySecret`,会启动 confirmation checker,间隔 10 秒。 +- 图片发送和表情/贴纸库存读取依赖 Web Session 就绪。 +- 文本或图片发送遇到疑似 Session 过期时,会触发一次 `webLogOn()` 并等待新的 `webSession` 后重试。 + +### 5.4 用户信息缓存 + +`client.js` 提供 `getUserInfo()`: + +- 支持传入字符串 SteamID64 或 SteamID 对象。 +- 首次查询通过 `steamUser.getPersonas()` 拉取资料。 +- 查询结果缓存在进程内 `users` 对象中。 +- 查询失败时返回 `{ player_name: "Unknown" }`。 + +## 6. 消息、媒体与富内容能力 + +### 6.1 文本消息 + +- 后端通过 `steamUser.chat.sendFriendMessage()` 发送好友文本消息。 +- 发送成功后写入本地日志,并广播给 WebSocket 客户端。 +- 收到 Steam `friendMessage` 时广播为 `type: "message"`。 +- 收到 Steam `friendMessageEcho` 时按短期 key 去重,避免同一条自己发送的消息重复出现。 +- 文本发送遇到临时网络错误时会先重试一次。 +- 文本发送遇到疑似 Web Session 过期时会刷新 Session 后重试。 + +### 6.2 图片发送 + +后端支持两种图片输入: + +- `img`: Base64 字符串,允许纯 Base64 或 Data URL。 +- `url`: 远程图片 URL,服务端先下载为 Buffer 再上传给 Steam。 + +发送流程: + +- 发送前等待 Steam 登录和 Web Session。 +- 使用 `steamCommunity.sendImageToUser()` 上传图片。 +- 发送成功后记录 `type: "image"` 日志。 +- WebSocket 发送图片时,发送者收到 `image_sent` 回执,其他客户端收到 `image` 广播。 +- 图片发送遇到临时网络错误会先重试一次。 +- 图片发送遇到疑似 Session 过期会刷新 Web Session 后重试。 +- 服务会记住最近发送的图片 URL,抑制 Steam 随后以文本 URL 或 BBCode 形式回显出的重复图片消息。 + +### 6.3 表情与贴纸 + +- 后端通过 Steam `ClientGetEmoticonList` 获取当前账号的表情和贴纸库存。 +- 表情项包含 `name`、`count`、`use_count`、`time_last_used`、`appid`。 +- 贴纸项包含 `name`、`count`、`use_count`、`time_last_used`、`appid`。 +- 前端将表情渲染为 `:name:`,发送时仍作为普通文本消息发送。 +- 前端发送贴纸时实际发送 Steam 贴纸 BBCode:`[sticker type="..." limit="0"][/sticker]`。 +- 后端和前端都能识别贴纸 BBCode,并在消息气泡和会话预览中渲染为贴纸。 + +### 6.4 富内容解析 + +文本消息渲染支持: + +- Steam 表情:`:name:`、`[emoticon name="name"][/emoticon]`、`[emoticon]name[/emoticon]`。 +- 图片:`[img]url[/img]`、`[img src=url]...[/img]`、HTML ``、直接图片 URL。 +- OpenGraph:`[og url="..." img="..." title="..."]fallback[/og]`。 +- 链接:`[url=href]label[/url]`、`[url]href[/url]`、普通 `http(s)` URL。 +- 纯单图消息会渲染为图片气泡;混合文本和图片会渲染为富文本内容。 + +## 7. 历史记录、日志与缓存 + +### 7.1 聊天日志 + +- 日志文件为 `logs/chat.jsonl`。 +- 每行是一条 JSON 消息记录。 +- `logger.js` 监听 `friendMessage` 和 `friendMessageEcho` 并写入日志。 +- `chat.js` 发送文本和图片时也会写入同一日志。 +- `logger.js` 在首次获取某个用户资料时,会尝试通过 Steam 拉取该好友历史消息并导入本地日志。 +- `/history` 和 `/conversations` 只读取本地日志,不会主动向 Steam 远端查询历史。 + +### 7.2 历史查询 + +- `/history` 可按 `id` 过滤会话。 +- `limit` 默认 100,最大 500。 +- 返回前会补齐旧日志缺失字段,例如旧文本日志没有 `type` 时会补为 `message`。 +- 结果按 `date` 或 `sentAt` 升序排序,同一时间下按 `ordinal` 升序。 +- 日志文件不存在时返回空数组。 +- 无法解析的 JSONL 行会跳过并记录 warning。 + +### 7.3 会话摘要 + +- `/conversations` 基于最近日志生成会话列表。 +- 会话列表按最近更新时间倒序排列。 +- 摘要预览会识别图片、贴纸、表情-only 消息和 OpenGraph 标题。 +- 如果会话名称缺失,会尝试通过 Steam 用户信息补齐。 + +### 7.4 图片和贴纸缓存 + +- 贴纸缓存目录:`logs/stickers`。 +- 图片缓存目录:`logs/images`。 +- 贴纸缓存路径基于贴纸类型生成。 +- 远程图片缓存路径基于 URL 的 SHA-1 生成,同时保存 `.bin` 数据和 `.json` 元信息。 +- 远程图片 Content-Type 优先根据 URL 后缀推断,fallback 到源响应头或 `image/png`。 +- 对同一贴纸或同一图片 URL 的并发请求会合并为一个下载 Promise。 +- 图片代理只接受 `http://` 或 `https://`,并拒绝 `localhost`、回环地址、`0.0.0.0` 和局域网 IPv4,降低 SSRF 风险。 + +## 8. 前端 Web UI 功能 + +### 8.1 页面布局 + +Web UI 是纯浏览器 ES Module 应用,无前端框架。页面分为: + +- 左侧侧栏:打开会话、历史条数、反馈状态、最近会话、好友、群组。 +- 主聊天区:当前会话标题、连接状态、消息列表、消息发送区。 +- 全局浮层:拖拽图片提示、图片 lightbox。 + +样式入口为 `public/style.css`,按模块引入 `base.css`、`sidebar.css`、`messages.css`、`composer.css`、`overlays.css`、`responsive.css`。 + +### 8.2 初始化流程 + +前端启动后: + +1. 从 `localStorage` 恢复最近使用的目标 SteamID 和历史条数。 +2. 请求 `/api/config` 获取 WebSocket 路径,失败时回退到 `/ws`。 +3. 建立 WebSocket 连接。 +4. 收到 `ready` 后请求最近会话、好友、群组和表情/贴纸库存。 +5. 如果已有活跃会话,加载该会话历史;否则优先恢复本地目标 SteamID,再退到最近会话列表第一项。 + +### 8.3 会话、好友与群组侧栏 + +- 最近会话展示名称、更新时间、消息预览和 SteamID。 +- 好友列表展示头像、昵称、SteamID、在线状态和正在游戏信息。 +- 群组列表展示群组名称和 ID。 +- 点击最近会话、好友或群组会切换当前会话并加载历史。 +- 侧栏 tab 支持鼠标点击和键盘方向键、Home、End 导航。 +- 支持手动输入 SteamID64 打开会话。 +- 支持刷新历史、刷新最近会话、刷新好友列表、刷新群组列表。 + +### 8.4 消息列表 + +- 历史消息批量渲染,实时消息增量追加。 +- 自己和对方消息使用不同方向的消息行。 +- 每条消息显示发送方昵称和时间。 +- 跨天插入日期分隔线。 +- 同一天内两条消息间隔超过 10 分钟时插入时间分隔线。 +- 渲染历史或追加新消息后自动滚动到底部。 +- 切换会话或清空消息时会清理已创建的图片对象 URL 和未完成图片请求。 + +### 8.5 消息发送区 + +文本发送: + +- 点击发送按钮或按 Enter 发送。 +- Shift+Enter 换行。 +- 输入框会按内容自动调整高度,移动端和桌面端有不同高度上限。 +- 未选择会话且没有有效内容时会显示状态提示。 + +图片发送: + +- 支持选择本地图片文件。 +- 支持输入远程图片 URL。 +- 支持在输入框或页面中直接粘贴剪切板图片。 +- 支持拖拽一个或多个图片文件到页面,多个文件会依次发送。 +- 未选择会话时,图片会先进入待发送附件预览。 +- 发送区有上传队列,展示读取进度、等待确认、成功或失败状态。 +- WebSocket 断开时会将挂起上传请求标记为失败。 + +表情和贴纸: + +- 表情/贴纸选择器有独立 tab。 +- 支持搜索库存中的表情或贴纸。 +- 表情按 `use_count` 和名称排序,点击后插入输入框光标位置。 +- 贴纸按 `use_count` 和名称排序,点击后立即发送贴纸 BBCode。 +- 输入 `:xxx` 时会出现表情自动补全建议。 +- 自动补全支持上下键选择、Tab 或 Enter 应用、Escape 关闭。 +- 发送或加载历史时会记住出现过的表情,提高后续补全命中率。 + +### 8.6 图片展示与预览 + +- 消息中的图片通过 `/proxy/image` 加载,避免浏览器直接访问远程图片。 +- 图片加载使用 XHR,展示加载中、百分比、失败状态。 +- 加载成功后使用 Blob object URL 显示,并在清理时释放。 +- 图片、OpenGraph 缩略图和单图气泡都可点击打开 lightbox。 +- lightbox 支持滚轮缩放、按钮缩放、双击放大/还原、拖拽平移、触摸双指缩放。 +- lightbox 支持 Escape 关闭、点击背景关闭,并在关闭后恢复焦点。 + +### 8.7 通知与未读 + +- 浏览器支持 Notification API 时,首次 pointerdown 或 keydown 会尝试预热通知权限。 +- 当页面隐藏、窗口失焦、没有活跃会话或新消息来自非活跃会话时,会增加未读数。 +- 未读数会显示在 document title 中。 +- 非自己发送的新消息在通知权限为 granted 时会弹出系统通知。 +- 点击通知会聚焦窗口、切换到对应会话并重新加载历史。 +- 页面重新可见或窗口聚焦时清空未读数。 + +### 8.8 移动端适配 + +- 900px 及以下进入移动布局。 +- 移动端侧栏改为抽屉,支持遮罩关闭和按钮开关。 +- 使用 `visualViewport` 和 CSS 变量处理移动端键盘、视口高度和安全区域。 +- 移动端输入框 placeholder 和高度范围与桌面端不同。 +- 退出移动布局时会自动关闭侧栏。 + +## 9. 配置、安全与运维能力 + +### 9.1 配置项 + +`config.example.js` 包含: + +- `accountName`: Steam 登录名。 +- `password`: Steam 密码。 +- `logonID`: 随机登录 ID。 +- `steamID`: 当前账号 SteamID。 +- `chat.enabled`: 是否启用聊天服务。 +- `chat.host`: HTTP 服务监听地址,默认 `0.0.0.0`。 +- `chat.port`: HTTP 服务端口,默认 `3000`。 +- `chat.wsPath`: WebSocket 路径,默认 `/ws`。 +- `chat.auth.username`: Basic Auth 用户名。 +- `chat.auth.password`: Basic Auth 密码。 +- `chat.auth.realm`: Basic Auth realm。 +- `chat.auth.trustProxy`: 是否信任反向代理 IP 头。 +- `identitySecret`: 可选,用于 Steam confirmation checker。 + +`config.chat` 也可以直接配置为 `true`,表示启用聊天服务并使用默认参数。 + +### 9.2 环境变量 + +| 变量 | 功能 | +|------|------| +| `STEAM_CHAT_DISABLE_AUTOSTART=1` | 禁用 `chat.js` 自动启动,主要用于测试或手动构造服务 | + +### 9.3 安全边界 + +- 生产环境应修改默认 Basic Auth 密码。 +- 建议在反向代理后启用 HTTPS。 +- Basic Auth 默认放行局域网和回环地址,公网暴露时应结合网络边界检查。 +- 开启 `trustProxy` 前应确认反向代理会覆盖客户端传入的转发头。 +- `/proxy/image` 对明显本地和局域网 IPv4 做拒绝,但它不是完整的网络沙箱。 +- `config.js`、`refresh.token`、`logs/` 都属于运行态或敏感文件,不应提交真实内容。 + +### 9.4 运行与测试 + +安装依赖: + +```bash +npm install +``` + +运行服务: + +```bash +node client.js +``` + +测试: + +```bash +npm test +``` + +当前 `package.json` 只有 `test` 脚本,没有独立 `build`、`lint` 或类型检查脚本。 + +## 10. 当前限制与注意事项 + +- `/history` 和 `/conversations` 只基于本地 `logs/chat.jsonl`,不会实时拉取 Steam 远端历史。 +- `logger.js` 会在首次获取某好友资料时尝试导入 Steam 历史,但这不是每次查询历史都执行的同步远端拉取。 +- 好友和群组列表依赖 `steamUser` 当前进程内状态,服务刚登录或 Steam 状态未同步完成时可能为空或信息不完整。 +- 表情和贴纸库存依赖 Steam Web Session 和内部 Steam 消息接口,网络或 Session 异常时会失败。 +- 图片上传依赖 `SteamCommunity` Web Session 和 Steam 图片上传能力。 +- 图片 URL 发送会由服务端下载远程资源,因此受远程站点可用性、响应速度和图片大小影响。 +- Web UI 是纯前端页面,没有用户管理、多账号管理或服务端会话隔离。 +- 当前项目使用 CommonJS 后端和浏览器 ES Module 前端,不能在同一文件内混用模块系统。 diff --git a/README.md b/README.md deleted file mode 100644 index d8048a0..0000000 --- a/README.md +++ /dev/null @@ -1,289 +0,0 @@ -# Steam Chat - -[中文](./README.zh-CN.md) - -A real-time chat service based on Steam API, supporting sending and receiving messages via HTTP/WebSocket interfaces, with a built-in web chat UI. - -## Features - -- **Real-time Messaging**: Receive Steam friend messages in real-time via WebSocket -- **Multimedia Support**: Send/receive images, emoticons, and stickers -- **Message History**: Local JSONL file storage for chat logs -- **HTTP API**: Complete RESTful API interface -- **Built-in Web UI**: Responsive design for desktop and mobile -- **Image Proxy**: Automatic caching of remote images, sticker and image proxy support -- **Authentication**: Optional HTTP Basic Auth protection - -## Project Structure - -``` -steam-chat/ -├── public/ # Frontend resources -│ ├── index.html # Main page -│ ├── style.css # Style entry point -│ ├── app.js # Frontend main script -│ ├── app/ # Modular frontend code -│ │ ├── bootstrap.js # Page initialization & event binding -│ │ ├── composer.js # Message composer component -│ │ ├── dom.js # DOM reference collection -│ │ ├── layout.js # Responsive layout control -│ │ ├── lightbox.js # Image preview modal -│ │ ├── managed-images.js # Image management -│ │ ├── messages.js # Message list rendering -│ │ ├── message-bubble.js # Message bubble rendering -│ │ ├── notifications.js # Desktop notifications -│ │ ├── preferences.js # Local preferences -│ │ ├── rich-content.js # Rich content rendering -│ │ ├── session.js # Session state management -│ │ ├── sidebar.js # Sidebar rendering -│ │ ├── status.js # Connection status display -│ │ ├── utils.js # Utility functions -│ │ └── websocket.js # WebSocket communication -│ └── styles/ # Modular CSS files -│ ├── base.css # Base styles -│ ├── composer.css # Composer styles -│ ├── messages.css # Message area styles -│ ├── overlays.css # Overlay styles -│ ├── responsive.css # Responsive styles -│ └── sidebar.css # Sidebar styles -├── logs/ # Log directory -│ ├── chat.jsonl # Chat history -│ ├── images/ # Image cache -│ └── stickers/ # Sticker cache -├── test/ # Test files -├── client.js # Steam client wrapper -├── chat.js # Chat service core -├── config.js # Configuration file -├── config.example.js # Configuration example -├── logger.js # Logger -└── package.json # Dependencies -``` - -## Quick Start - -### Requirements - -- Node.js 18+ -- Steam account - -### Installation - -```bash -npm install -``` - -### Configuration - -Copy and edit the configuration file: - -```bash -cp config.example.js config.js -``` - -Edit `config.js`: - -```javascript -module.exports = { - accountName: 'your_steam_username', - password: 'your_steam_password', - steamID: "your_steam_id64", - // Optional: Two-factor authentication - // identitySecret: 'your_identity_secret', - - chat: { - enabled: true, - host: '0.0.0.0', - port: 3000, - wsPath: '/ws', - auth: { - username: 'admin', - password: 'change-me', - realm: 'Steam Chat', - trustProxy: false, - }, - }, -}; -``` - -### Running - -```bash -node client.js -``` - -After starting, access `http://localhost:3000` to open the chat interface. - -### Testing - -```bash -npm test -``` - -## API Documentation - -For detailed API documentation, see [API.md](./API.md). - -### Basic Info - -- Default listen address: `0.0.0.0:3000` -- WebSocket path: `/ws` -- Root page: `GET /` returns the built-in chat page - -### HTTP API - -#### Send Text Message - -```bash -POST /message -# or -POST / - -{ - "id": "7656119xxxxxxxxxx", # Recipient's SteamID - "msg": "Hello" -} -``` - -#### Send Image - -```bash -POST /image -# or -POST /img - -# Method A: Base64 encoded -{ - "id": "7656119xxxxxxxxxx", - "img": "iVBORw0KGgoAAAANSUhEUg..." -} - -# Method B: Remote URL -{ - "id": "7656119xxxxxxxxxx", - "url": "https://example.com/image.png" -} -``` - -#### Get Message History - -```bash -GET /history?id=7656119xxxxxxxxxx&limit=100 -``` - -#### Get Conversation List - -```bash -GET /conversations?limit=200 -``` - -### WebSocket API - -Connection address: `ws://localhost:3000/ws` - -#### Send Message - -```javascript -ws.send(JSON.stringify({ - type: 'send_message', - requestId: 'req-1', - id: '7656119xxxxxxxxxx', - msg: 'hello' -})); -``` - -#### Get History - -```javascript -ws.send(JSON.stringify({ - type: 'get_history', - requestId: 'req-2', - id: '7656119xxxxxxxxxx', - limit: 50 -})); -``` - -## Configuration Options - -### Chat Service Configuration (`config.chat`) - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | boolean | `false` | Enable chat service | -| `host` | string | `'0.0.0.0'` | Listen address | -| `port` | number | `3000` | Listen port | -| `wsPath` | string | `'/ws'` | WebSocket path | -| `auth.username` | string | `''` | HTTP Basic Auth username | -| `auth.password` | string | `''` | HTTP Basic Auth password | -| `auth.realm` | string | `'Steam Chat'` | Authentication realm | -| `auth.trustProxy` | boolean | `false` | Trust reverse proxy headers | - -## Frontend Modules - -### Core Modules - -| Module | Responsibility | -|--------|----------------| -| `bootstrap.js` | Page initialization, event binding, config fetching | -| `session.js` | Session state management, current conversation info | -| `websocket.js` | WebSocket connection, message sending, request ID management | -| `dom.js` | DOM element reference collection | - -### Feature Modules - -| Module | Responsibility | -|--------|----------------| -| `composer.js` | Message input, attachment upload, emoticon/sticker picker | -| `messages.js` | Message list rendering, separators, batch rendering | -| `message-bubble.js` | Message bubble rendering, text/image/sticker styles | -| `sidebar.js` | Sidebar list rendering (conversations/friends/groups) | -| `lightbox.js` | Image preview modal, zoom controls | -| `managed-images.js` | Lazy image loading management | -| `rich-content.js` | Rich text rendering (emoticons, link cards) | -| `notifications.js` | Desktop notifications, new message alerts | -| `preferences.js` | Local preference storage (target ID, history limit) | -| `layout.js` | Responsive layout, sidebar toggle | -| `status.js` | Connection status display | - -### Style Modules - -| File | Coverage | -|------|----------| -| `base.css` | Base layout, buttons, forms, empty states | -| `sidebar.css` | Sidebar, tabs, list items | -| `messages.css` | Message area, bubbles, separators | -| `composer.css` | Composer area, input, picker | -| `overlays.css` | Modals, image preview, drag hints | -| `responsive.css` | Mobile adaptation, breakpoint styles | - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| `STEAM_CHAT_DISABLE_AUTOSTART` | Set to `1` to disable auto-start of chat service | - -## Security Notes - -- Always change the default HTTP Basic Auth password in production -- Recommended to enable HTTPS via reverse proxy (e.g., Nginx) -- Image proxy feature automatically caches remote resources - -## Dependencies - -### Main Dependencies - -- `steam-user` - Steam login and API -- `steamcommunity` - Steam community features -- `steam-totp` - Steam two-factor authentication -- `ws` - WebSocket server -- `winston` - Logging -- `axios` - HTTP requests - -### Dev Dependencies - -- `@types/steam-user` -- `@types/steamcommunity` -- `@types/steam-totp` - -## License - -[GPL-3.0](./LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md deleted file mode 100644 index 92be4be..0000000 --- a/README.zh-CN.md +++ /dev/null @@ -1,289 +0,0 @@ -# Steam Chat - -[English](./README.md) - -一个基于 Steam API 的实时聊天服务,支持通过 HTTP/WebSocket 接口发送和接收消息,并提供内置的 Web 聊天界面。 - -## 功能特性 - -- **实时消息收发**:支持通过 WebSocket 实时接收 Steam 好友消息 -- **多媒体支持**:发送/接收图片、表情、贴纸 -- **历史记录**:本地 JSONL 文件存储聊天记录 -- **HTTP API**:提供完整的 RESTful API 接口 -- **内置 Web UI**:响应式设计,支持桌面端和移动端 -- **图片代理**:自动缓存远程图片,支持贴纸和图片代理 -- **身份验证**:可选的 HTTP Basic Auth 保护 - -## 项目结构 - -``` -steam-chat/ -├── public/ # 前端资源 -│ ├── index.html # 主页面 -│ ├── style.css # 样式入口 -│ ├── app.js # 前端主脚本 -│ ├── app/ # 前端模块化代码 -│ │ ├── bootstrap.js # 页面初始化与事件绑定 -│ │ ├── composer.js # 消息发送组件 -│ │ ├── dom.js # DOM 引用收集 -│ │ ├── layout.js # 响应式布局控制 -│ │ ├── lightbox.js # 图片预览弹层 -│ │ ├── managed-images.js # 图片管理 -│ │ ├── messages.js # 消息列表渲染 -│ │ ├── message-bubble.js # 消息气泡渲染 -│ │ ├── notifications.js # 桌面通知 -│ │ ├── preferences.js # 本地偏好设置 -│ │ ├── rich-content.js # 富内容渲染 -│ │ ├── session.js # 会话状态管理 -│ │ ├── sidebar.js # 侧栏渲染 -│ │ ├── status.js # 连接状态显示 -│ │ ├── utils.js # 工具函数 -│ │ └── websocket.js # WebSocket 通信 -│ └── styles/ # CSS 模块化文件 -│ ├── base.css # 基础样式 -│ ├── composer.css # 发送区样式 -│ ├── messages.css # 消息区样式 -│ ├── overlays.css # 弹层样式 -│ ├── responsive.css # 响应式样式 -│ └── sidebar.css # 侧栏样式 -├── logs/ # 日志目录 -│ ├── chat.jsonl # 聊天记录 -│ ├── images/ # 图片缓存 -│ └── stickers/ # 贴纸缓存 -├── test/ # 测试文件 -├── client.js # Steam 客户端封装 -├── chat.js # 聊天服务核心 -├── config.js # 配置文件 -├── config.example.js # 配置示例 -├── logger.js # 日志记录器 -└── package.json # 项目依赖 -``` - -## 快速开始 - -### 环境要求 - -- Node.js 18+ -- Steam 账号 - -### 安装 - -```bash -npm install -``` - -### 配置 - -复制配置文件并编辑: - -```bash -cp config.example.js config.js -``` - -编辑 `config.js`: - -```javascript -module.exports = { - accountName: 'your_steam_username', - password: 'your_steam_password', - steamID: "your_steam_id64", - // 可选:两步验证 - // identitySecret: 'your_identity_secret', - - chat: { - enabled: true, - host: '0.0.0.0', - port: 3000, - wsPath: '/ws', - auth: { - username: 'admin', - password: 'change-me', - realm: 'Steam Chat', - trustProxy: false, - }, - }, -}; -``` - -### 运行 - -```bash -node client.js -``` - -服务启动后,访问 `http://localhost:3000` 打开聊天界面。 - -### 测试 - -```bash -npm test -``` - -## API 文档 - -详细 API 文档请参阅 [API.md](./API.md)。 - -### 基础信息 - -- 默认监听地址:`0.0.0.0:3000` -- WebSocket 路径:`/ws` -- 根页面:`GET /` 返回内置聊天页面 - -### HTTP API - -#### 发送文本消息 - -```bash -POST /message -# 或 -POST / - -{ - "id": "7656119xxxxxxxxxx", # 对方 SteamID - "msg": "你好" -} -``` - -#### 发送图片 - -```bash -POST /image -# 或 -POST /img - -# 方式 A:Base64 编码 -{ - "id": "7656119xxxxxxxxxx", - "img": "iVBORw0KGgoAAAANSUhEUg..." -} - -# 方式 B:远程 URL -{ - "id": "7656119xxxxxxxxxx", - "url": "https://example.com/image.png" -} -``` - -#### 获取历史记录 - -```bash -GET /history?id=7656119xxxxxxxxxx&limit=100 -``` - -#### 获取会话列表 - -```bash -GET /conversations?limit=200 -``` - -### WebSocket API - -连接地址:`ws://localhost:3000/ws` - -#### 发送消息 - -```javascript -ws.send(JSON.stringify({ - type: 'send_message', - requestId: 'req-1', - id: '7656119xxxxxxxxxx', - msg: 'hello' -})); -``` - -#### 获取历史 - -```javascript -ws.send(JSON.stringify({ - type: 'get_history', - requestId: 'req-2', - id: '7656119xxxxxxxxxx', - limit: 50 -})); -``` - -## 配置选项 - -### 聊天服务配置 (`config.chat`) - -| 选项 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `enabled` | boolean | `false` | 是否启用聊天服务 | -| `host` | string | `'0.0.0.0'` | 监听地址 | -| `port` | number | `3000` | 监听端口 | -| `wsPath` | string | `'/ws'` | WebSocket 路径 | -| `auth.username` | string | `''` | HTTP Basic Auth 用户名 | -| `auth.password` | string | `''` | HTTP Basic Auth 密码 | -| `auth.realm` | string | `'Steam Chat'` | 认证领域名称 | -| `auth.trustProxy` | boolean | `false` | 是否信任反向代理 | - -## 前端模块说明 - -### 核心模块 - -| 模块 | 职责 | -|------|------| -| `bootstrap.js` | 页面初始化、事件绑定、配置拉取 | -| `session.js` | 会话状态管理、当前会话信息 | -| `websocket.js` | WebSocket 连接、消息发送、请求 ID 管理 | -| `dom.js` | DOM 元素引用收集 | - -### 功能模块 - -| 模块 | 职责 | -|------|------| -| `composer.js` | 消息输入、附件上传、表情/贴纸选择器 | -| `messages.js` | 消息列表渲染、分隔线、批量渲染 | -| `message-bubble.js` | 消息气泡渲染、文本/图片/贴纸样式 | -| `sidebar.js` | 侧栏列表渲染(会话/好友/群组) | -| `lightbox.js` | 图片预览弹层、缩放控制 | -| `managed-images.js` | 图片懒加载管理 | -| `rich-content.js` | 富文本内容渲染(表情、链接卡片) | -| `notifications.js` | 桌面通知、新消息提醒 | -| `preferences.js` | 本地偏好存储(目标 ID、历史条数) | -| `layout.js` | 响应式布局、侧栏切换 | -| `status.js` | 连接状态显示 | - -### 样式模块 - -| 文件 | 覆盖范围 | -|------|----------| -| `base.css` | 基础布局、按钮、表单、空状态 | -| `sidebar.css` | 侧栏、标签页、列表项 | -| `messages.css` | 消息区、消息气泡、分隔线 | -| `composer.css` | 发送区、输入框、选择器 | -| `overlays.css` | 弹层、图片预览、拖拽提示 | -| `responsive.css` | 移动端适配、断点样式 | - -## 环境变量 - -| 变量 | 说明 | -|------|------| -| `STEAM_CHAT_DISABLE_AUTOSTART` | 设置为 `1` 可禁用自动启动聊天服务 | - -## 安全说明 - -- 生产环境请务必修改默认的 HTTP Basic Auth 密码 -- 建议通过反向代理(如 Nginx)启用 HTTPS -- 图片代理功能会自动缓存远程资源 - -## 依赖 - -### 主要依赖 - -- `steam-user` - Steam 登录和 API -- `steamcommunity` - Steam 社区功能 -- `steam-totp` - Steam 两步验证 -- `ws` - WebSocket 服务器 -- `winston` - 日志记录 -- `axios` - HTTP 请求 - -### 开发依赖 - -- `@types/steam-user` -- `@types/steamcommunity` -- `@types/steam-totp` - -## 许可证 - -[GPL-3.0](./LICENSE) diff --git a/UI_REFACTOR_TODO.md b/UI_REFACTOR_TODO.md deleted file mode 100644 index 142b797..0000000 --- a/UI_REFACTOR_TODO.md +++ /dev/null @@ -1,319 +0,0 @@ -# Web UI Refactor Todo List - -> 目标:整理当前网页显示界面的重构项,先建立一份可执行的前端重构清单,后续按优先级逐步落地。 - -## 当前进度 - -### 已完成(第一轮) -- [x] 将 `public/style.css` 拆分为多份区域样式文件,并由入口样式统一导入 -- [x] 移除 `public/index.html` 中的内联 `style` -- [x] 将富内容消息的主要静态样式从 JS 迁移到 CSS class -- [x] 新增统一连接状态组件,分离“连接状态”和“操作反馈” -- [x] 统一最近会话 / 好友 / 群组列表容器样式 -- [x] 群组点击行为改为直接进入会话并拉取历史 -- [x] 移动端恢复显式发送按钮 -- [x] 历史消息改为批量渲染,减少重复滚动 -- [x] 为 tab / lightbox / focus-visible 补充一轮基础可访问性支持 - -### 已完成(第二轮) -- [x] 从 `public/app.js` 中继续拆出图片管理模块 -- [x] 从 `public/app.js` 中拆出 lightbox 模块 -- [x] 从 `public/app.js` 中拆出消息渲染模块 -- [x] 从 `public/app.js` 中拆出侧栏渲染模块 -- [x] 进一步压缩入口文件体积,降低主文件耦合度 - -### 已完成(第三轮) -- [x] 从 `public/app.js` 中拆出 composer 模块 -- [x] 将附件上传、上传队列、URL 图片发送逻辑并入 composer 模块 -- [x] 将表情建议、贴纸/表情选择器逻辑并入 composer 模块 -- [x] 继续压缩入口文件体积,主入口聚焦在页面装配与 WebSocket 流程 - -### 已完成(第四轮) -- [x] 从 `public/app.js` 中拆出会话状态模块 -- [x] 从 `public/app.js` 中拆出 WebSocket 协调模块 -- [x] 进一步压缩主入口,仅保留页面装配与少量全局行为 - -### 已完成(第五轮) -- [x] 从 `public/app.js` 中拆出通知模块 -- [x] 从 `public/app.js` 中拆出响应式布局/侧栏控制模块 -- [x] 从 `public/app.js` 中拆出本地偏好读取与保存模块 -- [x] 补齐贴纸 URL 辅助方法到前端工具模块,统一消息渲染依赖来源 -- [x] 修复拆分过程中主入口残留引用,恢复语法与测试通过状态 - -### 已完成(第六轮) -- [x] 从 `public/app.js` 中拆出 DOM 引用收集模块 -- [x] 从 `public/app.js` 中拆出页面装配 / 事件绑定模块 -- [x] 从主入口中抽出 ws 配置拉取与连接启动逻辑 -- [x] 继续压缩主入口,进一步聚焦控制器组装 - -### 已完成(第七轮) -- [x] 从 `messages.js` 中拆出消息气泡渲染模块 -- [x] 将文本消息 / 图片消息 / 贴纸消息的气泡渲染统一收口 -- [x] 让 `messages.js` 聚焦消息列表编排、分隔线与滚动策略 - -### 本轮后新的文件结构 -- `public/styles/base.css` -- `public/styles/sidebar.css` -- `public/styles/messages.css` -- `public/styles/composer.css` -- `public/styles/overlays.css` -- `public/styles/responsive.css` -- `public/app/utils.js` -- `public/app/status.js` -- `public/app/rich-content.js` -- `public/app/managed-images.js` -- `public/app/lightbox.js` -- `public/app/messages.js` -- `public/app/sidebar.js` -- `public/app/composer.js` -- `public/app/session.js` -- `public/app/websocket.js` -- `public/app/notifications.js` -- `public/app/layout.js` -- `public/app/preferences.js` -- `public/app/dom.js` -- `public/app/bootstrap.js` -- `public/app/message-bubble.js` - -## 一、重构目标 - -- 提升页面结构清晰度,降低 `public/app.js` 与 `public/style.css` 的维护成本 -- 统一桌面端/移动端交互表现 -- 提升消息区、侧栏、发送区的一致性 -- 改善可访问性、状态展示和渲染性能 - ---- - -## 二、P0:优先处理 - -### 1. 拆分前端代码职责 -- [ ] 将 `public/app.js` 继续按功能拆分为独立模块 - - [x] `sidebar` - - [x] `messages` - - [x] `message-bubble` - - [x] `composer` - - [~] `picker` - - [x] `lightbox` - - [x] `connection-status` - - [x] `notifications` - - [x] `layout` - - [x] `preferences` - - [x] `dom` - - [x] `bootstrap` - - [x] `utils` - - [x] `rich-content` -- [x] 将 `public/style.css` 按页面区域拆分 - - [x] layout - - [x] sidebar - - [x] chat - - [x] composer - - [x] overlay / modal -- [~] 建立统一命名规范,避免样式和脚本继续堆在单文件里 - - [x] 已建立一批统一类名 - - [ ] 仍需继续收敛 `app.js` 中剩余页面装配逻辑 - -**现状问题** -- `public/app.js` 已降至约 395 行,主入口已明显收缩 -- `public/style.css` 已降为入口文件 -- 目前主要剩余耦合点集中在跨模块回调编排、消息气泡细分与部分交互细节 - ---- - -### 2. 清理内联样式,改为 class 驱动 -- [x] 移除 `public/index.html` 中的内联 `style` -- [~] 移除 `public/app.js` 中直接写入视觉样式的逻辑 - - [x] 已清理消息卡片、建议项、OG 卡片等静态视觉样式 - - [ ] 保留少量运行时样式控制:高度、自适应、transform、进度条宽度 -- [x] 为常见 UI 块补充语义化 class -- [~] 保证 JS 只控制状态,不直接控制具体视觉细节 - -**重点位置** -- [x] `public/index.html:42-72` -- [~] `public/app.js` 中图片、卡片、建议项、消息项里的 `.style.*` - ---- - -### 3. 重构移动端发送区 -- [x] 保留移动端显式发送按钮,不再完全依赖 Enter 发送 -- [x] 统一附件按钮 / 表情按钮 / 发送按钮布局 -- [x] 优化输入框高度、换行、滚动行为 -- [~] 重新梳理 URL 面板、附件预览、上传队列在移动端的展示顺序 - - [x] 已完成基础顺序整理 - - [ ] 仍可继续压缩移动端纵向占用 - -**现状问题** -- [x] 移动端 `#sendMessage` 被隐藏 -- [x] 发送行为与桌面端不完全一致 -- [ ] 发送区承载内容仍偏多,仍有进一步整理空间 - ---- - -### 4. 统一连接状态展示 -- [x] 合并顶部 `WebSocket` chip 与侧栏 `status` 的职责 -- [x] 建立统一的连接状态组件 -- [x] 明确区分: - - [x] 已连接 - - [x] 重连中 - - [x] 异常 - - [x] 请求失败 -- [x] 避免用户同时看到多个状态入口 - ---- - -## 三、P1:中优先级 - -### 5. 抽象统一的侧栏列表组件 -- [x] 为最近会话、好友、群组三类列表建立统一列表容器 -- [x] 提取统一 item 样式和 hover / active 规则 -- [x] 提取统一 empty state -- [x] 保证三个 tab 的滚动、间距、边界表现一致 - -**现状问题** -- [x] `#conversationList` 有专门样式 -- [x] `friendsList` / `groupsList` 缺少同级统一列表容器规则 - ---- - -### 6. 优化消息渲染逻辑 -- [x] 将消息列表渲染改为批量插入,减少反复重排 -- [x] 历史消息加载时只在完成后滚动一次 -- [x] 拆出消息气泡渲染器 - - [x] 文本消息 - - [x] 图片消息 - - [x] 贴纸消息 - - [~] 富文本/链接卡片 -- [x] 为时间分隔线、日期分隔线建立统一渲染入口 - -**现状问题** -- [x] `renderHistory()` 中逐条追加 -- [x] `appendEntry()` 每次都触发滚动到底部 - ---- - -### 7. 统一富内容消息的样式出口 -- [x] 将表情图、内联图片、Open Graph 卡片从 JS 内联样式改为 CSS class -- [x] 为卡片消息建立独立样式类 -- [~] 避免消息渲染函数中出现大量 DOM 样式拼接 - - [x] 已将主要富内容展示迁移到 `rich-content.js` - - [ ] 后续仍可继续拆分消息渲染器 - -**重点位置** -- [x] `appendEmoticonImage()` -- [x] `appendInlineImage()` -- [x] `appendOpenGraphCard()` -- [x] `renderSuggestionList()` - ---- - -### 8. 统一群组 / 好友 / 会话点击行为 -- [x] 明确三类列表项点击后的统一行为模型 -- [x] 群组点击后应与好友/会话保持一致,避免只填入输入框 -- [ ] 补充“进入会话 / 仅选中 / 自动拉取历史”规则说明 - -**现状问题** -- [x] 最近会话、好友:点击后直接进入 -- [x] 群组:点击后仅写入 `targetId` - ---- - -## 四、P2:体验与规范提升 - -### 9. 补齐可访问性 -- [x] 为 tab 增加完整语义 - - [x] `role="tablist"` - - [x] `role="tab"` - - [x] `aria-selected` -- [x] 为图片预览弹层增加 dialog 语义与焦点管理 -- [x] 增加 `:focus-visible` 样式 -- [~] 优化键盘操作路径 - - [x] tab 切换 - - [x] lightbox 关闭 - - [ ] 建议项导航 - ---- - -### 10. 统一视觉 token -- [ ] 抽取颜色、间距、圆角、阴影为统一变量 -- [ ] 减少样式文件中重复出现的硬编码颜色 -- [ ] 为桌面端/移动端建立更清晰的变量层级 - ---- - -### 11. 清理无效或遗留样式 -- [ ] 检查未使用类名和重复规则 -- [ ] 清理历史遗留命名 -- [ ] 合并重复媒体查询样式 - -**已观察到的潜在项** -- `send-row` -- `image-row` - ---- - -## 五、建议的执行顺序 - -### 第一阶段:结构整理 -- [x] 拆分 JS / CSS 文件 -- [x] 去掉内联样式 -- [~] 建立统一 class 命名 - -### 第二阶段:交互统一 -- [x] 重构移动端发送区 -- [x] 统一连接状态 -- [x] 统一侧栏三类列表行为 - -### 第三阶段:渲染优化 -- [~] 重构消息渲染 -- [x] 提取富内容消息组件 -- [x] 优化批量渲染和滚动策略 - -### 第四阶段:规范与体验 -- [~] 补齐可访问性 -- [ ] 抽取视觉 token -- [ ] 清理冗余样式 - ---- - -## 六、验收标准 - -- [x] 页面结构拆分后,单文件长度显著下降 -- [x] HTML 与 JS 中不再存在大段视觉内联样式 -- [x] 移动端发送区可稳定发送消息和图片 -- [x] 三类侧栏列表行为一致 -- [x] 消息历史加载更顺畅 -- [~] 主要交互支持键盘和焦点可见性 - ---- - -## 七、涉及文件 - -- `public/index.html` -- `public/style.css` -- `public/app.js` -- `public/styles/*.css` -- `public/app/*.js` - ---- - -## 八、下一轮建议优先做的事 - -### 高优先级 -- [ ] 将 `public/app.js` 继续拆成: - - [x] `sidebar.js` - - [x] `messages.js` - - [x] `composer.js` - - [~] `picker.js`(当前已并入 `composer.js`,后续可视情况独立) - - [x] `lightbox.js` -- [x] 将会话状态与 WebSocket 通信从主入口中拆分 -- [x] 将通知、布局控制、偏好存储从主入口中拆分 -- [x] 将 DOM 收集、页面装配与连接启动从主入口中拆分 -- [x] 把消息气泡渲染进一步组件化 -- [ ] 清理剩余运行时样式控制中的可静态部分 - -### 中优先级 -- [ ] 为建议项面板补充完整键盘导航与 aria -- [ ] 评估是否将 `picker` 从 `composer.js` 中独立成单文件 -- [x] 评估并将页面装配逻辑进一步独立 -- [ ] 继续细化富文本/链接卡片渲染边界 -- [ ] 抽取统一视觉 token(颜色、间距、阴影、圆角) -- [ ] 清理未使用类名和重复媒体查询 diff --git a/agents/SELF_REVIEW.md b/agents/SELF_REVIEW.md deleted file mode 100644 index 3e951e9..0000000 --- a/agents/SELF_REVIEW.md +++ /dev/null @@ -1,558 +0,0 @@ -# 通用功能模块自评审流程 - -> 一键触发:`ulw 启动自评审流程,目标:<包路径 | 功能模块描述>` -> 交互触发:`ulw 我要自评审`(逐步问答,无需记参数) -> 示例:`ulw 启动自评审流程,目标:core/helper/search` -> 示例:`ulw 启动自评审流程,目标:订单结算流程` -> 示例:`ulw 启动自评审流程,目标:库存同步与发货` - -## 触发方式 - -支持两种触发模式: - -### 模式 1:直接触发(参数完整) - -适合熟悉格式、一次性写全的场景。 - -Sisyphus 将根据输入自动判断目标类型: - -- **Go 包路径**(包含 `/` 或 `.`,如 `core/helper/search`)→ 走包发现模式 -- **功能模块描述**(自然语言,如 `订单结算流程`)→ 走功能模块发现模式 -- **手动指定文件清单**:在上述参数后追加 `|` 分隔的文件路径,跳过自动发现 - -``` -ulw 启动自评审流程,目标: [| file1.go,file2.go] [可选: 额外上下文] -``` - -额外上下文示例: -- `该模块负责订单结算,依赖 MySQL + Redis` -- `该模块是新增的,需要特别注意错误处理` -- `重点审查并发安全性` -- `涉及多服务交互:go-game-trade-serve → go-goods-serve` - -### 模式 2:交互式触发(推荐) - -记不住格式、或者想一步步来的时候用。无需记忆任何参数。 - -``` -ulw 我要自评审 -``` - -Sisyphus 收到后将通过对话逐项询问: - -1. **评审目标** — 包路径?功能模块描述?还是直接给文件列表? -2. **额外上下文** — 业务背景、关注重点、涉及的服务等 -3. **确认** — 展示理解到的目标,让用户确认后再启动 - -相当于把一次性参数填写变成了问答式引导,降低心智负担。 - -## Sisyphus 自动执行流程 - -Sisyphus 收到此请求后将执行: - -### Phase 0: 文件发现 - -**模式 A — 按包路径发现:** -- 解析目标包路径 -- `glob` + `grep` 发现包内所有 `.go` 文件 -- `grep` 发现包间引用关系 -- **反向发现调用者**:grep 搜索服务目录下哪些文件调用了目标包的导出函数/类型(如 `Start`、`TryPublish`、`Handle` 等入口),将这些调用者文件加入覆盖清单 - - 例:审查 `consumer/goods` 包时,发现调用它的 `game_trade.go`(API handler)和 producer 定时任务文件,加入审查范围 - - 反向发现确保端到端链路完整:调用者的日志级别、返回信息是否与模块实际行为一致 -- 汇总为「覆盖文件清单」 - -**模式 B — 按功能模块描述发现:** -- 启动 2 个 `explore` Agent 并行探索: - - Agent 1:根据功能描述在相关服务目录下搜索关键词、结构体、函数 - - Agent 2:根据功能描述搜索配置、路由注册、API 入口等外围文件 -- 合并结果去重,形成「覆盖文件清单」 -- 如发现跨服务调用,在报告中注明涉及的外部服务 - -**模式 C — 手动指定文件清单:** -- 跳过自动发现,直接使用用户提供的文件路径列表 -- 对每个文件做存在性验证,不存在的文件报告警告 - -### Phase 1: 基线检查 - -- 若覆盖文件清单归属单一服务或单一包 → `go build ./...` + `go test ./...` -- 若跨多个包 → 对每个涉及的独立包分别运行 build + test -- 失败则先不进入审查,报告用户 - -### Phase 2: 审查循环 (直至质量达标) - -循环核心原则:**不设轮次上限,只以质量门禁是否全部通过为终止条件。** - -#### 质量门禁(必须全部通过) - -| # | 门禁 | 判定方式 | 一票否决 | -|---|------|----------|----------| -| G1 | 代码正确性审查 verdict = PASS | Oracle Agent 1 | 是 | -| G2 | 安全+边界审查 verdict = PASS | Oracle Agent 2 | 是 | -| G3 | 架构+模式审查 verdict = PASS | Oracle Agent 3 | 是 | -| G4 | 攻击者测试 verdict = PASS | Oracle Agent 4 | 是 | -| G5 | 交叉验证 verdict = PASS | Sisyphus 对比 4 个 Agent 发现 | 是 | -| G6 | 误判检测 verdict = NO_MISJUDGMENT | Sisyphus 逐条复审 findings | 是 | -| G7 | 零 CRITICAL/MAJOR 残留 | 汇总所有 findings 检查 | 是 | -| G8 | 零回归问题 | 对比上一轮 findings,新引入的算回归 | 是 | -| G9 | go build 通过 | bash 执行 | 是 | -| G10 | go test 通过 | bash 执行 | 是 | - -所有门禁通过(PASS)才算质量达标,否则继续循环。 - -#### 循环流程 - -``` -第 N 轮: - ├─ Phase 2a: 并行审查 - │ ├─ 启动 4 个 Oracle 并行审查(使用 Phase 0 发现的文件清单): - │ │ bg_1: 代码正确性 (oracle, Agent 1) - │ │ bg_2: 安全 + 边界条件 (oracle, Agent 2) - │ │ bg_3: 架构 + 模式 (oracle, Agent 3) - │ │ bg_4: 攻击者测试 (oracle, Agent 4) - │ └─ 等待全部完成 - ├─ Phase 2b: 交叉验证 - │ ├─ Agent 4 审查 Agent 1-3 的发现补充遗漏 - │ ├─ Agent 1-3 审查 Agent 4 的发现去重 - │ └─ Sisyphus 汇总生成完整 findings 清单 - ├─ Phase 2c: 质量门禁检查 - │ ├─ 全部 10 项 PASS → 输出最终报告,循环终止 - │ └─ 有 FAIL 项 → 进入修复流程 - ├─ 修复流程: - │ ├─ 回归问题优先修复(先还旧债,再修新债) - │ ├─ 误判优先复查(G6 FAIL 说明某条 finding 被错误判定,先纠正) - │ ├─ 按类型分流到修复 Agent: - │ │ 单文件修改 → category="quick" - │ │ 多文件/复杂 → category="deep" - │ └─ 修复后执行 build + test - ├─ 如果连续 3 轮同一门禁 FAIL(僵局处理): - │ ├─ 启动 Oracle 深度诊断,分析为什么反复修不好 - │ ├─ 输出根因分析 + 替代方案 - │ └─ 上报用户决策:继续修 / 接受现状 / 改方案 - └─ 进入第 N+1 轮 -``` - -#### 僵局处理(Escalation) - -当连续 3 轮同一门禁 FAIL 时,说明常规修复手段无效。此时: - -1. **暂停修复**,不自欺欺人继续打补丁 -2. **启动 Oracle 深度诊断**,分析根因: - - 是设计缺陷导致修不好?(如:当前架构本身就不安全) - - 是修复引入了新问题?(如:为了修 A 破坏了 B) - - 是审查标准不合理?(如:过于理想化,与现有代码风格冲突) -3. **输出根因分析报告**,给出 2-3 个可选方案 -4. **上报用户**,由用户决策下一步方向 - -### Phase 3: 输出报告 - -## 审查 Agent 提示词模板 - -Sisyphus 将「覆盖文件清单」和模块上下文代入以下模板。 - -**所有 Agent 的 prompt 均包含四个通用要求(在模板中已内置):** - -``` -通用要求(对所有维度均适用): -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件(如 producer 写入 → event bus → consumer 读取并释放) - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值?(例如:重获取锁超时 = 5 秒,假设 priority 在 5 秒内跑完;但 priority 实际耗时可达 30 分钟——正常路径和边界路径混用了同一套超时) - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码?(例如:`LoadInt64 > 0` 的检查与后续操作之间假设了状态不变) - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? - -``` - -### Agent 1: 代码正确性 - -``` -task(subagent_type="oracle", load_skills=[], run_in_background=true, - description="Review correctness of MODULE_NAME", - prompt=""" -CODE CORRECTNESS + QUALITY REVIEW -{MODULE_NAME} -{NEWLINE_SEPARATED_FILE_LIST_WITH_FULL_CONTENT} -{MODULE_SPECIFIC_CONTEXT_FROM_USER} - -通用要求: -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件 - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值? - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? -Review for: -- Logic errors -- Concurrency issues: 死锁、活锁、竞态条件、双释放、ABA 问题、原子操作误用 -- 共享的 sync.Map/atomic.Pointer 等原语:区分"操作本身安全"和"业务语义安全" - (例如:sync.Map.Delete 是幂等的,但如果当前存储的是其他 goroutine 的 token, - 删除它就破坏了其他持有者的锁 — 这种"跨 goroutine 的语义安全性") -- defer cleanup 的覆盖完整性:所有获取操作是否有对应的 defer/手动释放 -- Error handling gaps: 错误被吞没、错误类型误判(如用 ctx.Err() 代替被包裹的 err) -- Data integrity risks -- Nil pointer dereference potential -- Dead code - -OUTPUT: PASS or FAIL each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation -""") -``` - -### Agent 2: 安全 + 边界条件 - -``` -task(subagent_type="oracle", load_skills=[], run_in_background=true, - description="Review security of MODULE_NAME", - prompt=""" -SECURITY + EDGE CASE REVIEW -{MODULE_NAME} -{FILE_LIST} - -通用要求: -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件 - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值? - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? - -Review for: -- Input validation 的覆盖面和充分性 -- Injection risks (SQL/命令/prompt injection 等) -- Secrets exposure (API key 是否被意外记入日志) -- DoS vectors(无界 goroutine、内存爆炸、死循环、无穷递归) -- 操作幂等性 vs 业务语义安全性的区别 - (例如:db.Delete 不报错 ≠ 业务语义正确;map.Delete 不 panic ≠ 没破坏其他持有者的状态) -- Edge cases: empty inputs, max-size, unicode, zero values, negative values, context cancelled during write -- Panic paths: 是否有未 recover 的 panic 点 -- Resource leaks: HTTP body, goroutine, channel, semaphore -- Timeout handling: 内外层超时不匹配、超时后状态不一致 -- 攻击者视角:假设调用者可以控制输入参数,找到所有利用路径 - -OUTPUT: PASS or FAIL each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, and concrete explanation -""") -``` - -### Agent 3: 架构 + 模式 - -``` -task(subagent_type="oracle", load_skills=[], run_in_background=true, - description="Review architecture of MODULE_NAME", - prompt=""" -ARCHITECTURE + PATTERN REVIEW -{MODULE_NAME} -{FILE_LIST} - -通用要求: -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件 - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值? - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? - -Review for: -- Package structure: 依赖方向是否清晰,是否存在循环依赖 -- 对称性检查: 是否有类似的代码路径(如 A vs B、game vs price、producer vs consumer) - 它们的 cleanup/错误处理是否一致?不一致的差异是否有正当理由? -- 代码重复: 哪些可以抽象复用,哪些是必要差异 -- Over-engineering: 是否存在不必要复杂度 -- Dead code: 未使用的函数、类型、字段、常量 -- 常量组织: 分散或重复的常量、硬编码值 -- Interface design: 是否利于测试(mockable)、扩展 -- 全局状态依赖: 是否过度依赖全局变量,影响可测试性和并发安全性 -- 已知设计约束记录: 如无法避免的依赖倒置,标注为已知约束 - -OUTPUT: PASS or FAIL each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation -""") -``` - -### Agent 4: 攻击者测试 - -这是新增角色,专门从"破坏系统"角度审查。它不检查"代码好不好看",只检查"有什么方式能让系统坏掉"。 - -``` -task(subagent_type="oracle", load_skills=[], run_in_background=true, - description="Adversarial review of MODULE_NAME", - prompt=""" -ADVERSARIAL + DESTRUCTIVE TESTING -{MODULE_NAME} -{FILE_LIST} - -角色: 你是恶意攻击者/系统破坏者。你的目标是找到所有方式让这个模块出错、崩溃、数据损坏、或行为异常。 -你不关心代码风格或架构优雅性。只关心:**我怎么搞坏它?** - -通用要求: -R1. 时序场景模拟: - - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) - - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine - - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 - - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 - -R2. 逐 return 路径 cleanup 验证: - - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) - - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 - - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) - -R3. 跨函数/跨文件共享状态生命周期追踪: - - 如果一个共享状态的生命周期跨越多个函数/文件 - - 追踪该状态的完整路径,检查每个跳转点的一致性 - - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 - -R4. 隐式假设陷阱扫描: - - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 - 对这些假设逐条问: - a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? - b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? - c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? - d) 代码是否将"正常情况"和"边界情况"用了同一个值? - e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? - - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: - f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? - g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? - h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? - i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? - j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? - k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 - l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? - m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? - -找以下类别的破坏路径(每个类别给出具体时序): - -1. 并发破坏: - - 双释放:同一个资源被释放两次,第二次释放时已被其他人持有 - - 释放后使用:资源被释放后仍有代码路径访问它 - - 先读后写竞态:TOC/TOU (time-of-check vs time-of-use) - - 死锁/活锁/自旋:循环等待条件永远不满足 - - 优先级反转:高优先级任务被低优先级任务阻塞超过预期 - - ABA 问题:atomic.CompareAndSwap 的经典陷阱 - -2. 状态泄露: - - Defer/resource 泄露:某个 return 路径遗漏了资源释放 - - 对称性违反:A 路径有 cleanup,B 路径没有 - - 永久残留:某个 key/token 写入 map 后没有删除路径 - -3. 数据损坏: - - 并发写入同一条记录 - - 部分更新:一批操作中部分成功部分失败 - - 脏读:读到不完整的状态 - -4. 静默失败: - - 错误被 log 后继续执行(错误被吞没) - - 返回成功但实际未执行任何操作 - - 条件竞争导致跳过执行 - -5. 超时/取消不一致: - - 外层超时比内层短,导致内层操作被无故终止 - - 取消后状态未回滚 - - 超时后仍有 goroutine 在后台运行 - -OUTPUT: PASS or FAIL each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, concrete exploit scenario, and expected impact -""") -``` - -## 交叉验证 - -所有 Agent 返回 findings 后,Sisyphus 执行交叉验证: - -``` -交叉验证步骤: - -1. 收集 4 个 Agent 的所有 findings,去重合并 - -2. 让 Agent 4 审查 Agent 1-3 的 findings: - - 是否有 Agent 1-3 判定为 PASS 但 Agent 4 持怀疑态度的? - - 是否有 Agent 1-3 标记为 MINOR 但 Agent 4 认为可能是 MAJOR/CRITICAL 的? - - 输出: 补充遗漏、严重度修正建议 - -3. 让 Agent 1-3 审查 Agent 4 的 findings: - - 是否与 Agent 1-3 的已有发现重叠? - - 是否有 Agent 4 发现但其他 Agent 确实遗漏的关键问题? - - 输出: 去重后的新增 findings - -4. Sisyphus 逐条检查 findings 的判定质量(误判检测): - - 对每条 finding,检查是否有 Agent 给出了"底层操作安全,无实际 bug"类判定 - - 追问:该操作在 业务语义 上是否安全?(即是否可能破坏其他 goroutine 的状态) - - 如果发现误判 → 标注 MISJUDGMENT,该轮 G6 门禁 FAIL -``` - -交叉验证的输出格式: -``` - - - 严重度从 MINOR 修正为 MAJOR: ... - Agent 1-3 未发现的路径: ... - - - 与 agent1_finding_7 重复,合并 - 确认遗漏,补充到主清单 - - - - 原判定: "sync.Map.Delete 幂等安全,无实际 bug" - 纠正: 虽然 Delete 不 panic,但此时 map 中存的是其他 goroutine 的 token,删除它导致该 goroutine 状态泄露 - - - -``` - -## 修复 Agent 分流规则 - -| 问题规模 | Agent | 策略 | -|----------|-------|------| -| 单文件简单修改 | `category="quick"` | 逐一给出文件路径+行号+精确修改内容 | -| 多文件协调修改 | `category="quick"` 分批 | 按「修改的文件不重叠」原则并行 | -| 复杂逻辑重写 | `category="deep"` | 给出完整上下文和期望结果 | - -## 退出条件 - -循环终止条件(按优先级): - -| 优先级 | 条件 | 说明 | -|--------|------|------| -| 1 | **全部质量门禁通过** | 正常退出 — 质量达标 | -| 2 | **用户手动终止** | `stop` / `终止` / `暂停` | -| 3 | **僵局经用户决策终止** | 上报后用户选择「接受现状」或「改方案」 | - -**不存在「无新发现就自动停止」这条退路。** 只要门禁没全过,就继续循环。 -只有质量达标、用户叫停、或用户决策接受现状这三种情况才能终止。 - -## 最终报告模板 - -```markdown -# {MODULE_NAME} 自评审报告 - -## 总览 -- 模块: {MODULE_NAME} -- 发现模式: [包路径 / 功能模块探索 / 手动指定] -- 涉及包/服务: {PACKAGES / SERVICES} -- 轮次: {ROUNDS} -- 最终判定: PASS / FAIL -- 已修复: {FIXED_COUNT} 项 -- 已知设计约束: {CONSTRAINT_COUNT} 项 -- 审查 Agent: 4 个(正确性/安全+边界/架构+模式/攻击者测试) -- 交叉验证: [已执行 / 跳过] -- 误判检测: [无误判 / 发现 {N} 条误判并纠正] - -## 已修复问题 -| # | 严重度 | 描述 | 文件 | 修复方式 | -|---|--------|------|------|----------| - -## 误判纠正记录 -| # | 原判定 | 纠正后 | 描述 | -|---|--------|--------|------| - -## 已知设计约束 -| # | 描述 | 原因 | -|---|------|------| - -## 验证 -- build: {STATUS} -- test: {PASSED}/{TOTAL} -``` diff --git a/agents/plan-mode.md b/agents/plan-mode.md deleted file mode 100644 index f7209d3..0000000 --- a/agents/plan-mode.md +++ /dev/null @@ -1,228 +0,0 @@ -# Plan Mode 工作流 - -> 先计划、后执行。每次非平凡任务必须经过 P → R → A → E 四阶段闭环。 -> 建立日期:2026-05-09 - ---- - -## 1. 触发方式 - -### 方式 A — 显式触发 - -``` -ulw plan <需求描述> -``` - -### 方式 B — 隐式触发 - -以下条件任一满足时自动进入 Plan Mode: -- 涉及 2+ 个文件的改动 -- 涉及架构决策 -- 涉及跨服务修改 -- 涉及新的业务流程 - ---- - -## 2. 四阶段工作流 - -``` -P 阶段 (Plan) ──→ R 阶段 (Review) ──→ A 阶段 (Approve) ──→ E 阶段 (Execute) - │ │ │ │ - │ 我出计划 │ 你审反馈 │ 你签字审批 │ 我执行 - │ 结构化文档 │ 逐条过 │ 无遗留问题 │ 按计划推进 - └───────────────────┴────────────────────┴────────────────────┴────────── -``` - ---- - -## 3. P 阶段:计划制定 - -### 3.1 产出物 - -计划文档,写入 `.sisyphus/plans/{task-name}.md`,包含 6 个章节: - -| 章节 | 内容 | -|------|------| -| 1. 需求理解 | 我对需求的重新表述 + 待确认疑问 | -| 2. 范围界定 | In Scope / Out of Scope 清单 | -| 3. 技术方案 | 架构决策、修改文件、技术路线 | -| 4. 任务分解 | 按 Wave(并行批次)拆分的任务列表,每任务含:文件、改动描述、依赖 | -| 5. 风险点 | 潜在风险、回滚方案、需要关注的点 | -| 6. 验证计划 | 如何验证每个改动正确 | - -### 3.2 内部流程 - -``` -P0: 需求澄清(如有歧义 → 提问) -P1: 探索代码库(explore agent 并行搜索相关代码、模式、依赖) -P2: 复杂架构 → 咨询 Oracle(技术方案评审) -P3: 撰写计划文档(上述 6 个章节) -P4: 输出给用户审核 -``` - -### 3.3 执行完成标准(通用门禁) - -``` -- [ ] 所有任务标记完成 -- [ ] 项目构建通过(按项目实际工具:go build / npm run build / cargo build / 等) -- [ ] 项目测试通过(按项目实际工具:go test / npm test / pytest / 等) -- [ ] LSP 诊断无新增错误 -- [ ] 改动的代码通过了手动验证(见验证计划各条) -``` - ---- - -### 3.4 计划文档模板 - -```markdown -# Plan: {任务名称} - -## 1. 需求理解 -{我对需求的重新表述} - -## 2. 范围界定 -### In Scope -- {list} -### Out of Scope -- {list} - -## 3. 技术方案 -{架构图/决策说明/文件清单} - -## 4. 任务分解 - -| Wave | Task ID | 描述 | 文件 | 依赖 | 预期产出 | -|------|---------|------|------|------|----------| -| 1 | T1 | ... | ... | 无 | ... | -| 1 | T2 | ... | ... | 无 | ... | -| 2 | T3 | ... | ... | T1,T2 | ... | - -## 5. 风险点 -| 风险 | 概率 | 影响 | 缓解措施 | -|------|------|------|----------| -| ... | 高/中/低 | ... | ... | - -## 6. 验证计划 -| 验证项 | 方法(按项目实际工具填写) | 预期结果 | -|--------|---------------------------|----------| -| 编译 | {go build / npm run build / cargo build / ...} | exit 0 | -| 单元测试 | {go test / npm test / pytest / ...} | 全部通过 | -| Lint/类型检查 | {golangci-lint / tsc / mypy / ...} | 无新增问题 | -| 手动验证 | {按功能描述执行的操作步骤} | 符合预期 | -``` - ---- - -## 4. R 阶段:审核反馈 - -### 角色分工 - -- **你**:逐章节审阅计划,给出修改意见,指出遗漏,调整优先级 -- **我**:根据反馈更新计划文档,对每条反馈回应(接受/解释/替代方案),方案重大变更则重新咨询 Oracle - -### 你说话的格式 - -``` -plan 反馈: -1. [章节名] 第X条:建议改为... -2. [风险] 漏掉了YY场景 -3. [任务分解] Wave 2 应该先于 Wave 1 -``` - -### 我回应的格式 - -``` -计划更新 v2: -[章节] 已修改:... -[章节] 已采纳反馈:... -[章节] 关于第X点,我的考虑是... 是否保持原方案? -``` - -### 轮次 - -不限,直至你满意。 - ---- - -## 5. A 阶段:审批通过 - -### 触发词 - -你说以下之一即表示审批通过: - -``` -plan 通过 -审核通过 -批准执行 -Plan Approved -``` - -### 审批门禁(前置条件) - -- [ ] 所有疑问已澄清 -- [ ] 需求理解无误 -- [ ] 技术方案没有明显漏洞 -- [ ] 任务分解完整(没有遗漏步骤) -- [ ] 验证计划合理 - -审批后,计划文档进入**锁定状态**。执行阶段严格按计划推进。 - ---- - -## 6. E 阶段:执行 - -### 执行原则 - -1. 严格按照任务分解的 **Wave 顺序** 执行 -2. 每个 Wave 内任务**并行**执行 -3. 每个任务完成后:`lsp_diagnostics` + 验证步骤 -4. 每完成一个 Wave:运行一次 build + test -5. 每完成一个 Wave:向你更新进度 - -### 执行偏差处理 - -| 情况 | 处理方式 | -|------|----------| -| 执行中发现计划遗漏 | 暂停 → 报告偏差 → 等你决策 | -| 执行中遇到计划错误的假设 | 暂停 → 分析根因 → 提交方案变更请求 | -| 执行中发现更好方案 | 暂停 → 说明理由 → 等你决定是否改用新方案 | -| 执行一切顺利 | 继续推进,直到全部完成 | - -### 变更请求格式 - -``` -变更请求 #1: -- 原计划:[Wave X, Task Y] -- 发现的问题:[具体问题] -- 建议修改为:[新方案] -- 理由:[为什么不按原计划] -- 影响:[对其他任务的影响] -请确认是否批准此变更。 -``` - ---- - -## 7. Plan Mode 与直接执行模式的选择 - -| 维度 | 直接执行 | Plan Mode | -|------|----------|-----------| -| 开始前 | 直接开始编码 | 先出计划文档 | -| 用户参与 | 执行中提反馈 | 计划阶段先审核 | -| 变更处理 | 随时改 | 正式变更请求 | -| 范围控制 | 容易 scope creep | 严格按计划执行 | -| 风险把控 | 走一步看一步 | 提前识别风险 | -| 适用场景 | 1 个文件的简单修改 | 2+ 文件、新功能、架构变更 | - ---- - -## 8. 执行完成标准(通用) - -所有执行任务完成后,必须逐条验证: - -- [ ] 所有任务标记完成 -- [ ] 项目构建通过(按项目实际工具填写命令) -- [ ] 项目测试通过(按项目实际工具填写命令) -- [ ] LSP 诊断无新增错误 -- [ ] 改动的代码通过了手动验证(见验证计划各条) - -**以上门禁全部通过才算任务完成。** diff --git a/chat.js b/chat.js deleted file mode 100644 index a043431..0000000 --- a/chat.js +++ /dev/null @@ -1,1990 +0,0 @@ -const http = require('http'); -const fs = require('fs'); -const crypto = require('crypto'); -const path = require('path'); -const { once } = require('node:events'); -const readline = require('readline'); - -const axios = require('axios'); -const dateformat = require('@matteo.collina/dateformat'); -const WebSocket = require('ws'); - -const CHAT_LOG_FILE = './logs/chat.jsonl'; -const STICKER_CACHE_DIR = './logs/stickers'; -const IMAGE_CACHE_DIR = './logs/images'; -const HISTORY_DEFAULT_LIMIT = 100; -const HISTORY_MAX_LIMIT = 500; -const PREVIEW_MAX_LENGTH = 60; - -function normalizeAuthConfig(rawAuth) { - const defaultConfig = { - username: '', - password: '', - realm: 'Steam Chat', - trustProxy: false, - }; - - if (!rawAuth || typeof rawAuth !== 'object') { - return defaultConfig; - } - - return { - username: rawAuth.username || '', - password: rawAuth.password || '', - realm: rawAuth.realm || defaultConfig.realm, - trustProxy: rawAuth.trustProxy === true, - }; -} - -function normalizeChatConfig(rawConfig) { - const defaultConfig = { - enabled: Boolean(rawConfig), - host: '0.0.0.0', - port: 3000, - wsPath: '/ws', - auth: normalizeAuthConfig(null), - }; - - if (!rawConfig || typeof rawConfig !== 'object') { - return defaultConfig; - } - - return { - enabled: rawConfig.enabled !== false, - host: rawConfig.host || defaultConfig.host, - port: rawConfig.port || defaultConfig.port, - wsPath: rawConfig.wsPath || defaultConfig.wsPath, - auth: normalizeAuthConfig(rawConfig.auth), - }; -} - -function normalizeIpAddress(rawAddress) { - let address = String(rawAddress || '').trim(); - if (!address) { - return ''; - } - - const forwardedMatch = address.match(/^for=(.+)$/i); - if (forwardedMatch) { - address = forwardedMatch[1]; - } - - address = address.replace(/^"|"$/g, ''); - - if (address.startsWith('[')) { - const closingIndex = address.indexOf(']'); - if (closingIndex !== -1) { - address = address.slice(1, closingIndex); - } - } else if ((address.match(/:/g) || []).length === 1 && address.includes('.')) { - address = address.split(':')[0]; - } - - address = address.replace(/^\[|\]$/g, '').replace(/%[0-9a-z]+$/i, '').trim().toLowerCase(); - - if (address.startsWith('::ffff:')) { - return address.slice('::ffff:'.length); - } - - return address; -} - -function parseForwardedHeader(headerValue) { - if (!headerValue) { - return ''; - } - - for (const part of String(headerValue).split(',')) { - for (const segment of part.split(';')) { - const match = segment.trim().match(/^for=(.+)$/i); - if (match && match[1]) { - return normalizeIpAddress(match[1]); - } - } - } - - return ''; -} - -function getClientIp(req, trustProxy = false) { - const headers = req && req.headers ? req.headers : {}; - - if (trustProxy) { - const forwarded = parseForwardedHeader(headers.forwarded); - if (forwarded) { - return forwarded; - } - - const xForwardedFor = String(headers['x-forwarded-for'] || '') - .split(',') - .map((item) => normalizeIpAddress(item)) - .find(Boolean); - if (xForwardedFor) { - return xForwardedFor; - } - - const xRealIp = normalizeIpAddress(headers['x-real-ip']); - if (xRealIp) { - return xRealIp; - } - } - - return normalizeIpAddress( - (req && req.socket && req.socket.remoteAddress) - || '', - ); -} - -function isLanIp(rawAddress) { - const address = normalizeIpAddress(rawAddress); - if (!address) { - return false; - } - - if (address === '::1' || address === 'localhost') { - return true; - } - - if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) { - if (address.startsWith('10.') || address.startsWith('127.') || address.startsWith('192.168.') || address.startsWith('169.254.')) { - return true; - } - - const octets = address.split('.').map((item) => Number.parseInt(item, 10)); - if (octets.length === 4 && octets.every((item) => Number.isInteger(item) && item >= 0 && item <= 255)) { - return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31; - } - - return false; - } - - return address.startsWith('fc') - || address.startsWith('fd') - || address.startsWith('fe8') - || address.startsWith('fe9') - || address.startsWith('fea') - || address.startsWith('feb'); -} - -function isAuthEnabled(authConfig) { - return Boolean(authConfig && authConfig.username && authConfig.password); -} - -function hashSecret(value) { - return crypto.createHash('sha256').update(String(value || '')).digest(); -} - -function safeEqual(left, right) { - return crypto.timingSafeEqual(hashSecret(left), hashSecret(right)); -} - -function parseBasicAuthHeader(headerValue) { - const match = String(headerValue || '').match(/^Basic\s+(.+)$/i); - if (!match) { - return null; - } - - try { - const decoded = Buffer.from(match[1], 'base64').toString('utf8'); - const separatorIndex = decoded.indexOf(':'); - if (separatorIndex === -1) { - return null; - } - - return { - username: decoded.slice(0, separatorIndex), - password: decoded.slice(separatorIndex + 1), - }; - } catch (err) { - return null; - } -} - -function isAuthorized(req, authConfig) { - if (!isAuthEnabled(authConfig)) { - return true; - } - - const credentials = parseBasicAuthHeader(req && req.headers ? req.headers.authorization : ''); - if (!credentials) { - return false; - } - - return safeEqual(credentials.username, authConfig.username) - && safeEqual(credentials.password, authConfig.password); -} - -function requiresHttpAuth(req, chatConfig) { - if (!isAuthEnabled(chatConfig && chatConfig.auth)) { - return false; - } - - return !isLanIp(getClientIp(req, chatConfig.auth.trustProxy)); -} - -function normalizeWsRequest(payload) { - switch (payload.type) { - case 'msg': - case 'send_message': - return { - action: 'send_message', - requestId: payload.requestId, - id: payload.id, - msg: payload.msg, - }; - case 'img': - case 'send_image': - return { - action: 'send_image', - requestId: payload.requestId, - id: payload.id, - img: payload.img, - url: payload.url, - }; - case 'history': - case 'get_history': - return { - action: 'get_history', - requestId: payload.requestId, - id: payload.id, - limit: payload.limit, - }; - case 'conversations': - case 'get_conversations': - return { - action: 'get_conversations', - requestId: payload.requestId, - limit: payload.limit, - }; - case 'emoticons': - case 'get_emoticons': - return { - action: 'get_emoticons', - requestId: payload.requestId, - }; - case 'friends': - case 'get_friends': - return { - action: 'get_friends', - requestId: payload.requestId, - }; - case 'groups': - case 'get_groups': - return { - action: 'get_groups', - requestId: payload.requestId, - }; - case 'ping': - return { - action: 'ping', - requestId: payload.requestId, - }; - default: - return { - action: payload.type, - requestId: payload.requestId, - ...payload, - }; - } -} - -function buildMessageKey(message) { - return `${message.id}:${message.ordinal}:${message.message}`; -} - -function normalizeHistoryEntry(entry) { - if (!entry || typeof entry !== 'object') { - return null; - } - - return { - type: entry.type || (entry.imageUrl ? 'image' : 'message'), - date: entry.date || '', - echo: Boolean(entry.echo), - id: entry.id || '', - name: entry.name || '', - message: entry.message || '', - imageUrl: entry.imageUrl || null, - ordinal: typeof entry.ordinal === 'number' ? entry.ordinal : null, - sentAt: entry.sentAt || null, - }; -} - -function sanitizeLimit(limit, fallback = HISTORY_DEFAULT_LIMIT) { - const value = Number.parseInt(limit, 10); - if (!Number.isFinite(value) || value <= 0) { - return fallback; - } - return Math.min(value, HISTORY_MAX_LIMIT); -} - -function extractStickerType(message) { - if (typeof message !== 'string') { - return null; - } - - const match = message.match(/\[sticker\s+type="([^"]+)"/i); - return match ? match[1] : null; -} - -function extractEmoticonNames(message) { - if (typeof message !== 'string') { - return []; - } - - const names = new Set(); - - for (const match of message.matchAll(/\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?/gi)) { - if (match[1]) { - names.add(match[1]); - } - } - - for (const match of message.matchAll(/\[emoticon\]([^\[]+)\[\/emoticon\]/gi)) { - if (match[1]) { - names.add(match[1].trim()); - } - } - - for (const match of message.matchAll(/(^|\s):([a-z0-9_][a-z0-9_\-]*):(?=\s|$|[!?,.])/gi)) { - if (match[2]) { - names.add(match[2]); - } - } - - return [...names]; -} - -function extractImageUrls(message) { - if (typeof message !== 'string') { - return []; - } - - const urls = new Set(); - - for (const match of message.matchAll(/\[img\](https?:\/\/[^\s[\]]+?)\[\/img\]/gi)) { - if (match[1]) { - urls.add(match[1]); - } - } - - for (const match of message.matchAll(/\[img\s+src=(https?:\/\/\S+?)[\s\]]/gi)) { - if (match[1]) { - urls.add(match[1]); - } - } - - for (const match of message.matchAll(/]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi)) { - if (match[1]) { - urls.add(match[1]); - } - } - - for (const match of message.matchAll(/https?:\/\/\S+?(?:png|jpe?g|gif|webp|bmp)(?:\?\S*)?/gi)) { - if (match[0]) { - urls.add(match[0]); - } - } - - return [...urls]; -} - -function parseBbCodeAttributes(rawAttributes) { - const attrs = {}; - const content = String(rawAttributes || ''); - const attributeRegex = /([a-z][a-z0-9_-]*)=(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^\s"'=<>`]+))/gi; - let match; - - while ((match = attributeRegex.exec(content)) !== null) { - const key = match[1].toLowerCase(); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - attrs[key] = value.replace(/\\(["'])/g, '$1'); - } - - return attrs; -} - -function extractOpenGraphEmbeds(message) { - if (typeof message !== 'string') { - return []; - } - - const embeds = []; - - for (const match of message.matchAll(/\[og\s+([^\]]+)\]([\s\S]*?)\[\/og\]/gi)) { - const attrs = parseBbCodeAttributes(match[1] || ''); - const fallbackUrl = (match[2] || '').trim(); - - embeds.push({ - url: attrs.url || fallbackUrl, - img: attrs.img || null, - title: attrs.title || '', - }); - } - - return embeds.filter((item) => item.url); -} - -function buildSteamEmoticonUrl(name, large = true) { - const normalized = String(name || '').trim().replace(/^:+|:+$/g, ''); - if (!normalized) { - return null; - } - - const sizePath = large ? 'emoticonlarge' : 'emoticon'; - return `https://steamcommunity-a.akamaihd.net/economy/${sizePath}/${encodeURIComponent(normalized)}`; -} - -function buildSteamStickerCandidateUrls(type) { - const normalized = String(type || '').trim(); - if (!normalized) { - return []; - } - - return [ - `https://steamcommunity-a.akamaihd.net/economy/sticker/${encodeURIComponent(normalized)}`, - `https://steamcommunity-a.akamaihd.net/economy/stickerlarge/${encodeURIComponent(normalized)}`, - `https://steamcommunity.com/economy/sticker/${encodeURIComponent(normalized)}`, - `https://steamcommunity.com/economy/stickerlarge/${encodeURIComponent(normalized)}`, - ]; -} - -function buildStickerCachePath(type) { - const normalized = String(type || '').trim(); - if (!normalized) { - return path.join(STICKER_CACHE_DIR, 'unknown.png'); - } - - return path.join(STICKER_CACHE_DIR, `${encodeURIComponent(normalized)}.bin`); -} - -function buildImageCachePaths(url) { - const normalized = String(url || '').trim(); - const hash = crypto.createHash('sha1').update(normalized).digest('hex'); - return { - dataPath: path.join(IMAGE_CACHE_DIR, `${hash}.bin`), - metaPath: path.join(IMAGE_CACHE_DIR, `${hash}.json`), - }; -} - -function guessImageContentType(url, fallback = 'image/png') { - const pathname = String(url || '').split('?')[0].toLowerCase(); - if (pathname.endsWith('.png')) { - return 'image/png'; - } - if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) { - return 'image/jpeg'; - } - if (pathname.endsWith('.gif')) { - return 'image/gif'; - } - if (pathname.endsWith('.webp')) { - return 'image/webp'; - } - if (pathname.endsWith('.bmp')) { - return 'image/bmp'; - } - if (pathname.endsWith('.svg')) { - return 'image/svg+xml'; - } - return fallback; -} - -function buildConversationPreview(entry) { - if (!entry) { - return ''; - } - - if (entry.type === 'image' || entry.imageUrl) { - return '[图片]'; - } - - const ogEmbeds = extractOpenGraphEmbeds(entry.message); - if (ogEmbeds.length > 0) { - return ogEmbeds[0].title || ogEmbeds[0].url || '[链接预览]'; - } - - if (extractImageUrls(entry.message).length > 0) { - return '[图片]'; - } - - const stickerType = extractStickerType(entry.message); - if (stickerType) { - return `[贴纸] ${stickerType.replace(/^Sticker_/, '')}`; - } - - const emoticonNames = extractEmoticonNames(entry.message); - const emoticonOnlyText = String(entry.message || '') - .replace(/\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?/gi, (_, name) => `:${name}:`) - .replace(/\[emoticon\]([^\[]+)\[\/emoticon\]/gi, (_, name) => `:${name.trim()}:`) - .trim() - .replace(/\s+/g, ''); - if (emoticonNames.length && emoticonOnlyText === emoticonNames.map((name) => `:${name}:`).join('')) { - return `[表情] ${emoticonNames.join(' ')}`; - } - - return String(entry.message || '').trim().replace(/\s+/g, ' ').slice(0, PREVIEW_MAX_LENGTH); -} - -function sortHistoryItems(items) { - return [...items].sort((left, right) => { - const leftDate = left.date || left.sentAt || ''; - const rightDate = right.date || right.sentAt || ''; - - if (leftDate !== rightDate) { - return leftDate.localeCompare(rightDate); - } - - const leftOrdinal = typeof left.ordinal === 'number' ? left.ordinal : Number.MAX_SAFE_INTEGER; - const rightOrdinal = typeof right.ordinal === 'number' ? right.ordinal : Number.MAX_SAFE_INTEGER; - - return leftOrdinal - rightOrdinal; - }); -} - -function buildConversationSummaries(items) { - const conversations = new Map(); - - for (const entry of sortHistoryItems(items)) { - if (!entry.id) { - continue; - } - - const current = conversations.get(entry.id) || { - id: entry.id, - name: entry.echo ? '' : (entry.name || ''), - updatedAt: entry.date || entry.sentAt || '', - preview: buildConversationPreview(entry), - lastType: entry.type || (entry.imageUrl ? 'image' : 'message'), - lastEcho: Boolean(entry.echo), - messageCount: 0, - }; - - if (!entry.echo) { - current.name = entry.name || current.name; - } - current.updatedAt = entry.date || entry.sentAt || current.updatedAt; - current.preview = buildConversationPreview(entry) || current.preview; - current.lastType = entry.type || (entry.imageUrl ? 'image' : 'message'); - current.lastEcho = Boolean(entry.echo); - current.messageCount += 1; - - conversations.set(entry.id, current); - } - - return [...conversations.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); -} - -const PUBLIC_DIR = path.join(__dirname, 'public'); - -const STATIC_CONTENT_TYPES = { - '.html': 'text/html; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.js': 'application/javascript; charset=utf-8', -}; - -function serveStaticFile(res, filePath) { - const ext = path.extname(filePath) || '.html'; - const contentType = STATIC_CONTENT_TYPES[ext]; - if (!contentType) { - res.writeHead(404); - res.end('Not Found'); - return; - } - - const fullPath = path.join(PUBLIC_DIR, filePath); - const normalized = path.normalize(fullPath); - if (!normalized.startsWith(PUBLIC_DIR)) { - res.writeHead(403); - res.end('Forbidden'); - return; - } - - fs.readFile(normalized, (err, data) => { - if (err) { - res.writeHead(404); - res.end('Not Found'); - return; - } - res.writeHead(200, { 'Content-Type': contentType }); - res.end(data); - }); -} - -function getDefaultDeps() { - const config = require('./config.js'); - const client = require('./client'); - - return { - rawChatConfig: config.chat, - client, - logger: client.logger, - steamUser: client.steamUser, - steamCommunity: client.steamCommunity, - fsModule: fs, - httpModule: http, - onceFn: once, - axiosInstance: axios, - WebSocketImpl: WebSocket, - dateToString: (date) => dateformat(date, 'yyyy-mm-dd HH:MM:ss.l'), - }; -} - -function createChatService(customDeps = {}) { - const baseDeps = customDeps.useDefaultDeps === false ? {} : getDefaultDeps(); - const deps = { - ...baseDeps, - ...customDeps, - }; - - delete deps.useDefaultDeps; - - const { - rawChatConfig, - client, - logger, - steamUser, - steamCommunity, - fsModule, - httpModule, - onceFn, - axiosInstance, - WebSocketImpl, - dateToString, - } = deps; - - const chatConfig = normalizeChatConfig(rawChatConfig); - const recentSelfMessages = new Map(); - const recentSelfImageUrls = new Map(); - const pendingStickerFetches = new Map(); - const pendingImageFetches = new Map(); - let pendingWebSessionRefresh = null; - let started = false; - - const MSG_DEDUP_TTL_MS = 15000; - const WS_SESSION_TIMEOUT_MS = 15000; - const EMOTICON_LIST_TIMEOUT_MS = 10000; - const LOG_DIR = './logs'; - - fsModule.mkdir(LOG_DIR, { recursive: true }, (err) => { - if (err) { - logger.error('an error occurred while creating the logs directory: ' + err); - } - }); - fsModule.mkdir(STICKER_CACHE_DIR, { recursive: true }, (err) => { - if (err) { - logger.error('an error occurred while creating the sticker cache directory: ' + err); - } - }); - fsModule.mkdir(IMAGE_CACHE_DIR, { recursive: true }, (err) => { - if (err) { - logger.error('an error occurred while creating the image cache directory: ' + err); - } - }); - - const server = httpModule.createServer(handleHttp); - const wss = new WebSocketImpl.Server({ - server, - path: chatConfig.wsPath, - verifyClient: (info, done) => { - if (!requiresHttpAuth(info.req, chatConfig) || isAuthorized(info.req, chatConfig.auth)) { - done(true); - return; - } - - done(false, 401, 'Authentication Required', { - 'WWW-Authenticate': `Basic realm="${String(chatConfig.auth.realm || 'Steam Chat').replace(/"/g, '\\"')}"`, - }); - }, - }); - - async function readRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB - - req.on('data', (chunk) => { - body += chunk.toString(); - if (body.length > MAX_BODY_SIZE) { - req.destroy(); - const error = new Error('Request body too large'); - error.code = 413; - reject(error); - } - }); - req.on('end', () => resolve(body)); - req.on('error', reject); - }); - } - - async function readJsonBody(req) { - const body = await readRequestBody(req); - if (!body) { - return {}; - } - - try { - return JSON.parse(body); - } catch (err) { - const error = new Error('Invalid JSON'); - error.code = 400; - throw error; - } - } - - function sendJson(res, statusCode, payload) { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); - } - - function sendAuthRequired(res) { - res.statusCode = 401; - res.setHeader('WWW-Authenticate', `Basic realm="${String(chatConfig.auth.realm || 'Steam Chat').replace(/"/g, '\\"')}"`); - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify({ error: 'Authentication Required' })); - } - - function sendWs(ws, payload) { - if (ws.readyState === WebSocketImpl.OPEN) { - ws.send(JSON.stringify(payload)); - } - } - - function broadcastWs(payload) { - const encoded = JSON.stringify(payload); - wss.clients.forEach((ws) => { - if (ws.readyState === WebSocketImpl.OPEN) { - ws.send(encoded); - } - }); - } - - function appendLogEntry(entry) { - fsModule.appendFile(CHAT_LOG_FILE, JSON.stringify(entry) + '\n', (err) => { - if (err) { - logger.error('an error occurred while writing chat log file: ' + err); - } - }); - } - - async function readFileIfExists(filePath) { - return new Promise((resolve, reject) => { - fsModule.readFile(filePath, (err, content) => { - if (err) { - if (err.code === 'ENOENT') { - resolve(null); - return; - } - reject(err); - return; - } - - resolve(content); - }); - }); - } - - async function writeFileAsync(filePath, content) { - return new Promise((resolve, reject) => { - if (typeof fsModule.writeFile !== 'function') { - resolve(); - return; - } - - fsModule.writeFile(filePath, content, (err) => { - if (err) { - reject(err); - return; - } - - resolve(); - }); - }); - } - - async function readJsonIfExists(filePath) { - const content = await readFileIfExists(filePath); - if (!content) { - return null; - } - - try { - return JSON.parse(Buffer.isBuffer(content) ? content.toString('utf8') : String(content)); - } catch (err) { - return null; - } - } - - function normalizeSteamId(steamId) { - if (!steamId) { - return ''; - } - - if (typeof steamId === 'string') { - return steamId; - } - - if (typeof steamId.getSteamID64 === 'function') { - return steamId.getSteamID64(); - } - - return String(steamId); - } - - async function getMessageSenderName(friendId, echo) { - const senderId = echo ? normalizeSteamId(steamUser.steamID) : normalizeSteamId(friendId); - if (!senderId) { - return ''; - } - - const sender = await client.getUserInfo(senderId, () => {}); - return sender && sender.player_name ? sender.player_name : senderId; - } - - function appendOutgoingLog(uid, response) { - getMessageSenderName(uid, true).then((senderName) => { - appendLogEntry({ - type: 'message', - date: dateToString(response.server_timestamp), - echo: true, - id: uid, - name: senderName, - message: response.modified_message, - ordinal: response.ordinal, - }); - }).catch((err) => { - logger.error('failed to append outgoing log', { id: uid, error: err.message }); - }); - } - - async function appendOutgoingImageLog(uid, imageUrl) { - const entry = { - type: 'image', - date: dateToString(new Date()), - echo: true, - id: uid, - name: await getMessageSenderName(uid, true), - imageUrl, - ordinal: null, - sentAt: new Date().toISOString(), - }; - appendLogEntry(entry); - return entry; - } - - async function encodeSteamMessage(message, echo) { - const friendId = normalizeSteamId(message.steamid_friend); - - return { - type: 'message', - date: dateToString(message.server_timestamp), - echo, - id: friendId, - name: await getMessageSenderName(friendId, echo), - message: message.message, - ordinal: message.ordinal, - imageUrl: null, - sentAt: null, - }; - } - - function rememberSelfMessage(message) { - const key = buildMessageKey(message); - recentSelfMessages.set(key, Date.now() + MSG_DEDUP_TTL_MS); - const timer = setTimeout(() => { - recentSelfMessages.delete(key); - }, MSG_DEDUP_TTL_MS); - - if (typeof timer.unref === 'function') { - timer.unref(); - } - } - - function wasRecentlyBroadcasted(message) { - const key = buildMessageKey(message); - const expiresAt = recentSelfMessages.get(key); - if (!expiresAt) { - return false; - } - - if (expiresAt < Date.now()) { - recentSelfMessages.delete(key); - return false; - } - - return true; - } - - async function broadcastSteamMessage(message, echo, { dedupe = false } = {}) { - const data = await encodeSteamMessage(message, echo); - if (dedupe && wasRecentlyBroadcasted(data)) { - return; - } - if (dedupe && wasRecentImageEcho(data)) { - return; - } - - broadcastWs({ - type: 'message', - data, - }); - } - - async function getEmoticonList() { - await ensureWebSession(); - - const EMsg = require('steam-user/enums/EMsg'); - const msgKey = EMsg.ClientEmoticonList; - - function removeHandler(handler) { - const handlers = steamUser._handlerManager._handlers[msgKey]; - if (handlers) { - const idx = handlers.indexOf(handler); - if (idx !== -1) { - handlers.splice(idx, 1); - } - } - } - - const body = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - removeHandler(handler); - reject(new Error('getEmoticonList timed out')); - }, EMOTICON_LIST_TIMEOUT_MS); - if (typeof timeout.unref === 'function') { - timeout.unref(); - } - function handler(body) { - clearTimeout(timeout); - removeHandler(handler); - resolve(body); - } - steamUser._handlerManager.add(msgKey, handler); - steamUser._send(EMsg.ClientGetEmoticonList, {}); - }); - - const emoticons = (body.emoticons || []).map((e) => ({ - name: String(e.name || '').replace(/^:+|:+$/g, ''), - count: e.count, - use_count: e.use_count || 0, - time_last_used: e.time_last_used, - appid: e.appid, - })); - - const stickers = (body.stickers || []).map((s) => ({ - name: s.name, - count: s.count, - use_count: s.use_count || 0, - time_last_used: s.time_last_used, - appid: s.appid, - })); - - return { emoticons, stickers }; - } - - async function getFriendsList() { - await client.steamLoginPromise; - const myFriends = steamUser.myFriends || {}; - const friendIds = Object.keys(myFriends).filter((id) => myFriends[id] === 3); // EFriendRelationship.Friend - - const friends = []; - for (const id of friendIds) { - const cached = steamUser.users && steamUser.users[id]; - friends.push({ - id, - name: (cached && cached.player_name) || id, - avatar: (cached && cached.avatar_url_medium) || null, - status: cached ? (cached.persona_state || 0) : 0, - game: (cached && cached.game_name) || null, - }); - } - - friends.sort((a, b) => { - // Online users first (status > 0), then alphabetical - if (a.status > 0 && b.status === 0) return -1; - if (a.status === 0 && b.status > 0) return 1; - return (a.name || '').localeCompare(b.name || ''); - }); - - return friends; - } - - async function getGroupsList() { - await client.steamLoginPromise; - const myGroups = steamUser.myGroups || {}; - const groupIds = Object.keys(myGroups); - - const groups = []; - for (const id of groupIds) { - const cached = steamUser.groups && steamUser.groups[id]; - const nameInfo = cached && cached.name_info; - groups.push({ - id, - name: (nameInfo && nameInfo.clan_name) || id, - }); - } - - groups.sort((a, b) => (a.name || '').localeCompare(b.name || '')); - return groups; - } - - async function sendFriendMessage(uid, msg) { - if (!uid || typeof msg !== 'string') { - throw new Error('id and msg are required'); - } - - async function doSend() { - return new Promise((resolve, reject) => { - steamUser.chat.sendFriendMessage(uid, msg, (err, response) => { - if (err) { - reject(err); - return; - } - - appendOutgoingLog(uid, response); - - resolve({ - server_timestamp: response.server_timestamp, - steamid_friend: uid, - message: response.modified_message, - ordinal: response.ordinal, - }); - }); - }); - } - - try { - return await doSend(); - } catch (err) { - let sendError = err; - - if (isTransientNetworkError(sendError)) { - logger.warn('temporary network error while sending message, retrying once', { - id: uid, - error: sendError.message, - code: sendError.code || null, - }); - - try { - return await doSend(); - } catch (retryErr) { - sendError = retryErr; - } - } - - if (!isLikelyExpiredWebSessionError(sendError) && !isTransientNetworkError(sendError)) { - throw sendError; - } - - logger.warn('failed to send message, trying to refresh web session', { - id: uid, - error: sendError.message, - code: sendError.code || null, - }); - - try { - if (!pendingWebSessionRefresh) { - pendingWebSessionRefresh = (async () => { - steamUser.webLogOn(); - await waitForFreshWebSession(); - })(); - } - try { - await pendingWebSessionRefresh; - } finally { - pendingWebSessionRefresh = null; - } - return await doSend(); - } catch (retryErr) { - logger.error('an error occurred while sending message', retryErr); - throw retryErr; - } - } - } - - async function ensureWebSession() { - await client.steamLoginPromise; - await client.steamWebLoginPromise; - } - - function isTransientNetworkError(err) { - const code = String(err && err.code ? err.code : '').toUpperCase(); - const message = String(err && err.message ? err.message : '').toLowerCase(); - - if ([ - 'ECONNRESET', - 'ECONNABORTED', - 'ETIMEDOUT', - 'EPIPE', - 'EAI_AGAIN', - 'ENETUNREACH', - 'EHOSTUNREACH', - 'ECONNREFUSED', - ].includes(code)) { - return true; - } - - return message.includes('client network socket disconnected before secure tls connection was established') - || message.includes('socket disconnected before secure tls connection was established') - || message.includes('tls connection') - || message.includes('socket hang up'); - } - - function isLikelyExpiredWebSessionError(err) { - const code = String(err && err.code ? err.code : '').toUpperCase(); - const message = String(err && err.message ? err.message : '').toLowerCase(); - - if (code === 'ESESSIONEXPIRED' || code === 'EWEBSESSION') { - return true; - } - - return message.includes('session') - || message.includes('cookie') - || message.includes('not logged in') - || message.includes('access denied') - || message.includes('forbidden'); - } - - async function waitForFreshWebSession(timeoutMs = WS_SESSION_TIMEOUT_MS) { - const ac = new AbortController(); - let timer = null; - - try { - await Promise.race([ - onceFn(steamUser, 'webSession', { signal: ac.signal }).catch((err) => { - if (err.name !== 'AbortError') throw err; - // Swallow AbortError — timeout already handled the race - }), - new Promise((_, reject) => { - timer = setTimeout(() => { - ac.abort(); - reject(new Error(`Timed out after ${timeoutMs}ms while waiting for Steam web session`)); - }, timeoutMs); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - ac.abort(); - } - } - - async function readUrlAsBuffer(url) { - try { - const response = await axiosInstance.get(url, { responseType: 'arraybuffer' }); - return Buffer.from(response.data); - } catch (err) { - const error = new Error(`Failed to fetch URL: ${err.message}`); - error.code = 400; - throw error; - } - } - - async function parseImageBuffer({ img, url }) { - if (url) { - return readUrlAsBuffer(url); - } - - if (img) { - const normalized = String(img).includes(',') ? String(img).split(',').pop() : String(img); - return Buffer.from(normalized, 'base64'); - } - - const error = new Error('img or url is required'); - error.code = 400; - throw error; - } - - function uploadImageToUser(uid, imageBuffer) { - return new Promise((resolve, reject) => { - steamCommunity.sendImageToUser(uid, imageBuffer, (err, imageUrl) => { - if (err) { - reject(err); - return; - } - - resolve(imageUrl); - }); - }); - } - - async function sendImageToUser(uid, img, url) { - if (!uid) { - const error = new Error('id is required'); - error.code = 400; - throw error; - } - - await ensureWebSession(); - - const imageBuffer = await parseImageBuffer({ img, url }); - - try { - return await uploadImageToUser(uid, imageBuffer); - } catch (err) { - let uploadError = err; - - if (isTransientNetworkError(uploadError)) { - logger.warn('temporary network error while sending image, retrying once', { - id: uid, - error: uploadError.message, - code: uploadError.code || null, - }); - - try { - return await uploadImageToUser(uid, imageBuffer); - } catch (retryErr) { - uploadError = retryErr; - } - } - - if (!isLikelyExpiredWebSessionError(uploadError) && !isTransientNetworkError(uploadError)) { - logger.error('an error occurred while sending image', uploadError); - throw uploadError; - } - - logger.warn('failed to send image, trying to refresh web session', { - id: uid, - error: uploadError.message, - code: uploadError.code || null, - }); - - try { - if (!pendingWebSessionRefresh) { - pendingWebSessionRefresh = (async () => { - steamUser.webLogOn(); - await waitForFreshWebSession(); - })(); - } - try { - await pendingWebSessionRefresh; - } finally { - pendingWebSessionRefresh = null; - } - return await uploadImageToUser(uid, imageBuffer); - } catch (retryErr) { - logger.error('an error occurred while sending image', retryErr); - throw retryErr; - } - } - } - - function parseLogLines(content) { - return String(content || '') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - try { - return normalizeHistoryEntry(JSON.parse(line)); - } catch (err) { - logger.warn('skip invalid chat log line', { line }); - return null; - } - }) - .filter(Boolean); - } - - async function readChatHistory({ id, limit } = {}) { - const maxItems = sanitizeLimit(limit, HISTORY_DEFAULT_LIMIT); - const items = []; - - try { - const rl = readline.createInterface({ - input: fsModule.createReadStream(CHAT_LOG_FILE, { encoding: 'utf8' }), - crlfDelay: Infinity, - }); - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; - - try { - const entry = normalizeHistoryEntry(JSON.parse(trimmed)); - if (entry && (!id || entry.id === id)) { - items.push(entry); - } - } catch (parseErr) { - logger.warn('skip invalid chat log line', { line: trimmed }); - } - } - } catch (err) { - if (err && err.code === 'ENOENT') { - return []; - } - if (err && err.message && err.message.includes('ENOENT')) { - return []; - } - throw err; - } - - if (items.length > maxItems) { - items.splice(0, items.length - maxItems); - } - - return sortHistoryItems(items); - } - - async function readConversationSummaries({ limit } = {}) { - const items = await readChatHistory({ limit: sanitizeLimit(limit, HISTORY_MAX_LIMIT) }); - const summaries = buildConversationSummaries(items); - - for (const summary of summaries) { - if (!summary.name && summary.id) { - try { - const friend = await client.getUserInfo(summary.id, () => {}); - summary.name = friend.player_name || summary.id; - } catch (err) { - summary.name = summary.id; - } - } - } - - return summaries; - } - - async function fetchStickerBuffer(type) { - const normalizedType = String(type || '').trim(); - const cachePath = buildStickerCachePath(normalizedType); - const cached = await readFileIfExists(cachePath); - if (cached) { - return cached; - } - - const pendingFetch = pendingStickerFetches.get(normalizedType); - if (pendingFetch) { - return pendingFetch; - } - - const urls = buildSteamStickerCandidateUrls(normalizedType); - let lastError = null; - const fetchPromise = (async () => { - for (const url of urls) { - try { - const response = await axiosInstance.get(url, { responseType: 'arraybuffer' }); - const buffer = Buffer.from(response.data); - if (!buffer.length) { - continue; - } - - try { - await writeFileAsync(cachePath, buffer); - } catch (writeErr) { - logger.warn('failed to write sticker cache', { type: normalizedType, error: writeErr.message }); - } - - return buffer; - } catch (err) { - lastError = err; - } - } - - const error = new Error(`Failed to fetch sticker: ${normalizedType}`); - error.code = 404; - error.cause = lastError; - throw error; - })(); - - pendingStickerFetches.set(normalizedType, fetchPromise); - - try { - return await fetchPromise; - } finally { - if (pendingStickerFetches.get(normalizedType) === fetchPromise) { - pendingStickerFetches.delete(normalizedType); - } - } - } - - async function fetchCachedImage(url) { - const normalizedUrl = String(url || '').trim(); - if (!/^https?:\/\//i.test(normalizedUrl)) { - const error = new Error('Invalid image URL'); - error.code = 400; - throw error; - } - - // SSRF protection: reject private/internal addresses - let parsedUrl; - try { - parsedUrl = new URL(normalizedUrl); - } catch (_) { - const error = new Error('Invalid image URL'); - error.code = 400; - throw error; - } - const hostname = parsedUrl.hostname; - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === '0.0.0.0') { - const error = new Error('Invalid image URL'); - error.code = 400; - throw error; - } - if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) && isLanIp(hostname)) { - const error = new Error('Invalid image URL'); - error.code = 400; - throw error; - } - - const { dataPath, metaPath } = buildImageCachePaths(normalizedUrl); - const cachedData = await readFileIfExists(dataPath); - if (cachedData) { - const cachedMeta = await readJsonIfExists(metaPath); - return { - buffer: cachedData, - contentType: (cachedMeta && cachedMeta.contentType) || guessImageContentType(normalizedUrl), - }; - } - - const pendingFetch = pendingImageFetches.get(normalizedUrl); - if (pendingFetch) { - return pendingFetch; - } - - const fetchPromise = (async () => { - const response = await axiosInstance.get(normalizedUrl, { responseType: 'arraybuffer' }); - const buffer = Buffer.from(response.data); - const contentType = guessImageContentType( - normalizedUrl, - response.headers && response.headers['content-type'] ? response.headers['content-type'] : 'image/png', - ); - - try { - await writeFileAsync(dataPath, buffer); - await writeFileAsync(metaPath, JSON.stringify({ contentType })); - } catch (err) { - logger.warn('failed to cache image', { url: normalizedUrl, error: err.message }); - } - - return { - buffer, - contentType, - }; - })(); - - pendingImageFetches.set(normalizedUrl, fetchPromise); - - try { - return await fetchPromise; - } finally { - if (pendingImageFetches.get(normalizedUrl) === fetchPromise) { - pendingImageFetches.delete(normalizedUrl); - } - } - } - - async function handleStickerProxy(req, res, type) { - try { - const buffer = await fetchStickerBuffer(type); - res.statusCode = 200; - res.setHeader('Content-Type', 'image/png'); - res.setHeader('Content-Length', buffer.length); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.end(buffer); - } catch (err) { - logger.warn('failed to proxy sticker', { type, error: err.message }); - sendJson(res, err.code || 404, { error: err.message || 'Sticker Not Found' }); - } - } - - async function handleImageProxy(req, res, url) { - try { - const image = await fetchCachedImage(url); - res.statusCode = 200; - res.setHeader('Content-Type', image.contentType); - res.setHeader('Content-Length', image.buffer.length); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.end(image.buffer); - } catch (err) { - logger.warn('failed to proxy image', { url, error: err.message }); - sendJson(res, err.code || 404, { error: err.message || 'Image Not Found' }); - } - } - - async function handleSendMessageRequest(payload) { - const message = await sendFriendMessage(payload.id, payload.msg); - const encoded = await encodeSteamMessage(message, true); - rememberSelfMessage(encoded); - - broadcastWs({ - type: 'message', - data: encoded, - }); - - return encoded; - } - - function rememberSelfImageUrl(uid, imageUrl) { - const urlKey = `${uid}:${imageUrl}`; - const uidKey = `img:${uid}`; - const expiresAt = Date.now() + MSG_DEDUP_TTL_MS; - - recentSelfImageUrls.set(urlKey, expiresAt); - recentSelfImageUrls.set(uidKey, expiresAt); - - const timer = setTimeout(() => { - recentSelfImageUrls.delete(urlKey); - // Only delete uidKey if it hasn't been refreshed by a newer call - if (recentSelfImageUrls.get(uidKey) === expiresAt) { - recentSelfImageUrls.delete(uidKey); - } - }, MSG_DEDUP_TTL_MS); - - if (typeof timer.unref === 'function') { - timer.unref(); - } - } - - function wasRecentImageEcho(data) { - // Direct match: message text is exactly the remembered image URL - const directKey = `${data.id}:${data.message}`; - const directExpiry = recentSelfImageUrls.get(directKey); - if (directExpiry && directExpiry >= Date.now()) { - return true; - } - if (directExpiry) { - recentSelfImageUrls.delete(directKey); - } - - // Extract image URLs from the message and check each one, - // because Steam may echo the URL wrapped in BBCode like - // [img src=URL ...]...[/img] or [img]URL[/img]. - const urls = extractImageUrls(data.message); - for (const url of urls) { - const key = `${data.id}:${url}`; - const expiresAt = recentSelfImageUrls.get(key); - if (!expiresAt) { - continue; - } - if (expiresAt < Date.now()) { - recentSelfImageUrls.delete(key); - continue; - } - return true; - } - - // Fallback: if we recently sent any image to this uid and - // the echo message contains any URL or image BBCode, suppress - // it even if the exact URL didn't match (Steam may rewrite - // the URL or use a host without a file extension). - const messageText = String(data.message || ''); - const looksLikeImageEcho = urls.length > 0 - || /https?:\/\/\S*(?:image|img|ugc|media|cdn)\S*/i.test(messageText) - || /\[img[\s\]]/i.test(messageText); - if (looksLikeImageEcho) { - const uidKey = `img:${data.id}`; - const uidExpiry = recentSelfImageUrls.get(uidKey); - if (uidExpiry && uidExpiry >= Date.now()) { - return true; - } - if (uidExpiry) { - recentSelfImageUrls.delete(uidKey); - } - } - - return false; - } - - async function handleSendImageRequest(payload, { senderWs } = {}) { - const imageUrl = await sendImageToUser(payload.id, payload.img, payload.url); - const data = await appendOutgoingImageLog(payload.id, imageUrl); - - // Remember the image URL so the friendMessageEcho (which echoes - // the image URL as a text message) gets deduplicated. - rememberSelfImageUrl(payload.id, imageUrl); - - // Broadcast to all clients except the sender (who gets image_sent). - const encoded = JSON.stringify({ type: 'image', data }); - wss.clients.forEach((ws) => { - if (ws !== senderWs && ws.readyState === WebSocketImpl.OPEN) { - ws.send(encoded); - } - }); - - return data; - } - - async function handleHttp(req, res) { - const requestUrl = new URL(req.url, 'http://127.0.0.1'); - - if (requiresHttpAuth(req, chatConfig) && !isAuthorized(req, chatConfig.auth)) { - sendAuthRequired(res); - return; - } - - if (req.method === 'GET' && requestUrl.pathname === '/') { - serveStaticFile(res, 'index.html'); - return; - } - - if (req.method === 'GET' && requestUrl.pathname === '/api/config') { - sendJson(res, 200, { wsPath: chatConfig.wsPath }); - return; - } - - if (req.method === 'GET' && requestUrl.pathname === '/api/emoticons') { - try { - const data = await getEmoticonList(); - sendJson(res, 200, data); - } catch (err) { - logger.error('failed to get emoticon list', err); - sendJson(res, err.code || 500, { error: err.message || 'Internal Server Error' }); - } - return; - } - - if (req.method === 'GET' && requestUrl.pathname.startsWith('/proxy/sticker/')) { - const type = decodeURIComponent(requestUrl.pathname.slice('/proxy/sticker/'.length)); - await handleStickerProxy(req, res, type); - return; - } - - if (req.method === 'GET' && requestUrl.pathname === '/proxy/image') { - await handleImageProxy(req, res, requestUrl.searchParams.get('url') || ''); - return; - } - - if (req.method === 'GET' && requestUrl.pathname === '/history') { - try { - const items = await readChatHistory({ - id: requestUrl.searchParams.get('id') || undefined, - limit: requestUrl.searchParams.get('limit') || undefined, - }); - sendJson(res, 200, { items }); - } catch (err) { - logger.error('failed to read chat history', err); - sendJson(res, 500, { error: err.message || 'Internal Server Error' }); - } - return; - } - - if (req.method === 'GET' && requestUrl.pathname === '/conversations') { - try { - const items = await readConversationSummaries({ - limit: requestUrl.searchParams.get('limit') || undefined, - }); - sendJson(res, 200, { items }); - } catch (err) { - logger.error('failed to read conversations', err); - sendJson(res, 500, { error: err.message || 'Internal Server Error' }); - } - return; - } - - if (req.method === 'GET' && requestUrl.pathname === '/api/friends') { - try { - const items = await getFriendsList(); - sendJson(res, 200, { items }); - } catch (err) { - logger.error('failed to get friends list', err); - sendJson(res, 500, { error: err.message || 'Internal Server Error' }); - } - return; - } - - if (req.method === 'GET' && requestUrl.pathname === '/api/groups') { - try { - const items = await getGroupsList(); - sendJson(res, 200, { items }); - } catch (err) { - logger.error('failed to get groups list', err); - sendJson(res, 500, { error: err.message || 'Internal Server Error' }); - } - return; - } - - if (req.method === 'GET') { - const ext = path.extname(requestUrl.pathname); - if (ext && STATIC_CONTENT_TYPES[ext]) { - serveStaticFile(res, requestUrl.pathname); - return; - } - } - - if (req.method !== 'POST') { - sendJson(res, 404, { error: 'Not Found' }); - return; - } - - try { - const payload = await readJsonBody(req); - - if (requestUrl.pathname === '/img' || requestUrl.pathname === '/image') { - const data = await handleSendImageRequest(payload); - sendJson(res, 200, data); - return; - } - - if (requestUrl.pathname === '/' || requestUrl.pathname === '/message') { - const data = await handleSendMessageRequest(payload); - sendJson(res, 200, data); - return; - } - - sendJson(res, 404, { error: 'Not Found' }); - } catch (err) { - logger.error('An error occurred while processing the request', err); - sendJson(res, err.code || 500, { error: err.message || 'Internal Server Error' }); - } - } - - async function handleWsCommand(ws, payload) { - const request = normalizeWsRequest(payload); - - switch (request.action) { - case 'send_message': { - const data = await handleSendMessageRequest(request); - sendWs(ws, { - type: 'message_sent', - requestId: request.requestId, - data, - }); - return; - } - case 'send_image': { - const data = await handleSendImageRequest(request, { senderWs: ws }); - sendWs(ws, { - type: 'image_sent', - requestId: request.requestId, - data, - }); - return; - } - case 'get_history': { - const items = await readChatHistory({ - id: request.id, - limit: request.limit, - }); - sendWs(ws, { - type: 'history', - requestId: request.requestId, - data: { - items, - }, - }); - return; - } - case 'get_conversations': { - const items = await readConversationSummaries({ - limit: request.limit, - }); - sendWs(ws, { - type: 'conversations', - requestId: request.requestId, - data: { - items, - }, - }); - return; - } - case 'get_emoticons': { - const emoticonData = await getEmoticonList(); - sendWs(ws, { - type: 'emoticons', - requestId: request.requestId, - data: emoticonData, - }); - return; - } - case 'get_friends': { - const items = await getFriendsList(); - sendWs(ws, { - type: 'friends', - requestId: request.requestId, - data: { items }, - }); - return; - } - case 'get_groups': { - const items = await getGroupsList(); - sendWs(ws, { - type: 'groups', - requestId: request.requestId, - data: { items }, - }); - return; - } - case 'ping': - sendWs(ws, { - type: 'pong', - requestId: request.requestId, - data: { - now: new Date().toISOString(), - }, - }); - return; - default: { - const error = new Error(`Unsupported WebSocket message type: ${payload.type}`); - error.code = 400; - throw error; - } - } - } - - function handleWs(ws) { - const MAX_WS_CONNECTIONS = 100; - if (wss.clients.size >= MAX_WS_CONNECTIONS) { - ws.close(1013, 'Too many connections'); - logger.warn('WebSocket connection rejected: too many connections', { - max: MAX_WS_CONNECTIONS, - }); - return; - } - logger.info('WebSocket connection established'); - sendWs(ws, { - type: 'ready', - data: { - wsPath: chatConfig.wsPath, - }, - }); - - ws.on('message', async (message) => { - let payload; - try { - payload = JSON.parse(message.toString()); - } catch (err) { - sendWs(ws, { - type: 'error', - message: 'Invalid JSON', - }); - return; - } - - try { - await handleWsCommand(ws, payload); - } catch (err) { - logger.error('WebSocket command failed', err); - sendWs(ws, { - type: 'error', - requestId: payload.requestId, - message: err.message || 'Internal Server Error', - }); - } - }); - - ws.on('close', () => { - logger.info('WebSocket connection closed'); - }); - - ws.on('error', (err) => { - logger.error('WebSocket error', err); - }); - } - - async function start() { - if (started || !chatConfig.enabled) { - return; - } - started = true; - - try { - await client.steamLoginPromise; - - steamUser.chat.on('friendMessage', (message) => { - broadcastSteamMessage(message, false).catch((err) => { - logger.error('failed to broadcast friend message', err); - }); - }); - - steamUser.chat.on('friendMessageEcho', (message) => { - broadcastSteamMessage(message, true, { dedupe: true }).catch((err) => { - logger.error('failed to broadcast echoed friend message', err); - }); - }); - - wss.on('connection', handleWs); - - await new Promise((resolve, reject) => { - server.listen(chatConfig.port, chatConfig.host, (err) => { - if (err) { - reject(err); - return; - } - - logger.info('chat server started', { - host: chatConfig.host, - port: chatConfig.port, - wsPath: chatConfig.wsPath, - }); - resolve(); - }); - }); - } catch (err) { - started = false; - logger.error('chat service failed to start', err); - throw err; - } - } - - return { - chatConfig, - server, - wss, - start, - sendFriendMessage, - sendImageToUser, - getEmoticonList, - getFriendsList, - getGroupsList, - readChatHistory, - readConversationSummaries, - fetchStickerBuffer, - fetchCachedImage, - handleSendMessageRequest, - handleSendImageRequest, - handleHttp, - handleWs, - handleWsCommand, - broadcastSteamMessage, - encodeSteamMessage, - parseImageBuffer, - parseLogLines, - readJsonBody, - wasRecentlyBroadcasted, - rememberSelfMessage, - sendWs, - broadcastWs, - }; -} - -let defaultChatService = null; - -if (!process.env.STEAM_CHAT_DISABLE_AUTOSTART) { - defaultChatService = createChatService(); - defaultChatService.start().catch((err) => { - console.error('Error during chat service initialization', err); - }); -} - -module.exports = { - CHAT_LOG_FILE, - STICKER_CACHE_DIR, - IMAGE_CACHE_DIR, - createChatService, - normalizeAuthConfig, - normalizeChatConfig, - normalizeHistoryEntry, - normalizeWsRequest, - normalizeIpAddress, - parseForwardedHeader, - getClientIp, - isLanIp, - isAuthEnabled, - parseBasicAuthHeader, - isAuthorized, - requiresHttpAuth, - sanitizeLimit, - extractStickerType, - extractEmoticonNames, - extractImageUrls, - extractOpenGraphEmbeds, - buildSteamEmoticonUrl, - buildSteamStickerCandidateUrls, - buildStickerCachePath, - buildImageCachePaths, - guessImageContentType, - buildConversationPreview, - sortHistoryItems, - buildConversationSummaries, - buildMessageKey, - defaultChatService, -}; diff --git a/client.js b/client.js deleted file mode 100644 index de192e8..0000000 --- a/client.js +++ /dev/null @@ -1,68 +0,0 @@ -const SteamUser = require('steam-user'); -const SteamCommunity = require('steamcommunity'); -const winston = require("winston"); -const config = require("./config.js"); -const { createSteamLifecycle } = require('./steam-lifecycle'); - -const logger = winston.createLogger({ - level: 'info', - format: winston.format.json(), - defaultMeta: { service: 'steam-logger' }, - transports: [ - new winston.transports.Console(), - ] -}); - -const users = {}; - -const steamUser = new SteamUser(); -const steamCommunity = new SteamCommunity(); - -const { - steamLoginPromise, - steamWebLoginPromise, -} = createSteamLifecycle({ - steamUser, - steamCommunity, - logger, - config, -}); - -async function getUserInfo(steamID, onUserInfoReceived) { - if (typeof steamID !== 'string') { - steamID = steamID.getSteamID64(); - } - let sender = users[steamID]; - // if user is not cached - if (!sender) { - try { - let personasResult = await steamUser.getPersonas([steamID]); - users[steamID] = personasResult.personas[steamID]; - sender = users[steamID]; - - if (onUserInfoReceived) { - onUserInfoReceived(sender); - } - - // noinspection ES6MissingAwait - logger.info("user data received: " + JSON.stringify(sender)); - } catch (err) { - logger.error("an error occurred while getting user data: ", err); - sender = { - player_name: "Unknown", - }; - } - } - return sender; -} - -module.exports = { - logger: logger, - steamUser: steamUser, - steamCommunity: steamCommunity, - getUserInfo: getUserInfo, - steamLoginPromise: steamLoginPromise, - steamWebLoginPromise: steamWebLoginPromise, -} - -require('./logger'); diff --git a/config.example.js b/config.example.js index 4925734..fdaa78e 100644 --- a/config.example.js +++ b/config.example.js @@ -1,24 +1,19 @@ -function getRandomInt(min, max) { - min = Math.ceil(min); - max = Math.floor(max); - return Math.floor(Math.random() * (max - min)) + min; -} - module.exports = { - accountName: 'accountName', - password: 'password', - logonID: getRandomInt(1000000, 999999999), - steamID: "xxxxxxxxxx", - chat: { - enabled: false, - host: '0.0.0.0', - port: 3000, - wsPath: '/ws', - auth: { - username: 'admin', - password: 'change-me', - realm: 'Steam Chat', - trustProxy: false, - }, - }, + accountName: 'your_steam_login_name', + password: 'your_steam_password', + logonID: Math.floor(Math.random() * 0x7fffffff), + steamID: '', + identitySecret: '', + chat: { + enabled: true, + host: '0.0.0.0', + port: 3000, + wsPath: '/ws', + auth: { + username: '', + password: '', + realm: 'Steam Chat', + trustProxy: false + } + } }; diff --git a/logger.js b/logger.js deleted file mode 100644 index 7562110..0000000 --- a/logger.js +++ /dev/null @@ -1,111 +0,0 @@ -const fs = require('fs'); -const config = require('./config.js'); - -const dateformat = require('@matteo.collina/dateformat'); - -const client = require("./client.js") -const steamUser = client.steamUser - -const logger = client.logger - -fs.mkdir("./logs", { recursive: true }, (err) => { - if (err) { - logger.error("an error occurred while creating the logs directory: " + err); - } -}); - -/** - * @param {string} date - * @param {SteamID} steamID - * @param {string} message - * @param {boolean} echo - * @param {number} ordinal - * @returns {Promise} - */ -async function logMessage(date, steamID, message, echo, ordinal) { - // try to get chat history - await getUserInfo(steamID); - - let sender = await getUserInfo(echo ? steamUser.steamID : steamID); - logger.info("log steam chat message", { - echo: echo, - id: steamID.getSteamID64(), - name: sender.player_name, - message: message, - ordinal: ordinal, - }); - fs.appendFile("./logs/chat.jsonl", JSON.stringify({ - date: date, - echo: echo, - id: steamID.getSteamID64(), - name: sender.player_name, - message: message, - ordinal: ordinal, - }) + "\n", (e) => { - if (e) { - logger.error("an error occurred while writing chat log file: " + e); - } - }); -} - -client.steamLoginPromise.then(() => { - steamUser.chat.on("friendMessage", (message) => { - logMessage(dateToString(message.server_timestamp), message.steamid_friend, message.message, false, message.ordinal) - .catch((err) => logger.error('failed to log friend message', err)); - }); - - steamUser.chat.on("friendMessageEcho", (message) => { - logMessage(dateToString(message.server_timestamp), message.steamid_friend, message.message, true, message.ordinal) - .catch((err) => logger.error('failed to log echoed friend message', err)); - }); -}); - -/** - * @param {Date} date - * @returns {string} - */ -function dateToString(date) { - return dateformat(date, "yyyy-mm-dd HH:MM:ss.l"); -} - -/** - * @param {SteamID} steamID - * @returns {Promise<{player_name: string}>} - */ -async function getUserInfo(steamID) { - return client.getUserInfo(steamID, (ignore) => { - importChatHistory(steamID); - }); -} - -function importChatHistory(steamID) { - if (steamID === steamUser.steamID) { - return; - } - - steamUser.chat.getFriendMessageHistory(steamID.getSteamID64(), (err, messages) => { - if (err) { - logger.error("an error occurred while getting chat history: ", err); - return - } - - for (let message of messages.messages) { - logger.info("get chat history", message); - logMessage( - dateToString(message.server_timestamp), - steamID, message.message, - message.sender.getSteamID64() === steamUser.steamID.getSteamID64(), - message.ordinal, - ).catch((err) => logger.error("failed to log imported chat history", err)); - } - }); -} - -if (config.chat) { - require("./chat.js") -} - -module.exports = { - steamUser: steamUser, - getUserInfo: getUserInfo, -} diff --git a/package-lock.json b/package-lock.json index f5a4d9f..3033ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,25 +7,22 @@ "": { "name": "steam-chat", "version": "1.0.0", - "license": "ISC", + "license": "GPL-3.0", "dependencies": { - "@matteo.collina/dateformat": "^5.0.1", - "axios": "^1.7.7", - "steam-totp": "^2.1.2", "steam-user": "^5.0.8", "steamcommunity": "^3.48.2", "winston": "^3.12.0", "ws": "^8.18.1" }, "devDependencies": { - "@types/steam-totp": "^2.1.2", - "@types/steam-user": "^5.1.0", - "@types/steamcommunity": "^3.43.8" + "@types/node": "^26.0.0", + "@types/ws": "^8.18.1", + "typescript": "^6.0.3" } }, "node_modules/@bbob/parser": { "version": "2.9.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@bbob/parser/-/parser-2.9.0.tgz", + "resolved": "https://registry.npmjs.org/@bbob/parser/-/parser-2.9.0.tgz", "integrity": "sha512-tldSYsMoEclke/B1nqL7+HbYMWZHTKvpbEHRSHuY+sZvS1o7Jpdfjb+KPpwP9wLI3p3r7GPv69/wGy+Xibs9yA==", "license": "MIT", "dependencies": { @@ -34,13 +31,13 @@ }, "node_modules/@bbob/plugin-helper": { "version": "2.9.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@bbob/plugin-helper/-/plugin-helper-2.9.0.tgz", + "resolved": "https://registry.npmjs.org/@bbob/plugin-helper/-/plugin-helper-2.9.0.tgz", "integrity": "sha512-idpUcNQ2co6T1oU/7/DG/ZRfipSSkTn9Ozw9f5vaXH7nzV3qhqZnhFVlHTzGGnRlzKlBwWOBzOdWi4Zeqg1c5A==", "license": "MIT" }, "node_modules/@colors/colors": { "version": "1.6.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@colors/colors/-/colors-1.6.0.tgz", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", "license": "MIT", "engines": { @@ -49,7 +46,7 @@ }, "node_modules/@dabh/diagnostics": { "version": "2.0.8", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", "license": "MIT", "dependencies": { @@ -60,7 +57,7 @@ }, "node_modules/@doctormckay/stats-reporter": { "version": "1.0.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@doctormckay/stats-reporter/-/stats-reporter-1.0.5.tgz", + "resolved": "https://registry.npmjs.org/@doctormckay/stats-reporter/-/stats-reporter-1.0.5.tgz", "integrity": "sha512-lCAuKW053zz91sKZZcGfOHxigBqn0Lo+/JvHBQq3XqzLJxn0YeZ5mJ96+PZto+PDCkgg+c/BX2Xo8DvAN44xLg==", "license": "MIT", "engines": { @@ -69,7 +66,7 @@ }, "node_modules/@doctormckay/stdlib": { "version": "2.10.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@doctormckay/stdlib/-/stdlib-2.10.0.tgz", + "resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-2.10.0.tgz", "integrity": "sha512-bwy+gPn6oa2KTpfxJKX3leZoV/wHDVtO0/gq3usPvqPswG//dcf3jVB8LcbRRsKO3BXCt5DqctOQ+Xb07ivxnw==", "license": "MIT", "dependencies": { @@ -81,89 +78,82 @@ }, "node_modules/@doctormckay/steam-crypto": { "version": "1.2.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@doctormckay/steam-crypto/-/steam-crypto-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/@doctormckay/steam-crypto/-/steam-crypto-1.2.0.tgz", "integrity": "sha512-lsxgLw640gEdZBOXpVIcYWcYD+V+QbtEsMPzRvjmjz2XXKc7QeEMyHL07yOFRmay+cUwO4ObKTJO0dSInEuq5g==", "license": "MIT" }, "node_modules/@doctormckay/user-agents": { "version": "1.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@doctormckay/user-agents/-/user-agents-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/@doctormckay/user-agents/-/user-agents-1.0.0.tgz", "integrity": "sha512-F+sL1YmebZTY2CnjoR9BXFEULpq7y8dxyLx48LZVa0BSDseXdLG/DtPISfM1iNv1XKCeiBzVNfAT/MOQ69v1Zw==", "license": "MIT" }, - "node_modules/@matteo.collina/dateformat": { - "version": "5.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@matteo.collina/dateformat/-/dateformat-5.0.1.tgz", - "integrity": "sha512-BxOmQxcfZxoo+qxI+/lQ28aoh1IpKAiuYLk7sIiCiqOXY9cWfMOwcyJY1xFWPQdHzYlMeeRo0oVABkB3b0ueWw==", - "license": "MIT" - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/base64/-/base64-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/float/-/float-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/path/-/path-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/pool/-/pool-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", "license": "MIT", "dependencies": { @@ -171,122 +161,41 @@ "text-hex": "1.0.x" } }, - "node_modules/@types/bytebuffer": { - "version": "5.0.49", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/bytebuffer/-/bytebuffer-5.0.49.tgz", - "integrity": "sha512-lV4YLiolMdD4upDmr4vnfiwV/FN9Jg33eNTSFMkHqyMKqTIAn5TmFTYsARwXwUXeFU7yzRpECmWV1SOhIvdkRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/long": "^3.0.0", - "@types/node": "*" - } - }, - "node_modules/@types/caseless": { - "version": "0.12.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/caseless/-/caseless-0.12.5.tgz", - "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/file-manager": { - "version": "2.0.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/file-manager/-/file-manager-2.0.3.tgz", - "integrity": "sha512-gEq6de+iMbjmkL3Wj183JQ651h9b331wQ3svXp2K6RkgBg+TtiyIZTMols/a3UtXllVV9kSelz/N3Rgh0n6S4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/long": { - "version": "3.0.32", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/long/-/long-3.0.32.tgz", - "integrity": "sha512-ZXyOOm83p7X8p3s0IYM3VeueNmHpkk/yMlP8CLeOnEcu6hIwPH7YjZBvhQkR0ZFS2DqZAxKtJ/M5fcuv3OU5BA==", - "dev": true, + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~8.3.0" } }, - "node_modules/@types/request": { - "version": "2.48.13", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/request/-/request-2.48.13.tgz", - "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/caseless": "*", - "@types/node": "*", - "@types/tough-cookie": "*", - "form-data": "^2.5.5" - } + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" }, - "node_modules/@types/steam-totp": { - "version": "2.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/steam-totp/-/steam-totp-2.1.2.tgz", - "integrity": "sha512-XNg9/PkSgbAVy58O3vgSermXU7YNJCX7Cmqx4aNE8CE6sI3m0vm7HBXgSj9eLJjEUfMP1vg64LKP65bj0Jpc5g==", + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, - "node_modules/@types/steam-user": { - "version": "5.1.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/steam-user/-/steam-user-5.1.1.tgz", - "integrity": "sha512-jYAsDpp30eC+/EWwG9Ea2qyGuKBWsTXgl7qVUiAMP3YtsCJi4/rCj0w8W8167RdhrkY7FhSC0OScQt5fYK8cGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/bytebuffer": "*", - "@types/file-manager": "*", - "@types/node": "*", - "@types/steamid": "*" - } - }, - "node_modules/@types/steamcommunity": { - "version": "3.43.8", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/steamcommunity/-/steamcommunity-3.43.8.tgz", - "integrity": "sha512-q742mcnxjiT3t0TyjhoTEpzmy1JI8L/p7nvvPBwNdArgjgb/5n5aWgKK8RavKIizAJ7AgjyEEfJNBY2ax8kFNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/request": "*", - "@types/steamid": "*" - } - }, - "node_modules/@types/steamid": { - "version": "2.0.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/steamid/-/steamid-2.0.4.tgz", - "integrity": "sha512-LC0oaiNq3gqMI18MV935CnlyHmDooKRszK56jHv0+b7EbJwf5k++YsD52zWeJUD8nuYqP9LpIgpXGiYBOxpcYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" - }, "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", "license": "MIT", "engines": { "node": ">=12.0" @@ -294,7 +203,7 @@ }, "node_modules/agent-base": { "version": "6.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/agent-base/-/agent-base-6.0.2.tgz", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", "dependencies": { @@ -305,9 +214,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -322,7 +231,7 @@ }, "node_modules/asn1": { "version": "0.2.6", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/asn1/-/asn1-0.2.6.tgz", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", "license": "MIT", "dependencies": { @@ -331,7 +240,7 @@ }, "node_modules/assert-plus": { "version": "1.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/assert-plus/-/assert-plus-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "license": "MIT", "engines": { @@ -340,7 +249,7 @@ }, "node_modules/async": { "version": "2.6.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/async/-/async-2.6.4.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", "license": "MIT", "dependencies": { @@ -349,13 +258,13 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/asynckit/-/asynckit-0.4.0.tgz", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/aws-sign2": { "version": "0.7.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/aws-sign2/-/aws-sign2-0.7.0.tgz", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", "license": "Apache-2.0", "engines": { @@ -364,40 +273,13 @@ }, "node_modules/aws4": { "version": "1.13.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/aws4/-/aws4-1.13.2.tgz", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "license": "MIT" }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "license": "BSD-3-Clause", "dependencies": { @@ -406,7 +288,7 @@ }, "node_modules/binarykvparser": { "version": "2.3.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/binarykvparser/-/binarykvparser-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/binarykvparser/-/binarykvparser-2.3.0.tgz", "integrity": "sha512-B1N5ZxC8I9oSLis7Rg36DxsZJoIikUGU2XwpI0FKFCaPIJIEYi0B9UeIk3QU006axzq0TI9KC3iXelfGGgnWew==", "bundleDependencies": [ "long" @@ -418,13 +300,13 @@ }, "node_modules/boolbase": { "version": "1.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/boolbase/-/boolbase-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, "node_modules/bytebuffer": { "version": "5.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/bytebuffer/-/bytebuffer-5.0.1.tgz", + "resolved": "https://registry.npmjs.org/bytebuffer/-/bytebuffer-5.0.1.tgz", "integrity": "sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ==", "license": "Apache-2.0", "dependencies": { @@ -434,28 +316,15 @@ "node": ">=0.8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/caseless": { "version": "0.12.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/caseless/-/caseless-0.12.0.tgz", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "license": "Apache-2.0" }, "node_modules/cheerio": { "version": "0.22.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/cheerio/-/cheerio-0.22.0.tgz", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz", "integrity": "sha512-8/MzidM6G/TgRelkzDG13y3Y9LxBjCb+8yOEZ9+wwq5gVF2w2pV0wmHvjfT0RvuxGyR7UEuK36r+yYMbT4uKgA==", "license": "MIT", "dependencies": { @@ -482,7 +351,7 @@ }, "node_modules/color": { "version": "5.0.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/color/-/color-5.0.3.tgz", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", "license": "MIT", "dependencies": { @@ -495,7 +364,7 @@ }, "node_modules/color-convert": { "version": "3.1.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/color-convert/-/color-convert-3.1.3.tgz", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", "license": "MIT", "dependencies": { @@ -507,7 +376,7 @@ }, "node_modules/color-name": { "version": "2.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/color-name/-/color-name-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", "license": "MIT", "engines": { @@ -516,7 +385,7 @@ }, "node_modules/color-string": { "version": "2.1.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/color-string/-/color-string-2.1.4.tgz", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", "license": "MIT", "dependencies": { @@ -528,7 +397,7 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/combined-stream/-/combined-stream-1.0.8.tgz", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { @@ -540,13 +409,13 @@ }, "node_modules/core-util-is": { "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/core-util-is/-/core-util-is-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "license": "MIT" }, "node_modules/css-select": { "version": "1.2.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/css-select/-/css-select-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", "integrity": "sha512-dUQOBoqdR7QwV90WysXPLXG5LO7nhYBgiWVfxF80DKPF8zx1t/pUd2FYy73emg3zrjtM6dzmYgbHKfV2rxiHQA==", "license": "BSD-like", "dependencies": { @@ -558,7 +427,7 @@ }, "node_modules/css-what": { "version": "2.1.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/css-what/-/css-what-2.1.3.tgz", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz", "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==", "license": "BSD-2-Clause", "engines": { @@ -567,13 +436,13 @@ }, "node_modules/cuint": { "version": "0.2.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/cuint/-/cuint-0.2.2.tgz", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", "integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==", "license": "MIT" }, "node_modules/dashdash": { "version": "1.14.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/dashdash/-/dashdash-1.14.1.tgz", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "license": "MIT", "dependencies": { @@ -585,7 +454,7 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/debug/-/debug-4.4.3.tgz", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { @@ -602,7 +471,7 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/delayed-stream/-/delayed-stream-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { @@ -611,7 +480,7 @@ }, "node_modules/dom-serializer": { "version": "0.1.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/dom-serializer/-/dom-serializer-0.1.1.tgz", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", "integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==", "license": "MIT", "dependencies": { @@ -621,13 +490,13 @@ }, "node_modules/domelementtype": { "version": "1.3.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/domelementtype/-/domelementtype-1.3.1.tgz", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "2.4.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/domhandler/-/domhandler-2.4.2.tgz", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", "license": "BSD-2-Clause", "dependencies": { @@ -636,30 +505,16 @@ }, "node_modules/domutils": { "version": "1.5.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/domutils/-/domutils-1.5.1.tgz", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", "integrity": "sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==", "dependencies": { "dom-serializer": "0", "domelementtype": "1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ecc-jsbn": { "version": "0.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "license": "MIT", "dependencies": { @@ -669,70 +524,25 @@ }, "node_modules/enabled": { "version": "2.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/enabled/-/enabled-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", "license": "MIT" }, "node_modules/entities": { "version": "1.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/entities/-/entities-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", "license": "BSD-2-Clause" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/extend": { "version": "3.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/extend/-/extend-3.0.2.tgz", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, "node_modules/extsprintf": { "version": "1.3.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/extsprintf/-/extsprintf-1.3.0.tgz", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", "engines": [ "node >=0.6.0" @@ -741,25 +551,25 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, "node_modules/fecha": { "version": "4.2.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/fecha/-/fecha-4.2.3.tgz", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, "node_modules/file-manager": { "version": "2.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/file-manager/-/file-manager-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/file-manager/-/file-manager-2.0.1.tgz", "integrity": "sha512-y/K/1OCha04OXOxzo3cXJYtIzEk/CUMBb7Okipxueu0u+xCiuoocbwPyh1smUBasOobo4GAYmjgjD9Vh5zI51w==", "license": "MIT", "dependencies": { @@ -771,7 +581,7 @@ }, "node_modules/file-manager/node_modules/@doctormckay/stdlib": { "version": "1.16.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@doctormckay/stdlib/-/stdlib-1.16.1.tgz", + "resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-1.16.1.tgz", "integrity": "sha512-XhuUOzElz6fnNdt70IYNKqhPAEpGaL4JHOhAvklRh0hAhVPW+/wLxaWT3DWUbaG5Dta5YvIp7+cZK3GhIpAuug==", "license": "MIT", "engines": { @@ -780,33 +590,13 @@ }, "node_modules/fn.name": { "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/fn.name/-/fn.name-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/forever-agent": { "version": "0.6.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/forever-agent/-/forever-agent-0.6.1.tgz", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", "license": "Apache-2.0", "engines": { @@ -814,93 +604,31 @@ } }, "node_modules/form-data": { - "version": "2.5.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/form-data/-/form-data-2.5.5.tgz", - "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", - "dev": true, + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" }, "engines": { "node": ">= 0.12" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/getpass": { "version": "0.1.7", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/getpass/-/getpass-0.1.7.tgz", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "license": "MIT", "dependencies": { "assert-plus": "^1.0.0" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/har-schema": { "version": "2.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/har-schema/-/har-schema-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", "license": "ISC", "engines": { @@ -909,7 +637,7 @@ }, "node_modules/har-validator": { "version": "5.1.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/har-validator/-/har-validator-5.1.5.tgz", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", "deprecated": "this library is no longer supported", "license": "MIT", @@ -921,48 +649,9 @@ "node": ">=6" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/htmlparser2": { "version": "3.10.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/htmlparser2/-/htmlparser2-3.10.1.tgz", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", "license": "MIT", "dependencies": { @@ -976,7 +665,7 @@ }, "node_modules/http-signature": { "version": "1.2.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/http-signature/-/http-signature-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", "license": "MIT", "dependencies": { @@ -991,7 +680,7 @@ }, "node_modules/image-size": { "version": "0.8.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/image-size/-/image-size-0.8.3.tgz", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.8.3.tgz", "integrity": "sha512-SMtq1AJ+aqHB45c3FsB4ERK0UCiA2d3H1uq8s+8T0Pf8A3W4teyBQyaFaktH6xvZqh+npwlKU7i4fJo0r7TYTg==", "license": "MIT", "dependencies": { @@ -1006,14 +695,14 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/inherits/-/inherits-2.0.4.tgz", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -1021,7 +710,7 @@ }, "node_modules/is-stream": { "version": "2.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/is-stream/-/is-stream-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { @@ -1033,43 +722,43 @@ }, "node_modules/is-typedarray": { "version": "1.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/is-typedarray/-/is-typedarray-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "license": "MIT" }, "node_modules/isstream": { "version": "0.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/isstream/-/isstream-0.1.2.tgz", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "license": "MIT" }, "node_modules/jsbn": { "version": "0.1.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/jsbn/-/jsbn-0.1.1.tgz", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/json-schema/-/json-schema-0.4.0.tgz", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stringify-safe": { "version": "5.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "license": "ISC" }, "node_modules/jsprim": { "version": "1.4.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/jsprim/-/jsprim-1.4.2.tgz", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", "license": "MIT", "dependencies": { @@ -1084,13 +773,13 @@ }, "node_modules/kuler": { "version": "2.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/kuler/-/kuler-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, "node_modules/kvparser": { "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/kvparser/-/kvparser-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/kvparser/-/kvparser-1.0.2.tgz", "integrity": "sha512-5P/5qpTAHjVYWqcI55B3yQwSY2FUrYYrJj5i65V1Wmg7/4W4OnBcaodaEvLyVuugeOnS+BAaKm9LbPazGJcRyA==", "license": "MIT", "engines": { @@ -1098,87 +787,87 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.assignin": { "version": "4.2.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.assignin/-/lodash.assignin-4.2.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz", "integrity": "sha512-yX/rx6d/UTVh7sSVWVSIMjfnz95evAgDFdb1ZozC35I9mSFCkmzptOzevxjgbQUsc78NR44LVHWjsoMQXy9FDg==", "license": "MIT" }, "node_modules/lodash.bind": { "version": "4.2.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.bind/-/lodash.bind-4.2.1.tgz", + "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz", "integrity": "sha512-lxdsn7xxlCymgLYo1gGvVrfHmkjDiyqVv62FAeF2i5ta72BipE1SLxw8hPEPLhD4/247Ijw07UQH7Hq/chT5LA==", "license": "MIT" }, "node_modules/lodash.defaults": { "version": "4.2.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, "node_modules/lodash.filter": { "version": "4.6.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.filter/-/lodash.filter-4.6.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz", "integrity": "sha512-pXYUy7PR8BCLwX5mgJ/aNtyOvuJTdZAo9EQFUvMIYugqmJxnrYaANvTbgndOzHSCSR0wnlBBfRXJL5SbWxo3FQ==", "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "license": "MIT" }, "node_modules/lodash.foreach": { "version": "4.5.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.foreach/-/lodash.foreach-4.5.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==", "license": "MIT" }, "node_modules/lodash.map": { "version": "4.6.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.map/-/lodash.map-4.6.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", "integrity": "sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.merge/-/lodash.merge-4.6.2.tgz", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, "node_modules/lodash.pick": { "version": "4.4.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.pick/-/lodash.pick-4.4.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==", "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.", "license": "MIT" }, "node_modules/lodash.reduce": { "version": "4.6.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", "license": "MIT" }, "node_modules/lodash.reject": { "version": "4.6.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.reject/-/lodash.reject-4.6.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.reject/-/lodash.reject-4.6.0.tgz", "integrity": "sha512-qkTuvgEzYdyhiJBx42YPzPo71R1aEr0z79kAv7Ixg8wPFEjgRgJdUsGMG3Hf3OYSF/kHI79XhNlt+5Ar6OzwxQ==", "license": "MIT" }, "node_modules/lodash.some": { "version": "4.6.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lodash.some/-/lodash.some-4.6.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz", "integrity": "sha512-j7MJE+TuT51q9ggt4fSgVqro163BEFjAt3u97IqU+JA2DkWl80nFTrowzLpZ/BnpN7rrl0JA/593NAdd8p/scQ==", "license": "MIT" }, "node_modules/logform": { "version": "2.7.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/logform/-/logform-2.7.0.tgz", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", "license": "MIT", "dependencies": { @@ -1195,7 +884,7 @@ }, "node_modules/long": { "version": "3.2.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/long/-/long-3.2.0.tgz", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", "license": "Apache-2.0", "engines": { @@ -1204,25 +893,16 @@ }, "node_modules/lzma": { "version": "2.3.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/lzma/-/lzma-2.3.2.tgz", + "resolved": "https://registry.npmjs.org/lzma/-/lzma-2.3.2.tgz", "integrity": "sha512-DcfiawQ1avYbW+hsILhF38IKAlnguc/fjHrychs9hdxe4qLykvhT5VTGNs5YRWgaNePh7NTxGD4uv4gKsRomCQ==", "license": "MIT", "bin": { "lzma.js": "bin/lzma.js" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/mime-db/-/mime-db-1.52.0.tgz", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { @@ -1231,7 +911,7 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/mime-types/-/mime-types-2.1.35.tgz", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { @@ -1243,13 +923,13 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/ms/-/ms-2.1.3.tgz", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/node-bignumber": { "version": "1.2.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/node-bignumber/-/node-bignumber-1.2.2.tgz", + "resolved": "https://registry.npmjs.org/node-bignumber/-/node-bignumber-1.2.2.tgz", "integrity": "sha512-VoTZHmdFQpZH1+q1dz2qcHNCwTWsJg2T3PYwlAyDNFOfVhSYUKQBLFcCpCud+wJBGgCttGavZILaIggDIKqEQQ==", "engines": { "node": ">=0.4.0" @@ -1257,7 +937,7 @@ }, "node_modules/nth-check": { "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/nth-check/-/nth-check-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", "license": "BSD-2-Clause", "dependencies": { @@ -1266,7 +946,7 @@ }, "node_modules/oauth-sign": { "version": "0.9.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/oauth-sign/-/oauth-sign-0.9.0.tgz", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", "license": "Apache-2.0", "engines": { @@ -1275,7 +955,7 @@ }, "node_modules/one-time": { "version": "1.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/one-time/-/one-time-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", "license": "MIT", "dependencies": { @@ -1284,13 +964,13 @@ }, "node_modules/performance-now": { "version": "2.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/performance-now/-/performance-now-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT" }, "node_modules/permessage-deflate": { "version": "0.1.7", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/permessage-deflate/-/permessage-deflate-0.1.7.tgz", + "resolved": "https://registry.npmjs.org/permessage-deflate/-/permessage-deflate-0.1.7.tgz", "integrity": "sha512-EUNi/RIsyJ1P1u9QHFwMOUWMYetqlE22ZgGbad7YP856WF4BFF0B7DuNy6vEGsgNNud6c/SkdWzkne71hH8MjA==", "license": "Apache-2.0", "dependencies": { @@ -1301,24 +981,23 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -1326,19 +1005,13 @@ }, "node_modules/protobufjs/node_modules/long": { "version": "5.3.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/long/-/long-5.3.2.tgz", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/psl": { "version": "1.15.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/psl/-/psl-1.15.0.tgz", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", "license": "MIT", "dependencies": { @@ -1350,7 +1023,7 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/punycode/-/punycode-2.3.1.tgz", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", "engines": { @@ -1359,7 +1032,7 @@ }, "node_modules/qs": { "version": "6.5.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/qs/-/qs-6.5.5.tgz", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", "license": "BSD-3-Clause", "engines": { @@ -1368,7 +1041,7 @@ }, "node_modules/queue": { "version": "6.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/queue/-/queue-6.0.1.tgz", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.1.tgz", "integrity": "sha512-AJBQabRCCNr9ANq8v77RJEv73DPbn55cdTb+Giq4X0AVnNVZvMHlYp7XlQiN+1npCZj1DuSmaA2hYVUUDgxFDg==", "license": "MIT", "dependencies": { @@ -1377,7 +1050,7 @@ }, "node_modules/readable-stream": { "version": "3.6.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/readable-stream/-/readable-stream-3.6.2.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { @@ -1391,7 +1064,7 @@ }, "node_modules/request": { "version": "2.88.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/request/-/request-2.88.2.tgz", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", "license": "Apache-2.0", @@ -1421,23 +1094,9 @@ "node": ">= 6" } }, - "node_modules/request/node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 0.12" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/safe-buffer/-/safe-buffer-5.2.1.tgz", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { @@ -1457,7 +1116,7 @@ }, "node_modules/safe-stable-stringify": { "version": "2.5.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", "engines": { @@ -1466,13 +1125,13 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/sax": { "version": "1.6.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/sax/-/sax-1.6.0.tgz", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "license": "BlueOak-1.0.0", "engines": { @@ -1481,7 +1140,7 @@ }, "node_modules/smart-buffer": { "version": "4.2.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/smart-buffer/-/smart-buffer-4.2.0.tgz", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", "engines": { @@ -1490,12 +1149,12 @@ } }, "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -1505,7 +1164,7 @@ }, "node_modules/socks-proxy-agent": { "version": "7.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", "license": "MIT", "dependencies": { @@ -1519,7 +1178,7 @@ }, "node_modules/sshpk": { "version": "1.18.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/sshpk/-/sshpk-1.18.0.tgz", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "license": "MIT", "dependencies": { @@ -1544,7 +1203,7 @@ }, "node_modules/stack-trace": { "version": "0.0.10", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/stack-trace/-/stack-trace-0.0.10.tgz", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", "license": "MIT", "engines": { @@ -1553,7 +1212,7 @@ }, "node_modules/steam-appticket": { "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steam-appticket/-/steam-appticket-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/steam-appticket/-/steam-appticket-1.0.2.tgz", "integrity": "sha512-zwDwZALGv3RanE8RHNYcQU3u4Ez23EzMuQ4Lh15uIHddpDh6TI6uFGbC0HNyt6y+UJYSILe77A33VhFZKQiaqQ==", "license": "MIT", "dependencies": { @@ -1569,29 +1228,23 @@ }, "node_modules/steam-appticket/node_modules/@doctormckay/stdlib": { "version": "1.16.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@doctormckay/stdlib/-/stdlib-1.16.1.tgz", + "resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-1.16.1.tgz", "integrity": "sha512-XhuUOzElz6fnNdt70IYNKqhPAEpGaL4JHOhAvklRh0hAhVPW+/wLxaWT3DWUbaG5Dta5YvIp7+cZK3GhIpAuug==", "license": "MIT", "engines": { "node": ">=6.0.0" } }, - "node_modules/steam-appticket/node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, "node_modules/steam-appticket/node_modules/long": { "version": "4.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/long/-/long-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", "license": "Apache-2.0" }, "node_modules/steam-appticket/node_modules/protobufjs": { - "version": "6.11.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/protobufjs/-/protobufjs-6.11.4.tgz", - "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "version": "6.11.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", + "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -1616,7 +1269,7 @@ }, "node_modules/steam-appticket/node_modules/steamid": { "version": "1.1.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steamid/-/steamid-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/steamid/-/steamid-1.1.3.tgz", "integrity": "sha512-t86YjtP1LtPt8D+TaIARm6PtC9tBnF1FhxQeLFs6ohG7vDUfQuy/M8II14rx1TTUkVuYoWHP/7DlvTtoCGULcw==", "license": "MIT", "dependencies": { @@ -1625,7 +1278,7 @@ }, "node_modules/steam-session": { "version": "1.9.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steam-session/-/steam-session-1.9.4.tgz", + "resolved": "https://registry.npmjs.org/steam-session/-/steam-session-1.9.4.tgz", "integrity": "sha512-MLvg1uMLEOIRHZS5LKruy1w5OqHb8EL7TeMxIY2a4mUcaVOgszz060+jo4c7s3gFeedyuoqGcfwJrR0pLKYzLw==", "license": "MIT", "dependencies": { @@ -1646,7 +1299,7 @@ }, "node_modules/steam-totp": { "version": "2.1.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steam-totp/-/steam-totp-2.1.2.tgz", + "resolved": "https://registry.npmjs.org/steam-totp/-/steam-totp-2.1.2.tgz", "integrity": "sha512-bTKlc/NoIUQId+my+O556s55DDsNNXfVIPWFDNVu68beql7AJhV0c+GTjFxfwCDYfdc4NkAme+0WrDdnY2D2VA==", "license": "MIT", "engines": { @@ -1655,7 +1308,7 @@ }, "node_modules/steam-user": { "version": "5.3.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steam-user/-/steam-user-5.3.0.tgz", + "resolved": "https://registry.npmjs.org/steam-user/-/steam-user-5.3.0.tgz", "integrity": "sha512-/92MOZGIocixlgzjloXrDffbAL5sF9rz4sbsafZ73samhkv2ITQNJrKaKGCJbAq5Xz8CbtCzstdjH8qXawLJVg==", "license": "MIT", "dependencies": { @@ -1690,9 +1343,9 @@ } }, "node_modules/steamcommunity": { - "version": "3.49.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steamcommunity/-/steamcommunity-3.49.0.tgz", - "integrity": "sha512-f5w+/mOcrfobueEG0n77RMh09SqEqqv90Rm6l/AYhANw9q8T/SLKIHEp3ya1rvt5GWCiDoCVjCbAfCtd633S0g==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/steamcommunity/-/steamcommunity-3.50.0.tgz", + "integrity": "sha512-xbQONnDZDA2ph9MN3vABetgFOnXsxkbJS+scYL5OjBwKyUtQe9hA0+JtReK4PGtxOqbs+kS8G79wghBp6W3Bzw==", "license": "MIT", "dependencies": { "@doctormckay/user-agents": "^1.0.0", @@ -1711,7 +1364,7 @@ }, "node_modules/steamcommunity/node_modules/steam-totp": { "version": "1.5.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steam-totp/-/steam-totp-1.5.0.tgz", + "resolved": "https://registry.npmjs.org/steam-totp/-/steam-totp-1.5.0.tgz", "integrity": "sha512-RMlBK5dFtgplDMYYGg/k80RqEntzBcl7C/0RF18fQh9+XPe/iEMsfKmIE+xj8I3hqJW1akANAC6gf+YpfZq52w==", "license": "MIT", "dependencies": { @@ -1720,7 +1373,7 @@ }, "node_modules/steamcommunity/node_modules/steamid": { "version": "1.1.3", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steamid/-/steamid-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/steamid/-/steamid-1.1.3.tgz", "integrity": "sha512-t86YjtP1LtPt8D+TaIARm6PtC9tBnF1FhxQeLFs6ohG7vDUfQuy/M8II14rx1TTUkVuYoWHP/7DlvTtoCGULcw==", "license": "MIT", "dependencies": { @@ -1729,7 +1382,7 @@ }, "node_modules/steamid": { "version": "2.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/steamid/-/steamid-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/steamid/-/steamid-2.1.0.tgz", "integrity": "sha512-ndt1cvuuSC+i8fcxVsmeyRlgGsR1QsoAuIXz+eabj8/Y4GIWE2+mgHA7Hys61JDHOxttfWtXHtN2m5TNYTlORg==", "license": "MIT", "engines": { @@ -1738,7 +1391,7 @@ }, "node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/string_decoder/-/string_decoder-1.3.0.tgz", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { @@ -1747,19 +1400,19 @@ }, "node_modules/text-hex": { "version": "1.0.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/text-hex/-/text-hex-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, "node_modules/tiny-typed-emitter": { "version": "2.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, "node_modules/tough-cookie": { "version": "2.5.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/tough-cookie/-/tough-cookie-2.5.0.tgz", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", "license": "BSD-3-Clause", "dependencies": { @@ -1772,7 +1425,7 @@ }, "node_modules/triple-beam": { "version": "1.4.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/triple-beam/-/triple-beam-1.4.1.tgz", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", "license": "MIT", "engines": { @@ -1781,7 +1434,7 @@ }, "node_modules/tunnel-agent": { "version": "0.6.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", "dependencies": { @@ -1793,19 +1446,33 @@ }, "node_modules/tweetnacl": { "version": "0.14.5", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/tweetnacl/-/tweetnacl-0.14.5.tgz", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/uri-js/-/uri-js-4.4.1.tgz", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "license": "BSD-2-Clause", "dependencies": { @@ -1814,15 +1481,15 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/uuid": { "version": "3.4.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/uuid/-/uuid-3.4.0.tgz", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "license": "MIT", "bin": { "uuid": "bin/uuid" @@ -1830,7 +1497,7 @@ }, "node_modules/verror": { "version": "1.10.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/verror/-/verror-1.10.0.tgz", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", "engines": [ "node >=0.6.0" @@ -1844,7 +1511,7 @@ }, "node_modules/websocket-extensions": { "version": "0.1.4", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", "license": "Apache-2.0", "engines": { @@ -1853,7 +1520,7 @@ }, "node_modules/websocket13": { "version": "4.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/websocket13/-/websocket13-4.1.0.tgz", + "resolved": "https://registry.npmjs.org/websocket13/-/websocket13-4.1.0.tgz", "integrity": "sha512-7+hxkUVTKQlUDTzN2rJI7fJRBXCT6dvRXr1aZflxUZlpNJutHBkKiEIbZOCGs0A1s7vxAmcAXngsNUQMSUTiVQ==", "license": "MIT", "dependencies": { @@ -1869,7 +1536,7 @@ }, "node_modules/winston": { "version": "3.19.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/winston/-/winston-3.19.0.tgz", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", "dependencies": { @@ -1891,7 +1558,7 @@ }, "node_modules/winston-transport": { "version": "4.9.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/winston-transport/-/winston-transport-4.9.0.tgz", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", "license": "MIT", "dependencies": { @@ -1905,14 +1572,14 @@ }, "node_modules/winston/node_modules/async": { "version": "3.2.6", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/async/-/async-3.2.6.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -1932,7 +1599,7 @@ }, "node_modules/xml2js": { "version": "0.6.2", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/xml2js/-/xml2js-0.6.2.tgz", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", "license": "MIT", "dependencies": { @@ -1945,7 +1612,7 @@ }, "node_modules/xmlbuilder": { "version": "11.0.1", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", "license": "MIT", "engines": { @@ -1954,7 +1621,7 @@ }, "node_modules/zstddec": { "version": "0.1.0", - "resolved": "https://mvn.tursom.cn:20080/repository/npm/zstddec/-/zstddec-0.1.0.tgz", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz", "integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==", "license": "MIT AND BSD-3-Clause" } diff --git a/package.json b/package.json index d59a783..6813193 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,31 @@ { "name": "steam-chat", "version": "1.0.0", - "description": "", + "description": "Steam account based realtime chat service with a built-in Web UI.", "type": "commonjs", - "main": "logger.js", + "main": "dist/src/index.js", "scripts": { - "test": "STEAM_CHAT_DISABLE_AUTOSTART=1 node --test" + "build": "tsc -p tsconfig.json && mkdir -p dist/web && cp web/index.html web/style.css dist/web/", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "npm run build && node dist/src/index.js", + "test": "npm run build && STEAM_CHAT_DISABLE_AUTOSTART=1 node --test dist/test/*.test.js" }, - "keywords": [], + "keywords": [ + "steam", + "chat", + "websocket" + ], "author": "", "license": "GPL-3.0", "dependencies": { - "@matteo.collina/dateformat": "^5.0.1", - "axios": "^1.7.7", - "steam-totp": "^2.1.2", "steam-user": "^5.0.8", "steamcommunity": "^3.48.2", "winston": "^3.12.0", "ws": "^8.18.1" }, "devDependencies": { - "@types/steam-totp": "^2.1.2", - "@types/steam-user": "^5.1.0", - "@types/steamcommunity": "^3.43.8" + "@types/node": "^26.0.0", + "@types/ws": "^8.18.1", + "typescript": "^6.0.3" } } diff --git a/public/app.js b/public/app.js deleted file mode 100644 index b7b5d73..0000000 --- a/public/app.js +++ /dev/null @@ -1,401 +0,0 @@ -import { - buildCachedImageUrl, - buildSteamStickerCandidateUrls, - buildSteamEmoticonUrl, - extractEmoticonNames, - extractImageUrls, - extractStickerType, - formatConversationTime, - formatDayLabel, - formatTimeLabel, - parseBbCodeAttributes, - parseDateString, - sameDay, -} from './app/utils.js'; -import { createStatusController } from './app/status.js'; -import { createRichContentRenderer } from './app/rich-content.js'; -import { createLightboxController } from './app/lightbox.js'; -import { createManagedImageController } from './app/managed-images.js'; -import { createMessageBubbleRenderer } from './app/message-bubble.js'; -import { createMessagesController } from './app/messages.js'; -import { createSidebarController } from './app/sidebar.js'; -import { createComposerController } from './app/composer.js'; -import { createSessionController } from './app/session.js'; -import { createWebSocketController } from './app/websocket.js'; -import { createNotificationController } from './app/notifications.js'; -import { createLayoutController } from './app/layout.js'; -import { createPreferencesController } from './app/preferences.js'; -import { getAppDomRefs } from './app/dom.js'; -import { bindAppShellEvents, connectSocketFromConfig } from './app/bootstrap.js'; - -(() => { - const { - targetIdInput, - historyLimitInput, - reloadHistoryButton, - openConversationButton, - reloadConversationsButton, - conversationListEl, - friendsListEl, - groupsListEl, - reloadFriendsButton, - reloadGroupsButton, - sidebarTabs, - sidebarTabPanels, - chatTitleEl, - chatSubtitleEl, - feedbackStatusEl, - connectionStatusEl, - connectionStatusLabelEl, - messagesEl, - dropOverlay, - sidebarEl, - sidebarBackdrop, - mobileSidebarToggleButton, - closeSidebarButton, - imageLightbox, - imageLightboxViewport, - imageLightboxImage, - imageLightboxCaption, - closeImageLightboxButton, - imageZoomOutButton, - imageZoomResetButton, - imageZoomInButton, - messageInput, - emoticonSuggestions, - emoticonPreviewImage, - emoticonPreviewLabel, - sendMessageButton, - attachmentButton, - attachmentMenu, - chooseImageButton, - chooseUrlButton, - imageFileInput, - urlPanel, - imageUrlInput, - confirmImageUrlButton, - cancelImageUrlButton, - attachmentPreview, - attachmentPreviewImage, - attachmentPreviewTitle, - attachmentPreviewSubtitle, - clearAttachmentButton, - uploadQueue, - uploadQueueList, - pickerButton, - pickerPanel, - pickerSearch, - pickerGrid, - pickerEmpty, - pickerTabs, - } = getAppDomRefs(document); - - const defaultDocumentTitle = document.title || 'Steam Chat'; - const mobileLayoutMedia = window.matchMedia('(max-width: 900px)'); - - let createRichMessageContent = null; - - const { setFeedback: setStatus, setConnection: setConnectionStatus } = createStatusController({ - feedbackEl: feedbackStatusEl, - connectionChipEl: connectionStatusEl, - connectionLabelEl: connectionStatusLabelEl, - }); - - setConnectionStatus('connecting'); - - let session = null; - - const preferences = createPreferencesController({ - targetIdInput, - historyLimitInput, - }); - preferences.loadPreferences(); - const { - currentHistoryLimit, - currentTargetId, - } = preferences; - - const { - createManagedImageHost, - cleanupManagedImages, - loadManagedImage, - resetManagedImage, - } = createManagedImageController(); - - const { - handleWindowResize: handleLightboxResize, - makeImageZoomable, - } = createLightboxController({ - buildCachedImageUrl, - imageLightbox, - imageLightboxViewport, - imageLightboxImage, - imageLightboxCaption, - closeImageLightboxButton, - imageZoomOutButton, - imageZoomResetButton, - imageZoomInButton, - loadManagedImage, - resetManagedImage, - }); - - ({ createRichMessageContent } = createRichContentRenderer({ - buildSteamEmoticonUrl, - buildCachedImageUrl, - extractImageUrls, - parseBbCodeAttributes, - createManagedImageHost, - makeImageZoomable, - loadManagedImage, - })); - - const { renderMessageBubble } = createMessageBubbleRenderer({ - createManagedImageHost, - makeImageZoomable, - loadManagedImage, - buildCachedImageUrl, - createRichMessageContent, - extractStickerType, - buildSteamStickerCandidateUrls, - extractImageUrls, - }); - - const { - appendEntry, - clearMessages, - renderHistory, - } = createMessagesController({ - messagesEl, - cleanupManagedImages, - parseDateString, - sameDay, - formatDayLabel, - formatTimeLabel, - getActiveConversationId: () => (session ? session.getActiveConversationId() : '') || currentTargetId(), - renderMessageBubble, - }); - - const { - bindTabKeyboardNavigation, - renderConversations, - renderFriends, - renderGroups, - switchSidebarTab, - } = createSidebarController({ - conversationListEl, - friendsListEl, - groupsListEl, - sidebarTabs, - sidebarTabPanels, - formatConversationTime, - getActiveConversationId: () => (session ? session.getActiveConversationId() : ''), - onConversationSelect: (conversation) => { - session.setActiveConversation(conversation.id, conversation.name); - requestHistory(); - }, - onFriendSelect: (friend) => { - session.setActiveConversation(friend.id, friend.name); - switchSidebarTab('conversations'); - requestHistory(); - }, - onGroupSelect: (group) => { - session.setActiveConversation(group.id, group.name); - switchSidebarTab('conversations'); - requestHistory(); - }, - }); - - let composer = null; - - const layout = createLayoutController({ - mediaQuery: mobileLayoutMedia, - sidebarEl, - sidebarBackdrop, - mobileSidebarToggleButton, - onResponsiveChange: () => { - if (composer) { - composer.handleViewportChange(); - } - }, - }); - const { isMobileLayout } = layout; - - composer = createComposerController({ - buildCachedImageUrl, - buildSteamEmoticonUrl, - extractEmoticonNames, - isMobileLayout, - setStatus, - send: (payload) => socketController.send(payload), - createRequestId: (prefix) => socketController.createRequestId(prefix), - getConversationId: () => (session ? session.getActiveConversationId() : '') || currentTargetId(), - controls: { - messageInput, - emoticonSuggestions, - emoticonPreviewImage, - emoticonPreviewLabel, - sendMessageButton, - attachmentButton, - attachmentMenu, - chooseImageButton, - chooseUrlButton, - imageFileInput, - urlPanel, - imageUrlInput, - confirmImageUrlButton, - cancelImageUrlButton, - attachmentPreview, - attachmentPreviewImage, - attachmentPreviewTitle, - attachmentPreviewSubtitle, - clearAttachmentButton, - uploadQueue, - uploadQueueList, - pickerButton, - pickerPanel, - pickerSearch, - pickerGrid, - pickerEmpty, - pickerTabs, - dropOverlay, - }, - }); - - session = createSessionController({ - targetIdInput, - chatTitleEl, - chatSubtitleEl, - renderConversations, - renderFriends, - renderGroups, - savePreferences: preferences.savePreferences, - clearUnreadCount: () => notifications.clearUnreadCount(), - closeSidebar: () => layout.closeSidebar(), - buildConversationPreview: (entry) => ( - entry.type === 'image' || entry.imageUrl - ? '[图片]' - : (extractStickerType(entry.message) - ? '[贴纸] ' + extractStickerType(entry.message).replace(/^Sticker_/, '') - : String(entry.message || '').trim().slice(0, 60)) - ), - }); - - const socketController = createWebSocketController({ - setStatus, - setConnectionStatus, - savePreferences: preferences.savePreferences, - clearPendingUploadRequests: (message) => composer.clearPendingUploadRequests(message), - onReady: () => { - socketController.requestConversations(); - socketController.requestFriends(); - socketController.requestGroups(); - composer.requestEmoticonInventory(); - }, - onEmoticons: (data) => { - composer.applyEmoticonInventory(data); - }, - onConversations: (data) => { - session.setConversations((data && data.items) || []); - const activeConversationId = session.getActiveConversationId(); - const conversations = session.getConversations(); - if (activeConversationId) { - requestHistory(); - } else if (preferences.currentTargetId()) { - session.setActiveConversation(preferences.currentTargetId()); - requestHistory(); - } else if (conversations.length) { - session.setActiveConversation(conversations[0].id, conversations[0].name); - requestHistory(); - } - }, - onFriends: (data) => { - session.setFriends((data && data.items) || []); - }, - onGroups: (data) => { - session.setGroups((data && data.items) || []); - }, - onHistory: (data) => { - ((data && data.items) || []).forEach((item) => composer.rememberEmoticonsFromMessage(item.message)); - renderHistory((data && data.items) || []); - session.updateConversationList(((data && data.items) || []).slice(-1)[0]); - setStatus('历史消息已加载'); - }, - onMessage: (entry) => { - session.updateConversationList(entry); - appendEntry(entry); - notifications.notifyIncomingEntry(entry); - }, - onMessageSent: () => { - composer.hideSuggestions(); - }, - onImageSent: (payload) => { - composer.resolveUploadRequest(payload.requestId, true, '已发送'); - session.updateConversationList(payload.data); - appendEntry(payload.data); - setStatus('图片已发送'); - }, - onError: (payload) => { - composer.resolveUploadRequest(payload.requestId, false, payload.message); - setStatus(payload.message || '请求失败'); - }, - }); - - const notifications = createNotificationController({ - defaultDocumentTitle, - getActiveConversationId: () => (session ? session.getActiveConversationId() : '') || preferences.currentTargetId(), - onNotificationOpen: (entry) => { - if (entry.id) { - session.setActiveConversation(entry.id, entry.name); - requestHistory(); - } - }, - }); - - function requestHistory() { - const id = (session ? session.getActiveConversationId() : '') || currentTargetId(); - if (!id) { - clearMessages(); - setStatus('请输入对方 SteamID64 后再加载历史'); - return; - } - - socketController.requestHistory(id, currentHistoryLimit()); - } - - function openConversation() { - const id = currentTargetId(); - if (!id) { - setStatus('请输入 SteamID64'); - return; - } - - session.setActiveConversation(id); - requestHistory(); - } - - bindAppShellEvents({ - reloadHistoryButton, - reloadConversationsButton, - reloadFriendsButton, - reloadGroupsButton, - sidebarTabs, - bindTabKeyboardNavigation, - switchSidebarTab, - openConversationButton, - targetIdInput, - historyLimitInput, - mobileSidebarToggleButton, - closeSidebarButton, - sidebarBackdrop, - mobileLayoutMedia, - sidebarEl, - requestHistory, - openConversation, - composer, - socketController, - layout, - notifications, - handleLightboxResize, - }); - connectSocketFromConfig(socketController); -})(); diff --git a/public/app/AGENTS.md b/public/app/AGENTS.md deleted file mode 100644 index 7c6803a..0000000 --- a/public/app/AGENTS.md +++ /dev/null @@ -1,81 +0,0 @@ -# Frontend Modules (public/app/) - -**Domain:** Browser-side ES6 modules for Steam Chat UI - -## STRUCTURE -``` -app/ -├── bootstrap.js # Page init, event binding, config fetch -├── session.js # Active conversation state, sidebar data -├── websocket.js # WebSocket client, auto-reconnect, request IDs -├── composer.js # Message input, attachments, emoticon/sticker picker -├── messages.js # Message list rendering, history, separators -├── message-bubble.js # Individual bubble rendering (text/image/sticker) -├── sidebar.js # Conversations/friends/groups tabs -├── lightbox.js # Image preview modal with zoom -├── managed-images.js # Lazy loading, image lifecycle -├── rich-content.js # Emoticons, link cards, BBCode parsing -├── notifications.js # Desktop notifications, unread count -├── preferences.js # localStorage for target ID, history limit -├── layout.js # Responsive layout, mobile sidebar toggle -├── status.js # Connection status chip -├── dom.js # DOM element references -└── utils.js # Date formatting, Steam URL builders, parsers -``` - -## WHERE TO LOOK -| Task | Location | -|------|----------| -| Add new WebSocket message type | `websocket.js` switch statement | -| Change message rendering | `message-bubble.js` | -| Add emoticon/sticker support | `composer.js` + `rich-content.js` | -| Modify layout breakpoints | `layout.js` (900px mobile) | -| Add keyboard shortcuts | `bootstrap.js` or `composer.js` | -| Change date/time format | `utils.js` | -| Mobile sidebar behavior | `layout.js`, `sidebar.js` | - -## CONVENTIONS - -### Module Pattern -```javascript -export function createXController({ dep1, dep2 }) { - // private state - let state = {}; - - function privateFn() {} - - function publicFn() {} - - return { - publicFn, - // only expose necessary methods - }; -} -``` - -### Event Handling -- Prefer delegation: `container.addEventListener('click', handler)` -- Check targets: `if (event.target.matches('.class'))` -- One-time warmup: `{ once: true }` option - -### DOM References -- Centralized in `dom.js` via `getAppDomRefs(document)` -- Passed as dependencies to controllers -- Avoid querying DOM repeatedly - -### WebSocket Communication -- Request IDs: `${prefix}-${counter++}` format -- Types: `send_message`, `get_history`, `get_conversations`, `get_friends`, `get_groups`, `get_emoticons` -- Auto-reconnect on disconnect (3s delay) - -## ANTI-PATTERNS -- **DO NOT** use inline event handlers (onclick="...") -- **DO NOT** import Node.js modules (fs, path, etc.) -- **AVOID** direct DOM manipulation outside controllers -- **NEVER** store sensitive data in localStorage - -## DEPENDENCIES -- Pure vanilla JS, no frameworks -- Native WebSocket API -- Native Fetch API (for `/api/config`) -- `window.visualViewport` for mobile keyboard handling diff --git a/public/app/bootstrap.js b/public/app/bootstrap.js deleted file mode 100644 index 4d470d7..0000000 --- a/public/app/bootstrap.js +++ /dev/null @@ -1,103 +0,0 @@ -export function bindAppShellEvents({ - reloadHistoryButton, - reloadConversationsButton, - reloadFriendsButton, - reloadGroupsButton, - sidebarTabs, - bindTabKeyboardNavigation, - switchSidebarTab, - openConversationButton, - targetIdInput, - historyLimitInput, - mobileSidebarToggleButton, - closeSidebarButton, - sidebarBackdrop, - mobileLayoutMedia, - sidebarEl, - requestHistory, - openConversation, - composer, - socketController, - layout, - notifications, - handleLightboxResize, -}) { - layout.updateViewportHeightVar(); - layout.syncResponsiveLayout(); - - reloadHistoryButton.addEventListener('click', requestHistory); - reloadConversationsButton.addEventListener('click', () => socketController.requestConversations()); - reloadFriendsButton.addEventListener('click', () => socketController.requestFriends()); - reloadGroupsButton.addEventListener('click', () => socketController.requestGroups()); - - sidebarTabs.forEach((tab) => { - tab.addEventListener('click', () => { - switchSidebarTab(tab.dataset.tab); - }); - }); - bindTabKeyboardNavigation(); - - openConversationButton.addEventListener('click', openConversation); - targetIdInput.addEventListener('change', openConversation); - historyLimitInput.addEventListener('change', requestHistory); - mobileSidebarToggleButton.addEventListener('click', layout.toggleSidebar); - closeSidebarButton.addEventListener('click', layout.closeSidebar); - sidebarBackdrop.addEventListener('click', layout.closeSidebar); - - if (typeof mobileLayoutMedia.addEventListener === 'function') { - mobileLayoutMedia.addEventListener('change', layout.syncResponsiveLayout); - } else if (typeof mobileLayoutMedia.addListener === 'function') { - mobileLayoutMedia.addListener(layout.syncResponsiveLayout); - } - - composer.bindEvents(); - - document.addEventListener('click', (event) => { - if (layout.isMobileLayout() - && sidebarEl.classList.contains('open') - && !sidebarEl.contains(event.target) - && event.target !== mobileSidebarToggleButton) { - layout.closeSidebar(); - } - }); - - document.addEventListener('visibilitychange', () => { - if (!document.hidden && document.hasFocus()) { - notifications.clearUnreadCount(); - } - }); - - window.addEventListener('focus', notifications.clearUnreadCount); - window.addEventListener('resize', () => { - layout.updateViewportHeightVar(); - layout.syncResponsiveLayout(); - handleLightboxResize(); - }); - - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', () => { - layout.updateViewportHeightVar(); - composer.handleViewportChange(); - }); - window.visualViewport.addEventListener('scroll', layout.updateViewportHeightVar); - } - - window.addEventListener('pointerdown', notifications.warmupNotifications, { once: true }); - window.addEventListener('keydown', notifications.warmupNotifications, { once: true }); - composer.handleViewportChange(); -} - -export function connectSocketFromConfig(socketController, { - configUrl = '/api/config', - fallbackWsPath = '/ws', - fetchImpl = (...args) => window.fetch(...args), -} = {}) { - return fetchImpl(configUrl) - .then((res) => res.json()) - .then((config) => { - socketController.connect((config && config.wsPath) || fallbackWsPath); - }) - .catch(() => { - socketController.connect(fallbackWsPath); - }); -} diff --git a/public/app/composer.js b/public/app/composer.js deleted file mode 100644 index 419a34f..0000000 --- a/public/app/composer.js +++ /dev/null @@ -1,997 +0,0 @@ -export function createComposerController({ - buildCachedImageUrl, - buildSteamEmoticonUrl, - extractEmoticonNames, - isMobileLayout, - setStatus, - send, - createRequestId, - getConversationId, - controls, -}) { - const { - messageInput, - emoticonSuggestions, - emoticonPreviewImage, - emoticonPreviewLabel, - sendMessageButton, - attachmentButton, - attachmentMenu, - chooseImageButton, - chooseUrlButton, - imageFileInput, - urlPanel, - imageUrlInput, - confirmImageUrlButton, - cancelImageUrlButton, - attachmentPreview, - attachmentPreviewImage, - attachmentPreviewTitle, - attachmentPreviewSubtitle, - clearAttachmentButton, - uploadQueue, - uploadQueueList, - pickerButton, - pickerPanel, - pickerSearch, - pickerGrid, - pickerEmpty, - pickerTabs, - dropOverlay, - } = controls; - - let pendingAttachment = null; - let dragDepth = 0; - const knownEmoticons = new Set(['steamhappy', 'steamfacepalm', 'steamthumbsup', 'steamheart', 'steamsad', 'steammocking']); - let currentSuggestions = []; - let activeSuggestionIndex = 0; - let uploadQueueItems = []; - const uploadRequestMap = new Map(); - let activePickerTab = 'emoticons'; - let emoticonInventory = []; - let stickerInventory = []; - - function autoResizeMessageInput() { - const minHeight = isMobileLayout() ? 30 : 44; - const maxHeight = isMobileLayout() ? 72 : 160; - messageInput.style.height = 'auto'; - const nextHeight = Math.max(minHeight, Math.min(messageInput.scrollHeight, maxHeight)); - messageInput.style.height = nextHeight + 'px'; - messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? 'auto' : 'hidden'; - } - - function syncMessageInputPlaceholder() { - const mobilePlaceholder = messageInput.dataset.mobilePlaceholder || '输入消息'; - const desktopPlaceholder = messageInput.dataset.desktopPlaceholder || mobilePlaceholder; - messageInput.placeholder = isMobileLayout() ? mobilePlaceholder : desktopPlaceholder; - } - - function categorizeEmoticon(name) { - if (/^steam/i.test(name)) { - return 'Steam'; - } - return '最近使用'; - } - - function renderUploadQueue() { - uploadQueueList.replaceChildren(); - uploadQueue.classList.toggle('active', uploadQueueItems.length > 0); - - uploadQueueItems.forEach((item) => { - const row = document.createElement('div'); - row.className = 'upload-queue-item'; - if (item.state) { - row.classList.add('is-' + item.state); - } - - const name = document.createElement('div'); - name.className = 'upload-queue-name'; - name.textContent = item.name; - - const status = document.createElement('div'); - status.className = 'upload-queue-status'; - status.textContent = item.statusText; - - const progress = document.createElement('div'); - progress.className = 'upload-queue-progress'; - - const bar = document.createElement('div'); - bar.className = 'upload-queue-progress-bar'; - if (item.state) { - bar.classList.add('is-' + item.state); - } - bar.style.width = item.progress + '%'; - - progress.appendChild(bar); - row.appendChild(name); - row.appendChild(status); - row.appendChild(progress); - uploadQueueList.appendChild(row); - }); - } - - function updateQueueItem(id, patch) { - const item = uploadQueueItems.find((entry) => entry.id === id); - if (!item) { - return; - } - Object.assign(item, patch); - renderUploadQueue(); - } - - function removeQueueItem(id) { - const nextItems = uploadQueueItems.filter((entry) => entry.id !== id); - if (nextItems.length === uploadQueueItems.length) { - return; - } - uploadQueueItems = nextItems; - renderUploadQueue(); - } - - function scheduleQueueItemRemoval(id, delay) { - window.setTimeout(() => { - removeQueueItem(id); - }, delay || 1500); - } - - function createQueueItem(name, statusText, progress) { - const queueId = 'queue-' + Date.now() + '-' + Math.random().toString(16).slice(2); - uploadQueueItems.push({ - id: queueId, - name: name || '图片', - progress: Number.isFinite(progress) ? progress : 0, - statusText: statusText || '准备中', - state: 'pending', - }); - renderUploadQueue(); - return queueId; - } - - function markQueueItemCompleted(id, statusText) { - updateQueueItem(id, { - progress: 100, - statusText: statusText || '已发送', - state: 'done', - }); - scheduleQueueItemRemoval(id, 1200); - } - - function markQueueItemFailed(id, statusText) { - updateQueueItem(id, { - progress: 100, - statusText: statusText || '发送失败', - state: 'error', - }); - scheduleQueueItemRemoval(id, 2500); - } - - function resolveUploadRequest(requestId, ok, message) { - if (!requestId || !uploadRequestMap.has(requestId)) { - return false; - } - - const queueId = uploadRequestMap.get(requestId); - uploadRequestMap.delete(requestId); - - if (ok) { - markQueueItemCompleted(queueId, message || '已发送'); - } else { - markQueueItemFailed(queueId, message || '发送失败'); - } - return true; - } - - function clearPendingUploadRequests(message) { - for (const [, queueId] of uploadRequestMap.entries()) { - markQueueItemFailed(queueId, message || '发送中断'); - } - uploadRequestMap.clear(); - } - - function closeAttachmentMenu() { - attachmentMenu.classList.remove('open'); - } - - function toggleAttachmentMenu() { - attachmentMenu.classList.toggle('open'); - } - - function closeUrlPanel() { - urlPanel.classList.remove('open'); - } - - function togglePickerPanel() { - const isOpen = pickerPanel.classList.toggle('open'); - if (isOpen) { - closeAttachmentMenu(); - pickerSearch.value = ''; - renderPickerGrid(); - pickerSearch.focus(); - } - } - - function closePickerPanel() { - pickerPanel.classList.remove('open'); - } - - function setPickerTab(tab) { - activePickerTab = tab; - pickerTabs.forEach((btn) => { - btn.classList.toggle('active', btn.dataset.tab === tab); - }); - pickerSearch.value = ''; - renderPickerGrid(); - } - - function insertEmoticonAtCursor(name) { - const value = messageInput.value; - const selectionStart = messageInput.selectionStart || 0; - const selectionEnd = messageInput.selectionEnd || selectionStart; - let replaceStart = selectionStart; - let replaceEnd = selectionEnd; - - if (selectionStart === selectionEnd) { - const leftSide = value.slice(0, selectionStart); - const colonIndex = leftSide.lastIndexOf(':'); - - if (colonIndex !== -1) { - const partialName = value.slice(colonIndex + 1, selectionStart); - if (/^[a-z0-9_\-]*$/i.test(partialName)) { - let tokenEnd = selectionStart; - - while (tokenEnd < value.length && /[a-z0-9_\-]/i.test(value.charAt(tokenEnd))) { - tokenEnd += 1; - } - - if (value.charAt(tokenEnd) === ':') { - tokenEnd += 1; - } - - replaceStart = colonIndex; - replaceEnd = tokenEnd; - } - } - } - - const before = value.slice(0, replaceStart); - const after = value.slice(replaceEnd); - const insertion = ':' + name + ': '; - messageInput.value = before + insertion + after; - const newCaret = replaceStart + insertion.length; - messageInput.setSelectionRange(newCaret, newCaret); - autoResizeMessageInput(); - messageInput.focus(); - knownEmoticons.add(name); - } - - function sendStickerMessage(name) { - const id = getConversationId(); - if (!id) { - setStatus('请先选择会话'); - return; - } - const msg = '[sticker type="' + name + '" limit="0"][/sticker]'; - if (send({ - type: 'send_message', - requestId: createRequestId('sticker-'), - id, - msg, - })) { - setStatus('贴纸已发送'); - } - } - - function renderPickerGrid() { - pickerGrid.replaceChildren(); - const query = (pickerSearch.value || '').trim().toLowerCase(); - - if (activePickerTab === 'emoticons') { - pickerGrid.className = 'picker-grid emoticon-grid'; - const items = emoticonInventory - .filter((e) => !query || e.name.toLowerCase().includes(query)) - .sort((a, b) => (b.use_count || 0) - (a.use_count || 0) || a.name.localeCompare(b.name)); - - if (!items.length) { - pickerEmpty.textContent = emoticonInventory.length ? '无匹配结果' : '加载中…'; - pickerEmpty.classList.add('active'); - return; - } - pickerEmpty.classList.remove('active'); - - items.forEach((e) => { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'picker-item emoticon-item'; - btn.title = ':' + e.name + ':'; - - const img = document.createElement('img'); - img.src = buildCachedImageUrl(buildSteamEmoticonUrl(e.name, true)); - img.alt = e.name; - img.loading = 'lazy'; - - const label = document.createElement('div'); - label.className = 'picker-item-name'; - label.textContent = e.name; - - btn.appendChild(img); - btn.appendChild(label); - btn.addEventListener('click', () => { - insertEmoticonAtCursor(e.name); - closePickerPanel(); - }); - pickerGrid.appendChild(btn); - }); - } else { - pickerGrid.className = 'picker-grid sticker-grid'; - const items = stickerInventory - .filter((s) => !query || s.name.toLowerCase().includes(query)) - .sort((a, b) => (b.use_count || 0) - (a.use_count || 0) || a.name.localeCompare(b.name)); - - if (!items.length) { - pickerEmpty.textContent = stickerInventory.length ? '无匹配结果' : '加载中…'; - pickerEmpty.classList.add('active'); - return; - } - pickerEmpty.classList.remove('active'); - - items.forEach((s) => { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'picker-item sticker-item'; - btn.title = s.name; - - const img = document.createElement('img'); - img.src = location.origin + '/proxy/sticker/' + encodeURIComponent(s.name); - img.alt = s.name; - img.loading = 'lazy'; - - const label = document.createElement('div'); - label.className = 'picker-item-name'; - label.textContent = s.name.replace(/^Sticker_/, ''); - - btn.appendChild(img); - btn.appendChild(label); - btn.addEventListener('click', () => { - sendStickerMessage(s.name); - closePickerPanel(); - }); - pickerGrid.appendChild(btn); - }); - } - } - - function revokeAttachmentPreviewUrl(attachment) { - if (attachment && attachment.previewUrl && String(attachment.previewUrl).startsWith('blob:')) { - URL.revokeObjectURL(attachment.previewUrl); - } - } - - function revokePendingAttachmentUrl() { - revokeAttachmentPreviewUrl(pendingAttachment); - } - - function clearPendingAttachment() { - revokePendingAttachmentUrl(); - pendingAttachment = null; - attachmentPreview.classList.remove('active'); - attachmentPreviewImage.hidden = true; - attachmentPreviewImage.removeAttribute('src'); - attachmentPreviewTitle.textContent = ''; - attachmentPreviewSubtitle.textContent = ''; - imageFileInput.value = ''; - } - - function setPendingAttachment(attachment) { - if (!attachment) { - clearPendingAttachment(); - return; - } - - revokePendingAttachmentUrl(); - pendingAttachment = attachment; - - attachmentPreview.classList.add('active'); - attachmentPreviewTitle.textContent = attachment.title || '待发送图片'; - attachmentPreviewSubtitle.textContent = attachment.subtitle || ''; - - if (attachment.previewUrl) { - attachmentPreviewImage.hidden = false; - attachmentPreviewImage.src = attachment.previewUrl; - } else { - attachmentPreviewImage.hidden = true; - attachmentPreviewImage.removeAttribute('src'); - } - } - - function readFileAsDataUrl(file, onProgress) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onprogress = (event) => { - if (typeof onProgress === 'function') { - onProgress(event.loaded || 0, event.total || file.size || 0); - } - }; - reader.onload = () => resolve(String(reader.result || '')); - reader.onerror = () => reject(reader.error || new Error('文件读取失败')); - reader.readAsDataURL(file); - }); - } - - function createFileAttachment(file, sourceLabel) { - if (!file) { - throw new Error('请选择图片文件'); - } - - return { - kind: 'file', - file, - previewUrl: URL.createObjectURL(file), - title: file.name || '待发送图片', - subtitle: sourceLabel || '本地图片', - }; - } - - async function resolveAttachmentPayload(attachment, onProgress) { - if (!attachment) { - throw new Error('缺少图片内容'); - } - - if (attachment.kind === 'url' || attachment.kind === 'base64') { - return attachment; - } - - if (attachment.kind !== 'file' || !attachment.file) { - throw new Error('暂不支持的图片类型'); - } - - const dataUrl = await readFileAsDataUrl(attachment.file, onProgress); - const base64 = dataUrl.split(',')[1] || ''; - if (!base64) { - throw new Error('图片编码失败'); - } - - return { - kind: 'base64', - payload: base64, - previewUrl: attachment.previewUrl, - title: attachment.title, - subtitle: attachment.subtitle, - }; - } - - function attachFile(file, sourceLabel) { - if (!file) { - return; - } - - const attachment = createFileAttachment(file, sourceLabel); - setPendingAttachment(attachment); - setStatus((sourceLabel || '图片') + ' 已添加,点击发送即可发送'); - } - - async function sendFileDirectly(file, sourceLabel) { - const id = getConversationId(); - if (!id) { - attachFile(file, sourceLabel); - setStatus('请先选择会话,图片已加入待发送'); - return; - } - - const attachment = createFileAttachment(file, sourceLabel); - if (await sendAttachmentWithQueue(id, attachment, 'drop-')) { - revokeAttachmentPreviewUrl(attachment); - setStatus((sourceLabel || '图片') + ' 已发送'); - } - } - - async function sendFilesDirectly(files, sourceLabel) { - const imageFiles = Array.from(files || []).filter((file) => String(file.type || '').startsWith('image/')); - if (!imageFiles.length) { - return; - } - - for (let index = 0; index < imageFiles.length; index += 1) { - await sendFileDirectly(imageFiles[index], imageFiles.length > 1 ? ((sourceLabel || '拖拽图片') + ' #' + (index + 1)) : sourceLabel); - } - - if (imageFiles.length > 1) { - setStatus('已连续发送 ' + imageFiles.length + ' 张图片'); - } - } - - function confirmImageUrl() { - const url = imageUrlInput.value.trim(); - if (!url) { - setStatus('请输入图片 URL'); - return; - } - - setPendingAttachment({ - kind: 'url', - payload: url, - previewUrl: url, - title: '待发送图片 URL', - subtitle: url, - }); - closeUrlPanel(); - closeAttachmentMenu(); - setStatus('图片 URL 已添加,点击发送即可发送'); - } - - function sendAttachmentPayload(id, attachment, requestId) { - const payload = { - type: 'send_image', - requestId, - id, - }; - - if (attachment.kind === 'url') { - payload.url = attachment.payload; - } else { - payload.img = attachment.payload; - } - - return send(payload); - } - - async function sendAttachmentWithQueue(id, attachment, requestPrefix) { - if (!id) { - throw new Error('请先选择会话'); - } - if (!attachment) { - return false; - } - - const name = attachment.title || attachment.subtitle || '图片'; - const isFileAttachment = attachment.kind === 'file' && attachment.file; - const queueId = createQueueItem(name, isFileAttachment ? '读取中 0%' : '准备发送', isFileAttachment ? 0 : 20); - - let resolvedAttachment; - try { - resolvedAttachment = await resolveAttachmentPayload(attachment, (loaded, total) => { - if (!isFileAttachment) { - return; - } - - const ratio = total > 0 ? loaded / total : 0; - const progress = Math.max(1, Math.min(90, Math.round(ratio * 90))); - const percent = Math.max(0, Math.min(100, Math.round(ratio * 100))); - - updateQueueItem(queueId, { - progress, - statusText: '读取中 ' + percent + '%', - state: 'pending', - }); - }); - } catch (error) { - markQueueItemFailed(queueId, error.message || '图片读取失败'); - throw error; - } - - const requestId = createRequestId(requestPrefix); - updateQueueItem(queueId, { - progress: 95, - statusText: '等待发送确认', - state: 'pending', - }); - uploadRequestMap.set(requestId, queueId); - - if (!sendAttachmentPayload(id, resolvedAttachment, requestId)) { - uploadRequestMap.delete(requestId); - markQueueItemFailed(queueId, '发送失败'); - return false; - } - - return true; - } - - function hideSuggestions() { - currentSuggestions = []; - activeSuggestionIndex = 0; - emoticonSuggestions.classList.remove('open'); - } - - function getAutocompleteContext() { - const value = messageInput.value; - const caret = messageInput.selectionStart || 0; - const textBefore = value.slice(0, caret); - const match = textBefore.match(/(^|\s):([a-z0-9_][a-z0-9_\-]*)?$/i); - if (!match) { - return null; - } - - return { - start: caret - (match[2] ? match[2].length + 1 : 1), - end: caret, - query: (match[2] || '').toLowerCase(), - }; - } - - function refreshSuggestionHighlight() { - Array.from(emoticonSuggestions.querySelectorAll('.emoticon-option')).forEach((node, index) => { - node.classList.toggle('active', index === activeSuggestionIndex); - }); - if (currentSuggestions[activeSuggestionIndex]) { - updateSuggestionPreview(currentSuggestions[activeSuggestionIndex]); - } - } - - function updateSuggestionPreview(name) { - emoticonPreviewImage.src = buildSteamEmoticonUrl(name, true); - emoticonPreviewLabel.textContent = ':' + name + ': · ' + categorizeEmoticon(name); - } - - function applySuggestion(index = activeSuggestionIndex) { - const suggestion = currentSuggestions[index]; - const context = getAutocompleteContext(); - if (!suggestion || !context) { - hideSuggestions(); - return; - } - - const value = messageInput.value; - const replacement = ':' + suggestion + ': '; - messageInput.value = value.slice(0, context.start) + replacement + value.slice(context.end); - const caret = context.start + replacement.length; - messageInput.setSelectionRange(caret, caret); - knownEmoticons.add(suggestion); - autoResizeMessageInput(); - hideSuggestions(); - messageInput.focus(); - } - - function renderSuggestionList(names) { - Array.from(emoticonSuggestions.querySelectorAll('.emoticon-option')).forEach((node) => node.remove()); - currentSuggestions = names.slice(0, 8); - activeSuggestionIndex = 0; - - if (!currentSuggestions.length) { - hideSuggestions(); - return; - } - - updateSuggestionPreview(currentSuggestions[0]); - - currentSuggestions.forEach((name, index) => { - const option = document.createElement('button'); - option.type = 'button'; - option.className = 'emoticon-option' + (index === 0 ? ' active' : ''); - - const img = document.createElement('img'); - img.src = buildSteamEmoticonUrl(name, true); - img.alt = name; - - const label = document.createElement('div'); - label.className = 'emoticon-option-body'; - - const code = document.createElement('code'); - code.textContent = ':' + name + ':'; - - const meta = document.createElement('div'); - meta.className = 'emoticon-option-meta'; - meta.textContent = categorizeEmoticon(name); - - label.appendChild(code); - label.appendChild(meta); - - option.appendChild(img); - option.appendChild(label); - option.addEventListener('click', () => applySuggestion(index)); - option.addEventListener('mouseenter', () => { - activeSuggestionIndex = index; - refreshSuggestionHighlight(); - updateSuggestionPreview(name); - }); - emoticonSuggestions.appendChild(option); - }); - - emoticonSuggestions.classList.add('open'); - } - - function updateEmoticonSuggestions() { - const context = getAutocompleteContext(); - if (!context) { - hideSuggestions(); - return; - } - - const query = context.query; - const names = [...knownEmoticons] - .filter((name) => !query || name.toLowerCase().includes(query)) - .sort((a, b) => { - const aLower = a.toLowerCase(); - const bLower = b.toLowerCase(); - const aCategory = categorizeEmoticon(a); - const bCategory = categorizeEmoticon(b); - const aStarts = query ? aLower.startsWith(query) : true; - const bStarts = query ? bLower.startsWith(query) : true; - - if (aStarts !== bStarts) { - return aStarts ? -1 : 1; - } - - if (aCategory !== bCategory) { - return aCategory.localeCompare(bCategory); - } - - return a.localeCompare(b); - }); - - renderSuggestionList(names); - } - - async function handlePasteImage(event) { - const items = Array.from((event.clipboardData && event.clipboardData.items) || []); - const imageItem = items.find((item) => item && item.type && item.type.startsWith('image/')); - if (!imageItem) { - return false; - } - - const file = imageItem.getAsFile(); - if (!file) { - return false; - } - - event.preventDefault(); - const attachment = createFileAttachment(file, '剪切板图片'); - const id = getConversationId(); - - if (id && await sendAttachmentWithQueue(id, attachment, 'paste-')) { - revokeAttachmentPreviewUrl(attachment); - imageUrlInput.value = ''; - setStatus('剪切板图片发送中'); - return true; - } - - setPendingAttachment(attachment); - setStatus('剪切板图片已添加,点击发送即可发送'); - return true; - } - - async function handleSendMessage() { - const id = getConversationId(); - const msg = messageInput.value.trim(); - if (!id || (!msg && !pendingAttachment)) { - setStatus('请输入目标 SteamID,并填写消息或添加图片'); - return; - } - - try { - if (pendingAttachment) { - const attachment = pendingAttachment; - if (await sendAttachmentWithQueue(id, attachment, 'img-')) { - clearPendingAttachment(); - imageUrlInput.value = ''; - setStatus('图片发送中'); - } - } - - if (msg) { - if (send({ - type: 'send_message', - requestId: createRequestId('msg-'), - id, - msg, - })) { - messageInput.value = ''; - autoResizeMessageInput(); - } - } - } catch (error) { - setStatus(error.message || '发送图片失败'); - } - } - - function requestEmoticonInventory() { - send({ - type: 'get_emoticons', - requestId: createRequestId('emoticons-'), - }); - } - - function applyEmoticonInventory(data) { - emoticonInventory = (data && data.emoticons) || []; - emoticonInventory.forEach((e) => { e.name = e.name.replace(/^:+|:+$/g, ''); }); - stickerInventory = (data && data.stickers) || []; - emoticonInventory.forEach((e) => knownEmoticons.add(e.name)); - if (pickerPanel.classList.contains('open')) { - renderPickerGrid(); - } - } - - function rememberEmoticonsFromMessage(message) { - extractEmoticonNames(message).forEach((name) => knownEmoticons.add(name)); - } - - function bindEvents() { - sendMessageButton.addEventListener('click', () => { - handleSendMessage(); - }); - - attachmentButton.addEventListener('click', (event) => { - event.stopPropagation(); - closePickerPanel(); - toggleAttachmentMenu(); - }); - - pickerButton.addEventListener('click', (event) => { - event.stopPropagation(); - togglePickerPanel(); - }); - - pickerTabs.forEach((tab) => { - tab.addEventListener('click', () => { - setPickerTab(tab.dataset.tab); - }); - }); - - pickerSearch.addEventListener('input', renderPickerGrid); - - pickerPanel.addEventListener('click', (event) => { - event.stopPropagation(); - }); - - chooseImageButton.addEventListener('click', () => { - closeAttachmentMenu(); - closeUrlPanel(); - imageFileInput.click(); - }); - - chooseUrlButton.addEventListener('click', () => { - closeAttachmentMenu(); - urlPanel.classList.add('open'); - imageUrlInput.focus(); - }); - - imageFileInput.addEventListener('change', () => { - try { - const file = imageFileInput.files && imageFileInput.files[0]; - if (!file) { - setStatus('请选择图片文件'); - return; - } - attachFile(file, '本地图片'); - } catch (error) { - setStatus(error.message || '读取图片失败'); - } - }); - - confirmImageUrlButton.addEventListener('click', confirmImageUrl); - cancelImageUrlButton.addEventListener('click', () => { - closeUrlPanel(); - imageUrlInput.value = ''; - }); - clearAttachmentButton.addEventListener('click', clearPendingAttachment); - - document.addEventListener('click', (event) => { - if (!attachmentMenu.contains(event.target) && event.target !== attachmentButton) { - closeAttachmentMenu(); - } - if (!pickerPanel.contains(event.target) && event.target !== pickerButton) { - closePickerPanel(); - } - }); - - messageInput.addEventListener('keydown', (event) => { - if (emoticonSuggestions.classList.contains('open')) { - if (event.key === 'ArrowDown') { - event.preventDefault(); - activeSuggestionIndex = (activeSuggestionIndex + 1) % currentSuggestions.length; - refreshSuggestionHighlight(); - return; - } - - if (event.key === 'ArrowUp') { - event.preventDefault(); - activeSuggestionIndex = (activeSuggestionIndex - 1 + currentSuggestions.length) % currentSuggestions.length; - refreshSuggestionHighlight(); - return; - } - - if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) { - event.preventDefault(); - applySuggestion(); - return; - } - - if (event.key === 'Escape') { - event.preventDefault(); - hideSuggestions(); - return; - } - } - - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - sendMessageButton.click(); - } - }); - - messageInput.addEventListener('input', () => { - autoResizeMessageInput(); - updateEmoticonSuggestions(); - }); - messageInput.addEventListener('click', updateEmoticonSuggestions); - messageInput.addEventListener('blur', () => { - setTimeout(hideSuggestions, 120); - }); - - messageInput.addEventListener('paste', (event) => { - handlePasteImage(event).catch((error) => { - setStatus(error.message || '处理剪切板图片失败'); - }); - }); - - document.addEventListener('paste', (event) => { - if (document.activeElement === messageInput) { - return; - } - - handlePasteImage(event).catch((error) => { - setStatus(error.message || '处理剪切板图片失败'); - }); - }); - - document.addEventListener('dragenter', (event) => { - const hasFile = Array.from((event.dataTransfer && event.dataTransfer.items) || []).some((item) => item.kind === 'file'); - if (!hasFile) { - return; - } - - dragDepth += 1; - dropOverlay.classList.add('active'); - }); - - document.addEventListener('dragover', (event) => { - const hasFile = Array.from((event.dataTransfer && event.dataTransfer.items) || []).some((item) => item.kind === 'file'); - if (!hasFile) { - return; - } - - event.preventDefault(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'copy'; - } - }); - - document.addEventListener('dragleave', () => { - dragDepth = Math.max(0, dragDepth - 1); - if (dragDepth === 0) { - dropOverlay.classList.remove('active'); - } - }); - - document.addEventListener('drop', (event) => { - const files = Array.from((event.dataTransfer && event.dataTransfer.files) || []).filter((file) => String(file.type || '').startsWith('image/')); - if (!files.length) { - dragDepth = 0; - dropOverlay.classList.remove('active'); - return; - } - - event.preventDefault(); - dragDepth = 0; - dropOverlay.classList.remove('active'); - - sendFilesDirectly(files, '拖拽图片').catch((error) => { - setStatus(error.message || '发送拖拽图片失败'); - }); - }); - } - - function handleViewportChange() { - syncMessageInputPlaceholder(); - autoResizeMessageInput(); - } - - return { - applyEmoticonInventory, - bindEvents, - clearPendingUploadRequests, - handleViewportChange, - hideSuggestions, - rememberEmoticonsFromMessage, - requestEmoticonInventory, - resolveUploadRequest, - }; -} diff --git a/public/app/dom.js b/public/app/dom.js deleted file mode 100644 index 47fea61..0000000 --- a/public/app/dom.js +++ /dev/null @@ -1,68 +0,0 @@ -export function getAppDomRefs(root = document) { - const pickerPanel = root.getElementById('pickerPanel'); - - return { - targetIdInput: root.getElementById('targetId'), - historyLimitInput: root.getElementById('historyLimit'), - reloadHistoryButton: root.getElementById('reloadHistory'), - openConversationButton: root.getElementById('openConversation'), - reloadConversationsButton: root.getElementById('reloadConversations'), - conversationListEl: root.getElementById('conversationList'), - friendsListEl: root.getElementById('friendsList'), - groupsListEl: root.getElementById('groupsList'), - reloadFriendsButton: root.getElementById('reloadFriends'), - reloadGroupsButton: root.getElementById('reloadGroups'), - sidebarTabs: root.querySelectorAll('.sidebar-tab'), - sidebarTabPanels: { - conversations: root.getElementById('sidebarTabConversations'), - friends: root.getElementById('sidebarTabFriends'), - groups: root.getElementById('sidebarTabGroups'), - }, - chatTitleEl: root.getElementById('chatTitle'), - chatSubtitleEl: root.getElementById('chatSubtitle'), - feedbackStatusEl: root.getElementById('feedbackStatus'), - connectionStatusEl: root.getElementById('connectionStatus'), - connectionStatusLabelEl: root.getElementById('connectionStatusLabel'), - messagesEl: root.getElementById('messages'), - dropOverlay: root.getElementById('dropOverlay'), - sidebarEl: root.querySelector('.sidebar'), - sidebarBackdrop: root.getElementById('sidebarBackdrop'), - mobileSidebarToggleButton: root.getElementById('mobileSidebarToggle'), - closeSidebarButton: root.getElementById('closeSidebar'), - imageLightbox: root.getElementById('imageLightbox'), - imageLightboxViewport: root.getElementById('imageLightboxViewport'), - imageLightboxImage: root.getElementById('imageLightboxImage'), - imageLightboxCaption: root.getElementById('imageLightboxCaption'), - closeImageLightboxButton: root.getElementById('closeImageLightbox'), - imageZoomOutButton: root.getElementById('imageZoomOut'), - imageZoomResetButton: root.getElementById('imageZoomReset'), - imageZoomInButton: root.getElementById('imageZoomIn'), - messageInput: root.getElementById('messageInput'), - emoticonSuggestions: root.getElementById('emoticonSuggestions'), - emoticonPreviewImage: root.getElementById('emoticonPreviewImage'), - emoticonPreviewLabel: root.getElementById('emoticonPreviewLabel'), - sendMessageButton: root.getElementById('sendMessage'), - attachmentButton: root.getElementById('attachmentButton'), - attachmentMenu: root.getElementById('attachmentMenu'), - chooseImageButton: root.getElementById('chooseImageButton'), - chooseUrlButton: root.getElementById('chooseUrlButton'), - imageFileInput: root.getElementById('imageFile'), - urlPanel: root.getElementById('urlPanel'), - imageUrlInput: root.getElementById('imageUrl'), - confirmImageUrlButton: root.getElementById('confirmImageUrl'), - cancelImageUrlButton: root.getElementById('cancelImageUrl'), - attachmentPreview: root.getElementById('attachmentPreview'), - attachmentPreviewImage: root.getElementById('attachmentPreviewImage'), - attachmentPreviewTitle: root.getElementById('attachmentPreviewTitle'), - attachmentPreviewSubtitle: root.getElementById('attachmentPreviewSubtitle'), - clearAttachmentButton: root.getElementById('clearAttachment'), - uploadQueue: root.getElementById('uploadQueue'), - uploadQueueList: root.getElementById('uploadQueueList'), - pickerButton: root.getElementById('pickerButton'), - pickerPanel, - pickerSearch: root.getElementById('pickerSearch'), - pickerGrid: root.getElementById('pickerGrid'), - pickerEmpty: root.getElementById('pickerEmpty'), - pickerTabs: pickerPanel ? pickerPanel.querySelectorAll('.picker-tab') : [], - }; -} diff --git a/public/app/layout.js b/public/app/layout.js deleted file mode 100644 index ea5492e..0000000 --- a/public/app/layout.js +++ /dev/null @@ -1,54 +0,0 @@ -export function createLayoutController({ - mediaQuery, - sidebarEl, - sidebarBackdrop, - mobileSidebarToggleButton, - onResponsiveChange, -}) { - function updateViewportHeightVar() { - const viewport = window.visualViewport; - const height = viewport && Number.isFinite(viewport.height) - ? viewport.height - : window.innerHeight; - const offsetTop = viewport && Number.isFinite(viewport.offsetTop) - ? viewport.offsetTop - : 0; - document.documentElement.style.setProperty('--app-height', height + 'px'); - document.documentElement.style.setProperty('--viewport-offset-top', offsetTop + 'px'); - } - - function isMobileLayout() { - return mediaQuery.matches; - } - - function setSidebarOpen(open) { - const shouldOpen = Boolean(open && isMobileLayout()); - sidebarEl.classList.toggle('open', shouldOpen); - sidebarBackdrop.classList.toggle('open', shouldOpen); - sidebarBackdrop.setAttribute('aria-hidden', shouldOpen ? 'false' : 'true'); - mobileSidebarToggleButton.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false'); - } - - function closeSidebar() { - setSidebarOpen(false); - } - - function toggleSidebar() { - setSidebarOpen(!sidebarEl.classList.contains('open')); - } - - function syncResponsiveLayout() { - if (!isMobileLayout()) { - closeSidebar(); - } - onResponsiveChange(); - } - - return { - closeSidebar, - isMobileLayout, - syncResponsiveLayout, - toggleSidebar, - updateViewportHeightVar, - }; -} diff --git a/public/app/lightbox.js b/public/app/lightbox.js deleted file mode 100644 index d822386..0000000 --- a/public/app/lightbox.js +++ /dev/null @@ -1,459 +0,0 @@ -export function createLightboxController({ - buildCachedImageUrl, - imageLightbox, - imageLightboxViewport, - imageLightboxImage, - imageLightboxCaption, - closeImageLightboxButton, - imageZoomOutButton, - imageZoomResetButton, - imageZoomInButton, - loadManagedImage, - resetManagedImage, -}) { - let lastFocusedElementBeforeLightbox = null; - const imageLightboxState = { - scale: 1, - minScale: 1, - maxScale: 6, - offsetX: 0, - offsetY: 0, - dragging: false, - dragPointerId: null, - dragStartX: 0, - dragStartY: 0, - dragOriginX: 0, - dragOriginY: 0, - activePointers: new Map(), - pinching: false, - pinchStartDistance: 0, - pinchStartScale: 1, - pinchContentX: 0, - pinchContentY: 0, - rafPending: false, - cachedBaseSize: null, - cachedViewportRect: null, - }; - - function resolveDisplayImageUrl(url) { - const value = String(url || '').trim(); - if (!value) { - return ''; - } - - try { - const parsed = new URL(value, location.origin); - if (parsed.origin === location.origin) { - return parsed.toString(); - } - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - return buildCachedImageUrl(parsed.toString()); - } - return parsed.toString(); - } catch { - return value; - } - } - - function computeImageLightboxBaseSize() { - const viewportWidth = imageLightboxViewport.clientWidth || 1; - const viewportHeight = imageLightboxViewport.clientHeight || 1; - const naturalWidth = imageLightboxImage.naturalWidth || viewportWidth; - const naturalHeight = imageLightboxImage.naturalHeight || viewportHeight; - const fitScale = Math.min(viewportWidth / naturalWidth, viewportHeight / naturalHeight, 1); - - return { - width: naturalWidth * fitScale, - height: naturalHeight * fitScale, - viewportWidth, - viewportHeight, - }; - } - - function refreshImageLightboxBaseSize() { - imageLightboxState.cachedBaseSize = computeImageLightboxBaseSize(); - imageLightboxState.cachedViewportRect = imageLightboxViewport.getBoundingClientRect(); - } - - function getImageLightboxBaseSize() { - return imageLightboxState.cachedBaseSize || computeImageLightboxBaseSize(); - } - - function clampImageLightboxOffset() { - if (imageLightboxState.scale <= 1) { - imageLightboxState.offsetX = 0; - imageLightboxState.offsetY = 0; - return; - } - - const { width, height, viewportWidth, viewportHeight } = getImageLightboxBaseSize(); - const scaledWidth = width * imageLightboxState.scale; - const scaledHeight = height * imageLightboxState.scale; - const limitX = Math.max(0, (scaledWidth - viewportWidth) / 2); - const limitY = Math.max(0, (scaledHeight - viewportHeight) / 2); - - imageLightboxState.offsetX = Math.min(limitX, Math.max(-limitX, imageLightboxState.offsetX)); - imageLightboxState.offsetY = Math.min(limitY, Math.max(-limitY, imageLightboxState.offsetY)); - } - - function getViewportRelativePoint(clientX, clientY) { - const rect = imageLightboxState.cachedViewportRect || imageLightboxViewport.getBoundingClientRect(); - return { - x: clientX - rect.left - (rect.width / 2), - y: clientY - rect.top - (rect.height / 2), - }; - } - - function getTouchPointerList() { - return [...imageLightboxState.activePointers.values()].filter((pointer) => pointer.pointerType === 'touch'); - } - - function getTouchPointerMetrics(pointers) { - if (!pointers || pointers.length < 2) { - return null; - } - - const [first, second] = pointers; - const deltaX = second.clientX - first.clientX; - const deltaY = second.clientY - first.clientY; - - return { - distance: Math.hypot(deltaX, deltaY), - centerX: (first.clientX + second.clientX) / 2, - centerY: (first.clientY + second.clientY) / 2, - }; - } - - function applyImageLightboxTransform() { - imageLightboxState.rafPending = false; - clampImageLightboxOffset(); - imageLightboxImage.style.transform = 'translate3d(' + imageLightboxState.offsetX + 'px, ' + imageLightboxState.offsetY + 'px, 0) scale(' + imageLightboxState.scale + ')'; - } - - function updateImageLightboxTransform() { - const isActive = imageLightboxState.dragging || imageLightboxState.pinching; - imageLightboxImage.classList.toggle('is-dragging', isActive); - imageLightboxImage.style.cursor = imageLightboxState.scale > 1 - ? (isActive ? 'grabbing' : 'grab') - : 'zoom-in'; - imageZoomResetButton.textContent = Math.round(imageLightboxState.scale * 100) + '%'; - if (!imageLightboxState.rafPending) { - imageLightboxState.rafPending = true; - requestAnimationFrame(applyImageLightboxTransform); - } - } - - function scheduleTransformOnly() { - if (!imageLightboxState.rafPending) { - imageLightboxState.rafPending = true; - requestAnimationFrame(applyImageLightboxTransform); - } - } - - function beginImageLightboxDrag(pointerId, clientX, clientY) { - imageLightboxState.dragging = true; - imageLightboxState.dragPointerId = pointerId; - imageLightboxState.dragStartX = clientX; - imageLightboxState.dragStartY = clientY; - imageLightboxState.dragOriginX = imageLightboxState.offsetX; - imageLightboxState.dragOriginY = imageLightboxState.offsetY; - updateImageLightboxTransform(); - } - - function resetImageLightboxTransform() { - imageLightboxState.scale = 1; - imageLightboxState.offsetX = 0; - imageLightboxState.offsetY = 0; - imageLightboxState.dragging = false; - imageLightboxState.dragPointerId = null; - imageLightboxState.activePointers.clear(); - imageLightboxState.pinching = false; - imageLightboxState.pinchStartDistance = 0; - imageLightboxState.pinchStartScale = 1; - imageLightboxState.pinchContentX = 0; - imageLightboxState.pinchContentY = 0; - updateImageLightboxTransform(); - } - - function setImageLightboxScale(nextScale, clientX, clientY) { - const clampedScale = Math.min(imageLightboxState.maxScale, Math.max(imageLightboxState.minScale, nextScale)); - const previousScale = imageLightboxState.scale; - - if (Math.abs(clampedScale - previousScale) < 0.001) { - return; - } - - const anchorPoint = (typeof clientX === 'number' && typeof clientY === 'number') - ? getViewportRelativePoint(clientX, clientY) - : { x: 0, y: 0 }; - - imageLightboxState.offsetX = anchorPoint.x - (((anchorPoint.x - imageLightboxState.offsetX) / previousScale) * clampedScale); - imageLightboxState.offsetY = anchorPoint.y - (((anchorPoint.y - imageLightboxState.offsetY) / previousScale) * clampedScale); - imageLightboxState.scale = clampedScale; - updateImageLightboxTransform(); - } - - function beginImageLightboxPinch() { - const metrics = getTouchPointerMetrics(getTouchPointerList()); - if (!metrics) { - return; - } - - const center = getViewportRelativePoint(metrics.centerX, metrics.centerY); - imageLightboxState.pinching = true; - imageLightboxState.dragging = false; - imageLightboxState.dragPointerId = null; - imageLightboxState.pinchStartDistance = Math.max(metrics.distance, 1); - imageLightboxState.pinchStartScale = imageLightboxState.scale; - imageLightboxState.pinchContentX = (center.x - imageLightboxState.offsetX) / imageLightboxState.scale; - imageLightboxState.pinchContentY = (center.y - imageLightboxState.offsetY) / imageLightboxState.scale; - updateImageLightboxTransform(); - } - - function updateImageLightboxPinch() { - const metrics = getTouchPointerMetrics(getTouchPointerList()); - if (!metrics || !imageLightboxState.pinching) { - return; - } - - const center = getViewportRelativePoint(metrics.centerX, metrics.centerY); - const nextScale = Math.min( - imageLightboxState.maxScale, - Math.max( - imageLightboxState.minScale, - imageLightboxState.pinchStartScale * (metrics.distance / Math.max(imageLightboxState.pinchStartDistance, 1)), - ), - ); - - imageLightboxState.scale = nextScale; - imageLightboxState.offsetX = center.x - (imageLightboxState.pinchContentX * nextScale); - imageLightboxState.offsetY = center.y - (imageLightboxState.pinchContentY * nextScale); - scheduleTransformOnly(); - } - - function endImageLightboxPinch() { - if (!imageLightboxState.pinching) { - return; - } - - imageLightboxState.pinching = false; - imageLightboxState.pinchStartDistance = 0; - imageLightboxState.pinchStartScale = imageLightboxState.scale; - const remainingTouch = getTouchPointerList()[0]; - if (remainingTouch && imageLightboxState.scale > 1) { - beginImageLightboxDrag(remainingTouch.pointerId, remainingTouch.clientX, remainingTouch.clientY); - return; - } - updateImageLightboxTransform(); - } - - function openImageLightbox(url, caption) { - const displayUrl = resolveDisplayImageUrl(url); - if (!displayUrl) { - return; - } - - lastFocusedElementBeforeLightbox = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - resetImageLightboxTransform(); - loadManagedImage(imageLightboxViewport, imageLightboxImage, displayUrl, { - loadingText: '大图加载中', - errorText: '大图加载失败', - onLoad() { - refreshImageLightboxBaseSize(); - updateImageLightboxTransform(); - }, - }); - imageLightboxCaption.textContent = caption || url || ''; - imageLightbox.classList.add('open'); - imageLightbox.setAttribute('aria-hidden', 'false'); - closeImageLightboxButton.focus(); - } - - function closeImageLightbox() { - if (!imageLightbox.classList.contains('open')) { - return; - } - - imageLightbox.classList.remove('open'); - imageLightbox.setAttribute('aria-hidden', 'true'); - resetManagedImage(imageLightboxViewport, imageLightboxImage); - imageLightboxCaption.textContent = ''; - imageLightboxState.cachedBaseSize = null; - imageLightboxState.cachedViewportRect = null; - resetImageLightboxTransform(); - if (lastFocusedElementBeforeLightbox) { - lastFocusedElementBeforeLightbox.focus(); - lastFocusedElementBeforeLightbox = null; - } - } - - function makeImageZoomable(element, url, caption) { - element.classList.add('zoomable-image'); - element.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - openImageLightbox(url, caption || element.getAttribute('alt') || ''); - }); - } - - function stopImageLightboxDrag(event) { - const hadPointer = imageLightboxState.activePointers.delete(event.pointerId); - - if (imageLightboxState.pinching && getTouchPointerList().length < 2) { - endImageLightboxPinch(); - } - - if (!imageLightboxState.dragging || imageLightboxState.dragPointerId !== event.pointerId) { - if (hadPointer && imageLightboxViewport.hasPointerCapture(event.pointerId)) { - imageLightboxViewport.releasePointerCapture(event.pointerId); - } - return; - } - - imageLightboxState.dragging = false; - imageLightboxState.dragPointerId = null; - if (imageLightboxViewport.hasPointerCapture(event.pointerId)) { - imageLightboxViewport.releasePointerCapture(event.pointerId); - } - updateImageLightboxTransform(); - } - - function bindEvents() { - imageLightbox.addEventListener('click', (event) => { - if (event.target === imageLightbox) { - closeImageLightbox(); - } - }); - - imageLightboxViewport.addEventListener('wheel', (event) => { - if (!imageLightbox.classList.contains('open')) { - return; - } - - event.preventDefault(); - const factor = Math.exp(-event.deltaY * 0.0025); - setImageLightboxScale(imageLightboxState.scale * factor, event.clientX, event.clientY); - }, { passive: false }); - - imageLightboxViewport.addEventListener('pointerdown', (event) => { - if (!imageLightbox.classList.contains('open')) { - return; - } - - imageLightboxViewport.setPointerCapture(event.pointerId); - imageLightboxState.activePointers.set(event.pointerId, { - pointerId: event.pointerId, - pointerType: event.pointerType, - clientX: event.clientX, - clientY: event.clientY, - }); - - if (event.pointerType === 'touch') { - event.preventDefault(); - if (getTouchPointerList().length >= 2) { - beginImageLightboxPinch(); - return; - } - } - - if (event.button !== 0 || imageLightboxState.scale <= 1) { - return; - } - - event.preventDefault(); - beginImageLightboxDrag(event.pointerId, event.clientX, event.clientY); - }); - - imageLightboxViewport.addEventListener('pointermove', (event) => { - if (imageLightboxState.activePointers.has(event.pointerId)) { - imageLightboxState.activePointers.set(event.pointerId, { - pointerId: event.pointerId, - pointerType: event.pointerType, - clientX: event.clientX, - clientY: event.clientY, - }); - } - - if (imageLightboxState.pinching) { - event.preventDefault(); - updateImageLightboxPinch(); - return; - } - - if (!imageLightboxState.dragging || imageLightboxState.dragPointerId !== event.pointerId) { - return; - } - - imageLightboxState.offsetX = imageLightboxState.dragOriginX + (event.clientX - imageLightboxState.dragStartX); - imageLightboxState.offsetY = imageLightboxState.dragOriginY + (event.clientY - imageLightboxState.dragStartY); - scheduleTransformOnly(); - }); - - imageLightboxViewport.addEventListener('pointerup', stopImageLightboxDrag); - imageLightboxViewport.addEventListener('pointercancel', stopImageLightboxDrag); - imageLightboxViewport.addEventListener('dblclick', (event) => { - event.preventDefault(); - if (imageLightboxState.scale > 1) { - resetImageLightboxTransform(); - return; - } - setImageLightboxScale(2, event.clientX, event.clientY); - }); - - imageLightboxImage.addEventListener('load', () => { - refreshImageLightboxBaseSize(); - resetImageLightboxTransform(); - }); - - imageZoomOutButton.addEventListener('click', () => { - setImageLightboxScale(imageLightboxState.scale / 1.2); - }); - - imageZoomResetButton.addEventListener('click', resetImageLightboxTransform); - - imageZoomInButton.addEventListener('click', () => { - setImageLightboxScale(imageLightboxState.scale * 1.2); - }); - - closeImageLightboxButton.addEventListener('click', closeImageLightbox); - - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && imageLightbox.classList.contains('open')) { - closeImageLightbox(); - return; - } - - if (!imageLightbox.classList.contains('open')) { - return; - } - - if (event.key === '+' || event.key === '=') { - event.preventDefault(); - setImageLightboxScale(imageLightboxState.scale * 1.2); - } else if (event.key === '-') { - event.preventDefault(); - setImageLightboxScale(imageLightboxState.scale / 1.2); - } else if (event.key === '0') { - event.preventDefault(); - resetImageLightboxTransform(); - } - }); - } - - function handleWindowResize() { - if (imageLightbox.classList.contains('open')) { - refreshImageLightboxBaseSize(); - } - } - - bindEvents(); - - return { - closeImageLightbox, - handleWindowResize, - makeImageZoomable, - openImageLightbox, - }; -} diff --git a/public/app/managed-images.js b/public/app/managed-images.js deleted file mode 100644 index 55da590..0000000 --- a/public/app/managed-images.js +++ /dev/null @@ -1,242 +0,0 @@ -export function createManagedImageController() { - const managedImageRequestMap = new WeakMap(); - let nextManagedImageToken = 1; - - function createManagedImageHost(hostClassName, imageClassName) { - const host = document.createElement('div'); - host.className = 'image-loading-host'; - if (hostClassName) { - hostClassName.split(/\s+/).filter(Boolean).forEach((className) => host.classList.add(className)); - } - - const img = document.createElement('img'); - img.dataset.managedImage = 'true'; - img.classList.add('image-loading-target'); - if (imageClassName) { - imageClassName.split(/\s+/).filter(Boolean).forEach((className) => img.classList.add(className)); - } - - host.appendChild(img); - return { host, img }; - } - - function ensureManagedImageUi(host) { - if (host.__managedImageUi) { - return host.__managedImageUi; - } - - const overlay = document.createElement('div'); - overlay.className = 'image-loading-overlay'; - - const label = document.createElement('div'); - label.className = 'image-loading-label'; - label.textContent = '正在加载中'; - - const progress = document.createElement('div'); - progress.className = 'image-loading-progress'; - - const bar = document.createElement('div'); - bar.className = 'image-loading-progress-bar'; - - progress.appendChild(bar); - overlay.appendChild(label); - overlay.appendChild(progress); - host.appendChild(overlay); - - host.__managedImageUi = { overlay, label, progress, bar }; - return host.__managedImageUi; - } - - function setManagedImageState(host, state, labelText, progressValue, indeterminate) { - const ui = ensureManagedImageUi(host); - const nextState = state || 'idle'; - - host.classList.toggle('is-loading', nextState === 'loading'); - host.classList.toggle('is-loaded', nextState === 'loaded'); - host.classList.toggle('is-error', nextState === 'error'); - host.classList.toggle('is-indeterminate', Boolean(indeterminate)); - - if (labelText) { - ui.label.textContent = labelText; - } else if (nextState === 'error') { - ui.label.textContent = '图片加载失败'; - } else if (nextState === 'loaded') { - ui.label.textContent = ''; - } else { - ui.label.textContent = '正在加载中'; - } - - const width = Number.isFinite(progressValue) - ? Math.max(0, Math.min(100, progressValue)) - : 0; - ui.bar.style.width = width + '%'; - } - - function abortManagedImageRequest(img) { - const request = managedImageRequestMap.get(img); - if (!request) { - return; - } - - managedImageRequestMap.delete(img); - - try { - request.abort(); - } catch { - // ignore - } - } - - function revokeManagedImageObjectUrl(img) { - const objectUrl = img && img.dataset ? img.dataset.objectUrl : ''; - if (!objectUrl) { - return; - } - - try { - URL.revokeObjectURL(objectUrl); - } catch { - // ignore - } - - delete img.dataset.objectUrl; - } - - function resetManagedImage(host, img) { - abortManagedImageRequest(img); - revokeManagedImageObjectUrl(img); - delete img.dataset.managedImageLoadToken; - img.removeAttribute('src'); - - if (host) { - setManagedImageState(host, 'idle', '', 0, false); - } - } - - function cleanupManagedImages(root) { - if (!root || typeof root.querySelectorAll !== 'function') { - return; - } - - root.querySelectorAll('img[data-managed-image="true"]').forEach((img) => { - resetManagedImage(img.closest('.image-loading-host'), img); - }); - } - - function loadManagedImage(host, img, src, options) { - const settings = options || {}; - const loadingText = settings.loadingText || '正在加载中'; - const errorText = settings.errorText || '图片加载失败'; - const normalizedSrc = String(src || '').trim(); - - if (!host || !img) { - return; - } - - host.classList.add('image-loading-host'); - img.dataset.managedImage = 'true'; - img.classList.add('image-loading-target'); - - resetManagedImage(host, img); - - if (!normalizedSrc) { - setManagedImageState(host, 'error', errorText, 100, false); - if (typeof settings.onError === 'function') { - settings.onError(); - } - return; - } - - const token = String(nextManagedImageToken++); - img.dataset.managedImageLoadToken = token; - setManagedImageState(host, 'loading', loadingText, 8, true); - - const request = new XMLHttpRequest(); - managedImageRequestMap.set(img, request); - request.open('GET', normalizedSrc, true); - request.responseType = 'blob'; - - request.onprogress = (event) => { - if (img.dataset.managedImageLoadToken !== token) { - return; - } - - if (event.lengthComputable && event.total > 0) { - const percent = Math.max(1, Math.min(99, Math.round((event.loaded / event.total) * 100))); - setManagedImageState(host, 'loading', loadingText + ' ' + percent + '%', percent, false); - } else { - setManagedImageState(host, 'loading', loadingText, 32, true); - } - }; - - request.onerror = () => { - if (img.dataset.managedImageLoadToken !== token) { - return; - } - managedImageRequestMap.delete(img); - setManagedImageState(host, 'error', errorText, 100, false); - if (typeof settings.onError === 'function') { - settings.onError(); - } - }; - - request.onabort = () => { - if (img.dataset.managedImageLoadToken !== token) { - return; - } - managedImageRequestMap.delete(img); - }; - - request.onload = () => { - if (img.dataset.managedImageLoadToken !== token) { - return; - } - - managedImageRequestMap.delete(img); - - if (request.status < 200 || request.status >= 300 || !(request.response instanceof Blob)) { - setManagedImageState(host, 'error', errorText, 100, false); - if (typeof settings.onError === 'function') { - settings.onError(); - } - return; - } - - const objectUrl = URL.createObjectURL(request.response); - img.dataset.objectUrl = objectUrl; - - img.addEventListener('load', () => { - if (img.dataset.managedImageLoadToken !== token) { - return; - } - setManagedImageState(host, 'loaded', '', 100, false); - if (typeof settings.onLoad === 'function') { - settings.onLoad(); - } - }, { once: true }); - - img.addEventListener('error', () => { - if (img.dataset.managedImageLoadToken !== token) { - return; - } - revokeManagedImageObjectUrl(img); - setManagedImageState(host, 'error', errorText, 100, false); - if (typeof settings.onError === 'function') { - settings.onError(); - } - }, { once: true }); - - setManagedImageState(host, 'loading', '即将显示', 100, false); - img.src = objectUrl; - }; - - request.send(); - } - - return { - createManagedImageHost, - cleanupManagedImages, - loadManagedImage, - resetManagedImage, - }; -} diff --git a/public/app/message-bubble.js b/public/app/message-bubble.js deleted file mode 100644 index 1d88c5e..0000000 --- a/public/app/message-bubble.js +++ /dev/null @@ -1,128 +0,0 @@ -export function createMessageBubbleRenderer({ - createManagedImageHost, - makeImageZoomable, - loadManagedImage, - buildCachedImageUrl, - createRichMessageContent, - extractStickerType, - buildSteamStickerCandidateUrls, - extractImageUrls, -}) { - function extractStandaloneImageUrl(message) { - const normalizedMessage = String(message || '') - .trim() - .replace(/\[img\s+src=(https?:\/\/[^\s\]]+)[^\]]*\][\s\S]*?\[\/img\]/gi, '[img]$1[/img]'); - - if (!normalizedMessage) { - return ''; - } - - const imageUrls = extractImageUrls(normalizedMessage); - if (imageUrls.length !== 1) { - return ''; - } - - const leftoverText = normalizedMessage - .replace(/\[img\](https?:\/\/[^\s\[\]]+?)\[\/img\]/gi, '') - .replace(/]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi, '') - .replace(/https?:\/\/\S+?(?:png|jpe?g|gif|webp|bmp)(?:\?\S*)?/gi, '') - .trim(); - - return leftoverText === '' ? imageUrls[0] : ''; - } - - function renderImageBubble(bubble, entry, options = {}) { - bubble.classList.add('image-bubble'); - - const rawImageUrl = entry.imageUrl; - const { host, img } = createManagedImageHost('image-loading-host--bubble'); - img.alt = 'image'; - makeImageZoomable(host, rawImageUrl, rawImageUrl); - loadManagedImage(host, img, buildCachedImageUrl(rawImageUrl), { - onLoad: options.onAsyncLayoutChange, - onError: options.onAsyncLayoutChange, - }); - bubble.appendChild(host); - } - - function renderStickerBubble(bubble, stickerType, options = {}) { - bubble.classList.add('sticker-bubble'); - - const stickerCandidates = buildSteamStickerCandidateUrls(stickerType); - if (stickerCandidates.length) { - const stickerImage = document.createElement('img'); - stickerImage.className = 'sticker-image'; - stickerImage.alt = stickerType; - let stickerIndex = 0; - stickerImage.src = location.origin + '/proxy/sticker/' + encodeURIComponent(stickerType); - if (typeof options.onAsyncLayoutChange === 'function') { - stickerImage.addEventListener('load', options.onAsyncLayoutChange, { once: true }); - } - stickerImage.addEventListener('error', () => { - stickerIndex += 1; - if (stickerIndex < stickerCandidates.length) { - stickerImage.src = stickerCandidates[stickerIndex]; - } else { - stickerImage.remove(); - if (typeof options.onAsyncLayoutChange === 'function') { - options.onAsyncLayoutChange(); - } - } - }); - bubble.appendChild(stickerImage); - } - - const title = document.createElement('div'); - title.className = 'sticker-title'; - title.textContent = '\u2728'; - - const name = document.createElement('div'); - name.className = 'sticker-name'; - name.textContent = stickerType.replace(/^Sticker_/, ''); - - const raw = document.createElement('div'); - raw.className = 'sticker-raw'; - raw.textContent = 'Sticker'; - - bubble.appendChild(title); - bubble.appendChild(name); - bubble.appendChild(raw); - } - - function renderTextBubble(bubble, entry, options = {}) { - bubble.appendChild(createRichMessageContent(entry.message || '', options)); - } - - function renderMessageBubble(entry, options = {}) { - const bubble = document.createElement('div'); - bubble.className = 'bubble'; - - if (entry.type === 'image' || entry.imageUrl) { - renderImageBubble(bubble, entry, options); - return bubble; - } - - const stickerType = extractStickerType(entry.message); - if (stickerType) { - renderStickerBubble(bubble, stickerType, options); - return bubble; - } - - // 仅包含单张图片的消息(BBCode / HTML / 纯图片链接)走大图气泡 - const standaloneImageUrl = extractStandaloneImageUrl(entry.message); - if (standaloneImageUrl) { - renderImageBubble(bubble, { ...entry, imageUrl: standaloneImageUrl }, options); - return bubble; - } - - renderTextBubble(bubble, entry, options); - return bubble; - } - - return { - renderImageBubble, - renderMessageBubble, - renderStickerBubble, - renderTextBubble, - }; -} diff --git a/public/app/messages.js b/public/app/messages.js deleted file mode 100644 index 3ac6dcc..0000000 --- a/public/app/messages.js +++ /dev/null @@ -1,118 +0,0 @@ -export function createMessagesController({ - messagesEl, - cleanupManagedImages, - parseDateString, - sameDay, - formatDayLabel, - formatTimeLabel, - getActiveConversationId, - renderMessageBubble, -}) { - let lastRenderedEntry = null; - let pendingScrollFrame = 0; - - function scrollToBottom() { - if (pendingScrollFrame) { - cancelAnimationFrame(pendingScrollFrame); - } - - pendingScrollFrame = requestAnimationFrame(() => { - pendingScrollFrame = 0; - messagesEl.scrollTop = messagesEl.scrollHeight; - }); - } - - function clearMessages() { - cleanupManagedImages(messagesEl); - messagesEl.replaceChildren(); - lastRenderedEntry = null; - } - - function insertDivider(text, className, container = messagesEl) { - const divider = document.createElement('div'); - divider.className = className; - divider.textContent = text; - container.appendChild(divider); - } - - function appendEntry(entry, previousEntry = lastRenderedEntry, options = {}) { - if (!entry || !entry.id) { - return; - } - - const { - container = messagesEl, - autoScroll = true, - stickToBottom = autoScroll, - } = options; - - const activeId = getActiveConversationId(); - if (activeId && entry.id !== activeId) { - return; - } - - const currentDate = parseDateString(entry.date || entry.sentAt); - const previousDate = previousEntry ? parseDateString(previousEntry.date || previousEntry.sentAt) : null; - - if (currentDate && (!previousDate || !sameDay(currentDate, previousDate))) { - insertDivider(formatDayLabel(entry.date || entry.sentAt), 'day-divider', container); - } else if (currentDate && previousDate && (currentDate.getTime() - previousDate.getTime()) >= 10 * 60 * 1000) { - insertDivider(formatTimeLabel(entry.date || entry.sentAt), 'time-divider', container); - } - - const row = document.createElement('div'); - row.className = 'message-row ' + (entry.echo ? 'self' : 'other'); - - const meta = document.createElement('div'); - meta.className = 'message-meta'; - meta.textContent = (entry.name || (entry.echo ? '我' : '对方')) + ' · ' + formatTimeLabel(entry.date || entry.sentAt); - - const bubble = renderMessageBubble(entry, { - onAsyncLayoutChange: stickToBottom - ? () => { - scrollToBottom(); - } - : null, - }); - - row.appendChild(meta); - row.appendChild(bubble); - container.appendChild(row); - if (autoScroll && container === messagesEl) { - lastRenderedEntry = entry; - scrollToBottom(); - } - } - - function renderHistory(items) { - clearMessages(); - if (!items.length) { - const empty = document.createElement('div'); - empty.className = 'empty-state'; - empty.textContent = '暂无历史消息'; - messagesEl.appendChild(empty); - return; - } - - const fragment = document.createDocumentFragment(); - let previousEntry = null; - items.forEach((entry) => { - appendEntry(entry, previousEntry, { - container: fragment, - autoScroll: false, - stickToBottom: true, - }); - previousEntry = entry; - }); - messagesEl.appendChild(fragment); - lastRenderedEntry = previousEntry; - scrollToBottom(); - } - - return { - appendEntry, - clearMessages, - renderHistory, - scrollToBottom, - }; -} diff --git a/public/app/notifications.js b/public/app/notifications.js deleted file mode 100644 index 7920b9e..0000000 --- a/public/app/notifications.js +++ /dev/null @@ -1,96 +0,0 @@ -export function createNotificationController({ - defaultDocumentTitle, - getActiveConversationId, - onNotificationOpen, -}) { - let unreadCount = 0; - let notificationPermissionRequested = false; - - function updateDocumentTitle() { - document.title = unreadCount > 0 - ? '(' + unreadCount + ') ' + defaultDocumentTitle - : defaultDocumentTitle; - } - - function clearUnreadCount() { - if (!unreadCount) { - return; - } - - unreadCount = 0; - updateDocumentTitle(); - } - - function shouldRequestNotificationPermission() { - return typeof Notification !== 'undefined' - && Notification.permission === 'default' - && !notificationPermissionRequested; - } - - async function ensureNotificationPermission() { - if (!shouldRequestNotificationPermission()) { - return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission; - } - - notificationPermissionRequested = true; - - try { - return await Notification.requestPermission(); - } catch { - return Notification.permission; - } - } - - function warmupNotifications() { - ensureNotificationPermission().catch(() => {}); - } - - function shouldNotifyForEntry(entry) { - const activeId = getActiveConversationId(); - return document.hidden || !document.hasFocus() || !activeId || entry.id !== activeId; - } - - function buildNotificationBody(entry) { - if (!entry) { - return '你有一条新消息'; - } - - if (entry.type === 'image' || entry.imageUrl) { - return '[图片]'; - } - - const text = String(entry.message || '').replace(/\s+/g, ' ').trim(); - return text || '你有一条新消息'; - } - - function notifyIncomingEntry(entry) { - if (!entry || entry.echo || !shouldNotifyForEntry(entry)) { - return; - } - - unreadCount += 1; - updateDocumentTitle(); - - if (typeof Notification === 'undefined' || Notification.permission !== 'granted') { - return; - } - - const notification = new Notification(entry.name || entry.id || 'Steam Chat', { - body: buildNotificationBody(entry), - tag: 'steam-chat-' + (entry.id || 'unknown'), - }); - - notification.addEventListener('click', () => { - window.focus(); - onNotificationOpen(entry); - clearUnreadCount(); - notification.close(); - }); - } - - return { - clearUnreadCount, - notifyIncomingEntry, - warmupNotifications, - }; -} diff --git a/public/app/preferences.js b/public/app/preferences.js deleted file mode 100644 index cbac0bf..0000000 --- a/public/app/preferences.js +++ /dev/null @@ -1,34 +0,0 @@ -export function createPreferencesController({ - targetIdInput, - historyLimitInput, - storage = window.localStorage, -}) { - function loadPreferences() { - targetIdInput.value = storage.getItem('steam-chat-target-id') || ''; - historyLimitInput.value = storage.getItem('steam-chat-history-limit') || '100'; - } - - function currentTargetId() { - return targetIdInput.value.trim(); - } - - function currentHistoryLimit() { - const value = Number.parseInt(historyLimitInput.value, 10); - if (!Number.isFinite(value) || value <= 0) { - return 100; - } - return Math.min(value, 500); - } - - function savePreferences() { - storage.setItem('steam-chat-target-id', currentTargetId()); - storage.setItem('steam-chat-history-limit', String(currentHistoryLimit())); - } - - return { - currentHistoryLimit, - currentTargetId, - loadPreferences, - savePreferences, - }; -} diff --git a/public/app/rich-content.js b/public/app/rich-content.js deleted file mode 100644 index 33b79ed..0000000 --- a/public/app/rich-content.js +++ /dev/null @@ -1,159 +0,0 @@ -export function createRichContentRenderer({ - buildSteamEmoticonUrl, - buildCachedImageUrl, - extractImageUrls, - parseBbCodeAttributes, - createManagedImageHost, - makeImageZoomable, - loadManagedImage, -}) { - function appendEmoticonImage(fragment, name) { - const rawUrl = buildSteamEmoticonUrl(name, true); - if (!rawUrl) { - fragment.appendChild(document.createTextNode(':' + name + ':')); - return; - } - - const link = document.createElement('a'); - link.href = rawUrl; - link.target = '_blank'; - link.rel = 'noreferrer'; - - const img = document.createElement('img'); - img.src = buildCachedImageUrl(rawUrl); - img.alt = ':' + name + ':'; - img.title = ':' + name + ':'; - img.className = 'inline-emoticon'; - link.appendChild(img); - fragment.appendChild(link); - } - - function appendInlineImage(fragment, url, altText, options = {}) { - const link = document.createElement('a'); - link.href = url; - link.target = '_blank'; - link.rel = 'noreferrer'; - link.className = 'inline-image-link'; - - const { host, img } = createManagedImageHost('image-loading-host--inline', 'image-preview'); - img.alt = altText || '[图片]'; - img.title = altText || url; - makeImageZoomable(host, url, altText || url); - loadManagedImage(host, img, buildCachedImageUrl(url), { - onLoad: options.onAsyncLayoutChange, - onError: options.onAsyncLayoutChange, - }); - - link.appendChild(host); - fragment.appendChild(link); - } - - function appendOpenGraphCard(fragment, embed, options = {}) { - const wrapper = document.createElement('a'); - wrapper.href = embed.url; - wrapper.target = '_blank'; - wrapper.rel = 'noreferrer'; - wrapper.className = 'og-card'; - - if (embed.img) { - const { host, img } = createManagedImageHost('image-loading-host--card', 'image-preview'); - img.alt = embed.title || embed.url; - makeImageZoomable(host, embed.img, embed.title || embed.url); - loadManagedImage(host, img, buildCachedImageUrl(embed.img), { - onLoad: options.onAsyncLayoutChange, - onError: options.onAsyncLayoutChange, - }); - wrapper.appendChild(host); - } - - const title = document.createElement('div'); - title.className = 'og-card-title'; - title.textContent = embed.title || embed.url; - wrapper.appendChild(title); - - const url = document.createElement('div'); - url.className = 'og-card-url'; - url.textContent = embed.url; - wrapper.appendChild(url); - - fragment.appendChild(wrapper); - } - - function createRichMessageContent(text, options = {}) { - const fragment = document.createDocumentFragment(); - const content = String(text || '').replace(/\[img\s+src=(https?:\/\/[^\s\]]+)[^\]]*\][\s\S]*?\[\/img\]/gi, '[img]$1[/img]'); - const tokenRegex = /(\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?)|(\[emoticon\]([^\[]+)\[\/emoticon\])|(:([a-z0-9_][a-z0-9_\-]*):)|(\[img\](https?:\/\/[^\s\[\]]+?)\[\/img\])|(]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>)|(\[og\s+([^\]]+)\]([\s\S]*?)\[\/og\])|(\[url=([^\]]+)\]([\s\S]*?)\[\/url\])|(\[url\]([\s\S]*?)\[\/url\])|(https?:\/\/\S+)/gi; - let cursor = 0; - let match; - - while ((match = tokenRegex.exec(content)) !== null) { - if (match.index > cursor) { - fragment.appendChild(document.createTextNode(content.slice(cursor, match.index))); - } - - if (match[2]) { - appendEmoticonImage(fragment, match[2]); - } else if (match[4]) { - appendEmoticonImage(fragment, match[4].trim()); - } else if (match[6]) { - appendEmoticonImage(fragment, match[6]); - } else if (match[8]) { - appendInlineImage(fragment, match[8], '[img]', options); - } else if (match[10]) { - appendInlineImage(fragment, match[10], '', options); - } else if (match[11]) { - const attrs = parseBbCodeAttributes(match[12] || ''); - const fallbackUrl = String(match[13] || '').trim(); - const embed = { - url: attrs.url || fallbackUrl, - img: attrs.img || null, - title: attrs.title || '', - }; - if (embed.url) { - appendOpenGraphCard(fragment, embed, options); - } - } else if (match[14]) { - const href = match[15]; - const label = match[16] || href; - const link = document.createElement('a'); - link.href = href; - link.target = '_blank'; - link.rel = 'noreferrer'; - link.textContent = label; - fragment.appendChild(link); - } else if (match[17]) { - const href = match[18]; - const link = document.createElement('a'); - link.href = href; - link.target = '_blank'; - link.rel = 'noreferrer'; - link.textContent = href; - fragment.appendChild(link); - } else if (match[19]) { - const rawUrl = match[19]; - if (extractImageUrls(rawUrl).length > 0) { - appendInlineImage(fragment, rawUrl, rawUrl, options); - } else { - const link = document.createElement('a'); - link.href = rawUrl; - link.target = '_blank'; - link.rel = 'noreferrer'; - link.textContent = rawUrl; - fragment.appendChild(link); - } - } - - cursor = match.index + match[0].length; - } - - if (cursor < content.length) { - fragment.appendChild(document.createTextNode(content.slice(cursor))); - } - - return fragment; - } - - return { - createRichMessageContent, - }; -} diff --git a/public/app/session.js b/public/app/session.js deleted file mode 100644 index a4affd1..0000000 --- a/public/app/session.js +++ /dev/null @@ -1,97 +0,0 @@ -export function createSessionController({ - targetIdInput, - chatTitleEl, - chatSubtitleEl, - renderConversations, - renderFriends, - renderGroups, - savePreferences, - clearUnreadCount, - closeSidebar, - buildConversationPreview, -}) { - let activeConversationId = ''; - let conversations = []; - let friendsList = []; - let groupsList = []; - - function getActiveConversationId() { - return activeConversationId; - } - - function getConversations() { - return conversations; - } - - function setActiveConversation(id, name) { - activeConversationId = id || ''; - targetIdInput.value = activeConversationId; - savePreferences(); - const conversation = conversations.find((item) => item.id === activeConversationId); - chatTitleEl.textContent = name || (conversation && conversation.name) || activeConversationId || '未选择会话'; - chatSubtitleEl.textContent = activeConversationId - ? ('SteamID64: ' + activeConversationId) - : '请选择左侧会话,或手动输入 SteamID64'; - renderConversations(conversations); - - if (!document.hidden && document.hasFocus()) { - clearUnreadCount(); - } - - if (activeConversationId) { - closeSidebar(); - } - } - - function setConversations(items) { - conversations = Array.isArray(items) ? items : []; - renderConversations(conversations); - } - - function setFriends(items) { - friendsList = Array.isArray(items) ? items : []; - renderFriends(friendsList); - } - - function setGroups(items) { - groupsList = Array.isArray(items) ? items : []; - renderGroups(groupsList); - } - - function updateConversationList(entry) { - if (!entry || !entry.id) { - return; - } - - const preview = buildConversationPreview(entry); - const current = conversations.find((item) => item.id === entry.id); - - if (current) { - if (!entry.echo) { - current.name = entry.name || current.name; - } - current.updatedAt = entry.date || entry.sentAt || current.updatedAt; - current.preview = preview || current.preview; - } else { - conversations.push({ - id: entry.id, - name: entry.echo ? entry.id : (entry.name || entry.id), - updatedAt: entry.date || entry.sentAt || '', - preview, - }); - } - - conversations.sort((left, right) => String(right.updatedAt || '').localeCompare(String(left.updatedAt || ''))); - renderConversations(conversations); - } - - return { - getActiveConversationId, - getConversations, - setActiveConversation, - setConversations, - setFriends, - setGroups, - updateConversationList, - }; -} diff --git a/public/app/sidebar.js b/public/app/sidebar.js deleted file mode 100644 index 111d2de..0000000 --- a/public/app/sidebar.js +++ /dev/null @@ -1,217 +0,0 @@ -const PERSONA_STATE_LABELS = { - 0: '离线', - 1: '在线', - 2: '忙碌', - 3: '离开', - 4: '打盹', - 5: '想交易', - 6: '想玩游戏', -}; - -export function createSidebarController({ - conversationListEl, - friendsListEl, - groupsListEl, - sidebarTabs, - sidebarTabPanels, - formatConversationTime, - getActiveConversationId, - onConversationSelect, - onFriendSelect, - onGroupSelect, -}) { - function switchSidebarTab(tabName) { - sidebarTabs.forEach((tab) => { - const isActive = tab.dataset.tab === tabName; - tab.classList.toggle('active', isActive); - tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); - tab.tabIndex = isActive ? 0 : -1; - }); - Object.keys(sidebarTabPanels).forEach((key) => { - sidebarTabPanels[key].hidden = key !== tabName; - }); - } - - function bindTabKeyboardNavigation() { - sidebarTabs.forEach((tab) => { - tab.addEventListener('keydown', (event) => { - const tabs = Array.from(sidebarTabs); - const currentIndex = tabs.indexOf(tab); - if (currentIndex === -1) { - return; - } - - let nextIndex = currentIndex; - if (event.key === 'ArrowRight') { - nextIndex = (currentIndex + 1) % tabs.length; - } else if (event.key === 'ArrowLeft') { - nextIndex = (currentIndex - 1 + tabs.length) % tabs.length; - } else if (event.key === 'Home') { - nextIndex = 0; - } else if (event.key === 'End') { - nextIndex = tabs.length - 1; - } else { - return; - } - - event.preventDefault(); - const nextTab = tabs[nextIndex]; - switchSidebarTab(nextTab.dataset.tab); - nextTab.focus(); - }); - }); - } - - function renderConversations(conversations) { - conversationListEl.replaceChildren(); - - if (!conversations.length) { - const empty = document.createElement('div'); - empty.className = 'empty-state'; - empty.textContent = '暂无历史会话'; - conversationListEl.appendChild(empty); - return; - } - - const activeConversationId = getActiveConversationId(); - const fragment = document.createDocumentFragment(); - conversations.forEach((conversation) => { - const item = document.createElement('button'); - item.type = 'button'; - item.className = 'conversation-item' + (conversation.id === activeConversationId ? ' active' : ''); - - const top = document.createElement('div'); - top.className = 'conversation-top'; - - const name = document.createElement('div'); - name.className = 'conversation-name'; - name.textContent = conversation.name || conversation.id; - - const time = document.createElement('div'); - time.className = 'conversation-time'; - time.textContent = formatConversationTime(conversation.updatedAt); - - top.appendChild(name); - top.appendChild(time); - - const preview = document.createElement('div'); - preview.className = 'conversation-preview'; - preview.textContent = conversation.preview || '[空会话]'; - - const idLine = document.createElement('div'); - idLine.className = 'conversation-id'; - idLine.textContent = conversation.id; - - item.appendChild(top); - item.appendChild(preview); - item.appendChild(idLine); - item.addEventListener('click', () => onConversationSelect(conversation)); - fragment.appendChild(item); - }); - conversationListEl.appendChild(fragment); - } - - function renderFriends(friendsList) { - friendsListEl.replaceChildren(); - - if (!friendsList.length) { - const empty = document.createElement('div'); - empty.className = 'empty-state'; - empty.textContent = '暂无好友数据'; - friendsListEl.appendChild(empty); - return; - } - - const fragment = document.createDocumentFragment(); - friendsList.forEach((friend) => { - const item = document.createElement('button'); - item.type = 'button'; - item.className = 'friend-item'; - - if (friend.avatar) { - const avatar = document.createElement('img'); - avatar.className = 'friend-avatar'; - avatar.src = friend.avatar; - avatar.alt = friend.name; - avatar.loading = 'lazy'; - item.appendChild(avatar); - } else { - const placeholder = document.createElement('div'); - placeholder.className = 'friend-avatar'; - item.appendChild(placeholder); - } - - const info = document.createElement('div'); - info.className = 'friend-info'; - - const name = document.createElement('div'); - name.className = 'friend-name'; - name.textContent = friend.name || friend.id; - info.appendChild(name); - - const status = document.createElement('div'); - const isOnline = friend.status > 0; - const inGame = friend.game; - status.className = 'friend-status' + (inGame ? ' in-game' : isOnline ? ' online' : ''); - status.textContent = inGame - ? ('正在游戏: ' + friend.game) - : (PERSONA_STATE_LABELS[friend.status] || '离线'); - info.appendChild(status); - - const idLine = document.createElement('div'); - idLine.className = 'friend-id'; - idLine.textContent = friend.id; - info.appendChild(idLine); - - item.appendChild(info); - item.addEventListener('click', () => onFriendSelect(friend)); - fragment.appendChild(item); - }); - friendsListEl.appendChild(fragment); - } - - function renderGroups(groupsList) { - groupsListEl.replaceChildren(); - - if (!groupsList.length) { - const empty = document.createElement('div'); - empty.className = 'empty-state'; - empty.textContent = '暂无群组数据'; - groupsListEl.appendChild(empty); - return; - } - - const fragment = document.createDocumentFragment(); - groupsList.forEach((group) => { - const item = document.createElement('button'); - item.type = 'button'; - item.className = 'group-item'; - - const info = document.createElement('div'); - info.className = 'group-info'; - - const name = document.createElement('div'); - name.className = 'group-name'; - name.textContent = group.name || group.id; - info.appendChild(name); - - const idLine = document.createElement('div'); - idLine.className = 'group-id'; - idLine.textContent = group.id; - info.appendChild(idLine); - - item.appendChild(info); - item.addEventListener('click', () => onGroupSelect(group)); - fragment.appendChild(item); - }); - groupsListEl.appendChild(fragment); - } - - return { - bindTabKeyboardNavigation, - renderConversations, - renderFriends, - renderGroups, - switchSidebarTab, - }; -} diff --git a/public/app/status.js b/public/app/status.js deleted file mode 100644 index c2859d5..0000000 --- a/public/app/status.js +++ /dev/null @@ -1,24 +0,0 @@ -const CONNECTION_LABELS = { - connecting: '连接中', - connected: '已连接', - disconnected: '已断开', - error: '异常', -}; - -export function createStatusController({ feedbackEl, connectionChipEl, connectionLabelEl }) { - function setFeedback(text) { - feedbackEl.textContent = text || '准备就绪'; - } - - function setConnection(state, text) { - const normalizedState = CONNECTION_LABELS[state] ? state : 'connecting'; - connectionChipEl.classList.remove('is-connecting', 'is-connected', 'is-disconnected', 'is-error'); - connectionChipEl.classList.add('is-' + normalizedState); - connectionLabelEl.textContent = text || CONNECTION_LABELS[normalizedState]; - } - - return { - setFeedback, - setConnection, - }; -} diff --git a/public/app/utils.js b/public/app/utils.js deleted file mode 100644 index dc14538..0000000 --- a/public/app/utils.js +++ /dev/null @@ -1,169 +0,0 @@ -export function formatConversationTime(value) { - if (!value) { - return ''; - } - return String(value).slice(5, 16); -} - -export function parseDateString(value) { - if (!value) { - return null; - } - const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/); - if (!match) { - return null; - } - - return new Date( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - Number(match[7] || 0), - ); -} - -export function sameDay(left, right) { - return left && right - && left.getFullYear() === right.getFullYear() - && left.getMonth() === right.getMonth() - && left.getDate() === right.getDate(); -} - -export function formatDayLabel(value) { - const date = parseDateString(value); - if (!date) { - return value || ''; - } - - return date.getFullYear() + '-' - + String(date.getMonth() + 1).padStart(2, '0') + '-' - + String(date.getDate()).padStart(2, '0'); -} - -export function formatTimeLabel(value) { - const date = parseDateString(value); - if (!date) { - return value || ''; - } - - return String(date.getHours()).padStart(2, '0') + ':' - + String(date.getMinutes()).padStart(2, '0'); -} - -export function extractStickerType(message) { - const match = String(message || '').match(/\[sticker\s+type="([^"]+)"/i); - return match ? match[1] : null; -} - -export function extractEmoticonNames(message) { - const content = String(message || ''); - const names = new Set(); - - for (const match of content.matchAll(/\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?/gi)) { - if (match[1]) { - names.add(match[1]); - } - } - - for (const match of content.matchAll(/\[emoticon\]([^\[]+)\[\/emoticon\]/gi)) { - if (match[1]) { - names.add(match[1].trim()); - } - } - - for (const match of content.matchAll(/(^|\s):([a-z0-9_][a-z0-9_\-]*):(?=\s|$|[!?,.])/gi)) { - if (match[2]) { - names.add(match[2]); - } - } - - return [...names]; -} - -export function extractImageUrls(message) { - const content = String(message || ''); - const urls = new Set(); - - for (const match of content.matchAll(/\[img\](https?:\/\/[^\s\[\]]+?)\[\/img\]/gi)) { - if (match[1]) { - urls.add(match[1]); - } - } - - for (const match of content.matchAll(/]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi)) { - if (match[1]) { - urls.add(match[1]); - } - } - - for (const match of content.matchAll(/https?:\/\/\S+?(?:png|jpe?g|gif|webp|bmp)(?:\?\S*)?/gi)) { - if (match[0]) { - urls.add(match[0]); - } - } - - return [...urls]; -} - -export function parseBbCodeAttributes(rawAttributes) { - const attrs = {}; - const content = String(rawAttributes || ''); - const attributeRegex = /([a-z][a-z0-9_-]*)=(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^\s"'=<>`]+))/gi; - let match; - - while ((match = attributeRegex.exec(content)) !== null) { - const key = match[1].toLowerCase(); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - attrs[key] = value.replace(/\\(["'])/g, '$1'); - } - - return attrs; -} - -export function extractOpenGraphEmbeds(message) { - const content = String(message || ''); - const embeds = []; - - for (const match of content.matchAll(/\[og\s+([^\]]+)\]([\s\S]*?)\[\/og\]/gi)) { - const attrs = parseBbCodeAttributes(match[1] || ''); - const fallbackUrl = String(match[2] || '').trim(); - - embeds.push({ - url: attrs.url || fallbackUrl, - img: attrs.img || null, - title: attrs.title || '', - }); - } - - return embeds.filter((item) => item.url); -} - -export function buildSteamEmoticonUrl(name, large = true) { - const normalized = String(name || '').trim().replace(/^:+|:+$/g, ''); - if (!normalized) { - return ''; - } - - return 'https://steamcommunity-a.akamaihd.net/economy/' + (large ? 'emoticonlarge' : 'emoticon') + '/' + encodeURIComponent(normalized); -} - -export function buildCachedImageUrl(url) { - return location.origin + '/proxy/image?url=' + encodeURIComponent(String(url || '')); -} - -export function buildSteamStickerCandidateUrls(type) { - const normalized = String(type || '').trim(); - if (!normalized) { - return []; - } - - return [ - 'https://steamcommunity-a.akamaihd.net/economy/sticker/' + encodeURIComponent(normalized), - 'https://steamcommunity-a.akamaihd.net/economy/stickerlarge/' + encodeURIComponent(normalized), - 'https://steamcommunity.com/economy/sticker/' + encodeURIComponent(normalized), - 'https://steamcommunity.com/economy/stickerlarge/' + encodeURIComponent(normalized), - ]; -} diff --git a/public/app/websocket.js b/public/app/websocket.js deleted file mode 100644 index c10d163..0000000 --- a/public/app/websocket.js +++ /dev/null @@ -1,156 +0,0 @@ -export function createWebSocketController({ - setStatus, - setConnectionStatus, - savePreferences, - clearPendingUploadRequests, - onReady, - onEmoticons, - onConversations, - onFriends, - onGroups, - onHistory, - onMessage, - onMessageSent, - onImageSent, - onError, -}) { - let socket = null; - let nextRequestId = 1; - - function createRequestId(prefix) { - return String(prefix || 'req-') + (nextRequestId++); - } - - function send(payload) { - if (!socket || socket.readyState !== WebSocket.OPEN) { - setStatus('WebSocket 未连接'); - return false; - } - - savePreferences(); - socket.send(JSON.stringify(payload)); - return true; - } - - function requestConversations(limit = 200) { - send({ - type: 'get_conversations', - requestId: createRequestId('conv-'), - limit, - }); - } - - function requestFriends() { - send({ - type: 'get_friends', - requestId: createRequestId('friends-'), - }); - } - - function requestGroups() { - send({ - type: 'get_groups', - requestId: createRequestId('groups-'), - }); - } - - function requestHistory(id, limit) { - send({ - type: 'get_history', - requestId: createRequestId('history-'), - id, - limit, - }); - } - - function requestEmoticons() { - send({ - type: 'get_emoticons', - requestId: createRequestId('emoticons-'), - }); - } - - function connect(wsPath) { - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - setConnectionStatus('connecting', '连接中'); - socket = new WebSocket(protocol + '//' + location.host + wsPath); - - socket.addEventListener('open', () => { - setConnectionStatus('connected', '已连接'); - setStatus('WebSocket 已连接'); - }); - - socket.addEventListener('close', () => { - clearPendingUploadRequests('连接已断开'); - setConnectionStatus('connecting', '3 秒后重连'); - setStatus('WebSocket 已断开,3 秒后重连'); - setTimeout(() => connect(wsPath), 3000); - }); - - socket.addEventListener('error', () => { - setConnectionStatus('error', '连接异常'); - setStatus('WebSocket 连接异常'); - }); - - socket.addEventListener('message', (event) => { - let payload; - try { - payload = JSON.parse(event.data); - } catch { - setStatus('收到无法解析的消息'); - return; - } - - switch (payload.type) { - case 'ready': - setConnectionStatus('connected', '已同步'); - setStatus('WebSocket 已连接'); - onReady(); - break; - case 'emoticons': - onEmoticons(payload.data); - break; - case 'conversations': - onConversations(payload.data); - break; - case 'friends': - onFriends(payload.data); - break; - case 'groups': - onGroups(payload.data); - break; - case 'history': - onHistory(payload.data); - break; - case 'message': - case 'image': - onMessage(payload.data); - break; - case 'message_sent': - onMessageSent(payload); - break; - case 'image_sent': - onImageSent(payload); - break; - case 'error': - onError(payload); - break; - case 'pong': - break; - default: - console.log('unknown payload', payload); - } - }); - } - - return { - connect, - createRequestId, - requestConversations, - requestEmoticons, - requestFriends, - requestGroups, - requestHistory, - send, - }; -} diff --git a/public/index.html b/public/index.html deleted file mode 100644 index a7686ff..0000000 --- a/public/index.html +++ /dev/null @@ -1,169 +0,0 @@ - - - - - - Steam Chat - - - -
-
松开即可发送图片
-
- - -
- - -
-
-
-
-
未选择会话
-
请选择左侧会话,或手动输入 SteamID64
-
-
- -
- - 连接中 -
-
-
-
- -
-
请选择一个会话开始聊天
-
- -
-
- -
-
-
-
- -
-
-
发送队列
-
-
-
- - - -
-
-
- -
- - -
支持直接粘贴剪切板图片到输入框
-
- -
-
- - -
- -
-
加载中…
-
- -
- - -
-
-
-
- - - - diff --git a/public/style.css b/public/style.css deleted file mode 100644 index b656fde..0000000 --- a/public/style.css +++ /dev/null @@ -1,6 +0,0 @@ -@import url('/styles/base.css'); -@import url('/styles/sidebar.css'); -@import url('/styles/messages.css'); -@import url('/styles/composer.css'); -@import url('/styles/overlays.css'); -@import url('/styles/responsive.css'); diff --git a/public/styles/base.css b/public/styles/base.css deleted file mode 100644 index 133c946..0000000 --- a/public/styles/base.css +++ /dev/null @@ -1,215 +0,0 @@ -:root { - color-scheme: dark; - --page-padding: 16px; - --app-height: 100dvh; - --viewport-offset-top: 0px; - --safe-top: env(safe-area-inset-top, 0px); - --safe-bottom: env(safe-area-inset-bottom, 0px); -} -* { box-sizing: border-box; } -html, body { - margin: 0; - height: 100%; - min-height: 100%; - overflow: hidden; - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #111827; - color: #e5e7eb; -} -body { - min-height: var(--app-height); -} -.app { - height: var(--app-height); - max-height: var(--app-height); - display: grid; - grid-template-columns: 320px minmax(0, 1fr); - gap: 16px; - padding: var(--page-padding); - overflow: hidden; -} -.card { - background: #1f2937; - border: 1px solid #374151; - border-radius: 12px; - padding: 16px; -} -.sidebar, .chat-panel { - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; - gap: 16px; - min-width: 0; -} -.toolbar, .send-row, .image-row, .chat-header { - display: flex; - gap: 12px; - flex-wrap: wrap; - align-items: center; -} -.chat-header { - justify-content: space-between; -} -.chat-header-actions { - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: 8px; -} -.chat-heading { - min-width: 0; -} -.chat-title { - font-size: 18px; - font-weight: 700; - line-height: 1.35; -} -.chat-subtitle { - margin-top: 2px; - font-size: 13px; - color: #9ca3af; - line-height: 1.4; - word-break: break-word; -} -.chat-panel > .card { - flex-shrink: 0; -} -.chat-panel > #messages { - flex-shrink: 1; -} -.card-fill { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; -} -.toolbar-spaced { - margin-top: 12px; -} -.field-compact { - max-width: 160px; -} -.sidebar-section-header { - margin-bottom: 12px; -} -label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 14px; - color: #d1d5db; - flex: 1; - min-width: 180px; -} -.field-label { - display: inline-flex; - align-items: center; - min-height: 18px; -} -input, textarea, button { - border-radius: 8px; - border: 1px solid #4b5563; - background: #111827; - color: #f9fafb; - padding: 10px 12px; - font: inherit; -} -textarea { - min-height: 80px; - resize: vertical; - width: 100%; -} -button { - cursor: pointer; - background: #2563eb; - border-color: #2563eb; -} -button.secondary { - background: #374151; - border-color: #4b5563; -} -button:disabled { - opacity: 0.6; - cursor: not-allowed; -} -button, -input, -textarea { - transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; -} -button:focus-visible, -input:focus-visible, -textarea:focus-visible, -.sidebar-tab:focus-visible, -.conversation-item:focus-visible, -.friend-item:focus-visible, -.group-item:focus-visible, -.picker-item:focus-visible, -.emoticon-option:focus-visible { - outline: none; - border-color: #60a5fa; - box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); -} -.sidebar-backdrop { - position: fixed; - inset: 0; - background: rgba(2, 6, 23, 0.7); - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; - z-index: 35; -} -.sidebar-backdrop.open { - opacity: 1; - pointer-events: auto; -} -.sidebar-mobile-header, -.mobile-nav-button { - display: none; -} -.feedback-status { - font-size: 14px; - color: #93c5fd; - margin-top: 12px; - min-height: 20px; -} -.connection-chip { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 32px; - padding: 4px 10px; - border-radius: 999px; - border: 1px solid #334155; - background: #111827; - color: #cbd5e1; - font-size: 12px; - line-height: 1; -} -.connection-chip-dot { - width: 8px; - height: 8px; - border-radius: 999px; - background: #f59e0b; - box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12); -} -.connection-chip.is-connected .connection-chip-dot { - background: #22c55e; - box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12); -} -.connection-chip.is-error .connection-chip-dot, -.connection-chip.is-disconnected .connection-chip-dot { - background: #ef4444; - box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12); -} -.connection-chip.is-connecting .connection-chip-dot { - background: #f59e0b; - box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12); -} -.empty-state { - color: #9ca3af; - text-align: center; - margin: auto 0; -} diff --git a/public/styles/composer.css b/public/styles/composer.css deleted file mode 100644 index 79682a0..0000000 --- a/public/styles/composer.css +++ /dev/null @@ -1,375 +0,0 @@ -.composer { - display: flex; - flex-direction: column; - gap: 12px; - flex-shrink: 0; -} -.composer-row { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 12px; - align-items: end; -} -.composer-actions { - position: relative; - display: flex; - align-items: center; - gap: 8px; - height: 100%; -} -#sendMessage { - min-width: 72px; - min-height: 44px; -} -.icon-button { - width: 44px; - height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 22px; - padding: 0; -} -.attachment-menu { - position: absolute; - left: 0; - bottom: 52px; - width: 200px; - display: none; - flex-direction: column; - gap: 8px; - padding: 10px; - background: #0f172a; - border: 1px solid #374151; - border-radius: 10px; - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); - z-index: 20; -} -.attachment-menu.open { - display: flex; -} -.attachment-menu button { - width: 100%; - text-align: left; - background: #1f2937; - border-color: #374151; -} -.attachment-hint { - font-size: 12px; - color: #9ca3af; - line-height: 1.4; - padding: 4px 2px 0; -} -.attachment-preview { - display: none; - align-items: center; - gap: 12px; - padding: 12px; - border: 1px solid #374151; - border-radius: 12px; - background: #111827; -} -.attachment-preview.active { - display: flex; -} -.attachment-preview img { - width: 72px; - height: 72px; - object-fit: cover; - border-radius: 10px; - background: #0b1220; - border: 1px solid #374151; -} -.attachment-preview-body { - min-width: 0; - flex: 1; -} -.attachment-preview-title { - font-size: 14px; - font-weight: 600; - margin-bottom: 4px; -} -.attachment-preview-subtitle { - font-size: 12px; - color: #9ca3af; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.upload-queue { - display: none; - flex-direction: column; - gap: 8px; - padding: 12px; - border: 1px solid #374151; - border-radius: 12px; - background: #111827; -} -.upload-queue.active { - display: flex; -} -.upload-queue-title { - font-size: 13px; - color: #cbd5e1; - font-weight: 600; -} -.upload-queue-item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; -} -.upload-queue-item.is-done .upload-queue-status { - color: #86efac; -} -.upload-queue-item.is-error .upload-queue-status { - color: #fca5a5; -} -.upload-queue-name { - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.upload-queue-status { - font-size: 12px; - color: #93c5fd; -} -.upload-queue-progress { - grid-column: 1 / -1; - height: 6px; - border-radius: 999px; - background: #1f2937; - overflow: hidden; -} -.upload-queue-progress-bar { - width: 0%; - height: 100%; - background: linear-gradient(90deg, #2563eb, #60a5fa); - transition: width 0.2s ease; -} -.upload-queue-progress-bar.is-done { - background: linear-gradient(90deg, #16a34a, #4ade80); -} -.upload-queue-progress-bar.is-error { - background: linear-gradient(90deg, #dc2626, #f87171); -} -.url-panel { - display: none; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: 12px; - align-items: end; -} -.url-panel.open { - display: grid; -} -.composer-field { - position: relative; - min-width: 0; - gap: 0; -} -.composer-field .field-label { - display: none; -} -#messageInput { - min-height: 44px; - max-height: 160px; - resize: none; -} -.emoticon-suggestions { - position: absolute; - left: 0; - right: 0; - bottom: calc(100% + 10px); - display: none; - flex-direction: column; - gap: 4px; - padding: 8px; - background: #0f172a; - border: 1px solid #374151; - border-radius: 12px; - box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28); - max-height: 220px; - overflow: auto; - z-index: 25; -} -.emoticon-suggestions.open { - display: flex; -} -.emoticon-preview { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 10px 10px; - border-bottom: 1px solid #334155; - margin-bottom: 4px; -} -.emoticon-preview img { - width: 40px; - height: 40px; - object-fit: contain; - flex: none; -} -.emoticon-preview-label { - font-size: 13px; - color: #cbd5e1; -} -.emoticon-option { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - background: #111827; - border: 1px solid transparent; - border-radius: 10px; - padding: 8px 10px; - color: #e5e7eb; - text-align: left; -} -.emoticon-option.active { - border-color: #2563eb; - background: #172554; -} -.emoticon-option img { - width: 24px; - height: 24px; - object-fit: contain; - flex: none; -} -.emoticon-option code { - color: #bfdbfe; - background: transparent; - padding: 0; -} -.emoticon-option-body { - min-width: 0; -} -.emoticon-option-meta { - margin-top: 2px; - font-size: 12px; - color: #94a3b8; -} -.picker-panel { - position: absolute; - left: 0; - bottom: 52px; - width: 360px; - display: none; - flex-direction: column; - background: #0f172a; - border: 1px solid #374151; - border-radius: 12px; - box-shadow: 0 16px 32px rgba(0, 0, 0, 0.32); - z-index: 30; - max-height: 400px; -} -.picker-panel.open { - display: flex; -} -.picker-tabs { - display: flex; - border-bottom: 1px solid #374151; - flex-shrink: 0; -} -.picker-tab { - flex: 1; - padding: 10px 0; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: #9ca3af; - font-size: 14px; - cursor: pointer; - border-radius: 0; -} -.picker-tab.active { - color: #e5e7eb; - border-bottom-color: #2563eb; - background: transparent; -} -.picker-search { - padding: 8px 10px; - flex-shrink: 0; -} -.picker-search input { - width: 100%; - padding: 6px 10px; - font-size: 13px; - border-radius: 6px; -} -.picker-grid { - display: grid; - gap: 4px; - padding: 6px 10px 10px; - overflow: auto; - min-height: 0; - flex: 1; -} -.picker-grid.emoticon-grid { - grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); -} -.picker-grid.sticker-grid { - grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); -} -.picker-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - padding: 4px; - border-radius: 8px; - cursor: pointer; - border: 1px solid transparent; - background: transparent; - color: #e5e7eb; -} -.picker-item:hover { - background: #1e293b; - border-color: #334155; -} -.picker-item img { - object-fit: contain; -} -.picker-item.emoticon-item img { - width: 32px; - height: 32px; -} -.picker-item.sticker-item img { - width: 72px; - height: 72px; -} -.picker-item-name { - font-size: 10px; - color: #9ca3af; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; - text-align: center; -} -.picker-empty { - display: none; - color: #9ca3af; - text-align: center; - padding: 24px 10px; - font-size: 13px; -} -.picker-empty.active { - display: block; -} -@media (max-width: 900px) { - .picker-panel { - position: fixed; - left: 12px; - right: 12px; - bottom: calc(88px + var(--safe-bottom)); - width: auto; - max-width: none; - max-height: min(52vh, 420px); - } -} -@media (max-width: 600px) { - .picker-grid.sticker-grid { - grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); - } -} diff --git a/public/styles/messages.css b/public/styles/messages.css deleted file mode 100644 index 66d798c..0000000 --- a/public/styles/messages.css +++ /dev/null @@ -1,264 +0,0 @@ -#messages { - overflow: auto; - display: flex; - flex-direction: column; - gap: 8px; - min-height: 0; - flex: 1; - padding: 20px; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; - background: - radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 25%), - radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.08), transparent 30%), - #0f172a; -} -.day-divider, .time-divider { - align-self: center; - font-size: 12px; - color: #9ca3af; - background: rgba(17, 24, 39, 0.9); - border: 1px solid #374151; - padding: 4px 10px; - border-radius: 999px; - margin: 8px 0; -} -.message-row { - display: flex; - flex-direction: column; - gap: 4px; - max-width: 78%; -} -.message-row.self { - align-self: flex-end; - align-items: flex-end; -} -.message-row.other { - align-self: flex-start; - align-items: flex-start; -} -.message-meta { - font-size: 12px; - color: #9ca3af; - padding: 0 4px; -} -.bubble { - padding: 10px 14px; - border-radius: 12px; - background: #1f2937; - border: 1px solid #374151; - line-height: 1.6; - white-space: pre-wrap; - word-break: break-word; -} -.message-row.self .bubble { - background: #1d4ed8; - border-color: #2563eb; - color: #eff6ff; - border-bottom-right-radius: 4px; -} -.message-row.other .bubble { - border-bottom-left-radius: 4px; -} -.sticker-bubble { - min-width: 180px; - padding: 16px; - background: linear-gradient(135deg, #1e3a8a, #312e81); - border-color: #2563eb; -} -.sticker-title { - font-size: 26px; - margin-bottom: 6px; -} -.sticker-name { - font-size: 14px; - font-weight: 600; -} -.sticker-image { - max-width: min(240px, 100%); - max-height: 240px; - display: block; - margin-bottom: 10px; - object-fit: contain; - filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.28)); -} -.image-bubble { - padding: 0; - display: block; - width: min(320px, 100%); - overflow: hidden; - border: none !important; - background: transparent !important; - line-height: 0; -} -.image-bubble .image-loading-host { - display: block; - width: 100%; - border-radius: inherit; -} -.image-bubble .image-loading-host:not(.is-loaded) { - min-height: 140px; -} -.image-bubble .image-loading-target { - width: 100%; - height: auto; - max-height: 400px; - display: block; - border-radius: inherit; - object-fit: contain; -} -.bubble a { - color: inherit; - text-decoration: underline; -} -.inline-emoticon { - display: inline-block; - width: 28px; - height: 28px; - margin: 0 2px; - vertical-align: middle; - object-fit: contain; -} -.inline-image-link { - display: inline-block; - margin: 4px 4px 4px 0; -} -.og-card { - display: flex; - flex-direction: column; - gap: 8px; - margin: 6px 0; - padding: 10px; - border: 1px solid #475569; - border-radius: 12px; - background: rgba(15, 23, 42, 0.45); - color: inherit; - text-decoration: none; -} -.og-card-title { - font-weight: 600; - line-height: 1.5; -} -.og-card-url { - font-size: 12px; - opacity: 0.8; -} -.sticker-raw { - margin-top: 6px; - font-size: 12px; - opacity: 0.8; -} -.zoomable-image { - cursor: zoom-in; -} -.image-loading-host { - position: relative; - overflow: hidden; - background: #0b1220; -} -.image-loading-target { - display: block; - max-width: 100%; - max-height: 100%; - opacity: 0; - transition: opacity 0.2s ease; -} -.image-loading-host.is-loaded .image-loading-target { - opacity: 1; -} -.image-loading-host.is-error .image-loading-target { - opacity: 0.2; -} -.image-loading-overlay { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - padding: 16px; - background: - linear-gradient(180deg, rgba(15, 23, 42, 0.82), rgba(15, 23, 42, 0.92)); - color: #dbeafe; - text-align: center; - pointer-events: none; - opacity: 0; - transition: opacity 0.2s ease; -} -.image-loading-host.is-loading .image-loading-overlay, -.image-loading-host.is-error .image-loading-overlay { - opacity: 1; -} -.image-loading-label { - font-size: 13px; - font-weight: 600; - line-height: 1.5; -} -.image-loading-progress { - width: min(220px, 80%); - height: 6px; - border-radius: 999px; - background: rgba(51, 65, 85, 0.95); - overflow: hidden; -} -.image-loading-progress-bar { - width: 0%; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, #2563eb, #60a5fa); - transition: width 0.2s ease; -} -.image-loading-host.is-indeterminate .image-loading-progress-bar { - width: 35%; - animation: image-loading-indeterminate 1.1s ease-in-out infinite; -} -.image-loading-host.is-error .image-loading-progress-bar { - width: 100%; - background: linear-gradient(90deg, #dc2626, #f87171); - animation: none; -} -.image-loading-host--inline { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: min(180px, 100%); - min-height: 120px; - max-width: 280px; - max-height: 280px; - border-radius: 8px; -} -.image-loading-host--inline .image-loading-target { - max-width: 280px; - max-height: 280px; - object-fit: contain; -} -.image-loading-host--card { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - min-height: 180px; - max-height: 220px; - border-radius: 10px; -} -.image-loading-host--card .image-loading-target { - width: 100%; - max-height: 220px; - object-fit: cover; -} -@keyframes image-loading-indeterminate { - 0% { - transform: translateX(-120%); - } - 100% { - transform: translateX(320%); - } -} -.content img, .image-preview { - border-radius: 8px; - border: 1px solid #4b5563; -} -.image-loading-target.image-preview { - border: 0; -} diff --git a/public/styles/overlays.css b/public/styles/overlays.css deleted file mode 100644 index 7b978ff..0000000 --- a/public/styles/overlays.css +++ /dev/null @@ -1,125 +0,0 @@ -.drop-overlay { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - background: rgba(15, 23, 42, 0.76); - backdrop-filter: blur(2px); - z-index: 50; -} -.drop-overlay.active { - display: flex; -} -.drop-overlay-card { - padding: 28px 34px; - border: 2px dashed #60a5fa; - border-radius: 18px; - background: rgba(30, 41, 59, 0.92); - color: #dbeafe; - font-size: 18px; - font-weight: 600; - box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28); -} -.image-lightbox { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - padding: 8px; - background: rgba(2, 6, 23, 0.88); - z-index: 60; -} -.image-lightbox.open { - display: flex; -} -.image-lightbox-dialog { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: calc(100vw - 16px); - height: calc(100vh - 16px); - max-width: calc(100vw - 16px); - max-height: calc(100vh - 16px); - padding: 8px; - border-radius: 14px; - border: 1px solid #334155; - background: rgba(15, 23, 42, 0.96); - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4); - overflow: hidden; -} -.image-lightbox-viewport { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - touch-action: none; - user-select: none; -} -.image-lightbox-viewport.image-loading-host { - background: transparent; -} -.image-lightbox-close { - position: absolute; - top: 12px; - right: 12px; - width: 40px; - height: 40px; - padding: 0; - font-size: 24px; - line-height: 1; - border-radius: 999px; -} -.image-lightbox img { - display: block; - max-width: 100%; - max-height: 100%; - object-fit: contain; - border-radius: 12px; - background: #020617; - transform-origin: center center; - transition: transform 0.12s ease; - will-change: transform; - user-select: none; - -webkit-user-drag: none; -} -.image-lightbox img.is-dragging { - transition: none; -} -.image-lightbox-toolbar { - position: absolute; - left: 12px; - top: 12px; - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px; - border-radius: 12px; - background: rgba(15, 23, 42, 0.72); - backdrop-filter: blur(2px); -} -.image-lightbox-toolbar button { - min-width: 52px; - padding: 8px 10px; -} -.image-lightbox-caption { - position: absolute; - left: 12px; - right: 12px; - bottom: 12px; - padding: 8px 12px; - font-size: 12px; - color: #cbd5e1; - text-align: center; - word-break: break-all; - border-radius: 10px; - background: rgba(15, 23, 42, 0.72); - backdrop-filter: blur(2px); -} -.image-lightbox-caption:empty { - display: none; -} diff --git a/public/styles/responsive.css b/public/styles/responsive.css deleted file mode 100644 index d4066af..0000000 --- a/public/styles/responsive.css +++ /dev/null @@ -1,446 +0,0 @@ -@media (max-width: 900px) { - .app { - display: flex; - flex-direction: column; - gap: 0; - height: var(--app-height); - max-height: var(--app-height); - padding: 0; - padding-top: max(var(--safe-top), var(--viewport-offset-top)); - padding-bottom: var(--safe-bottom); - background: #111827; - overflow: hidden; - } - .sidebar { - position: fixed; - top: 0; - left: 0; - bottom: 0; - width: min(88vw, 360px); - max-width: 360px; - padding: calc(12px + var(--safe-top)) 12px calc(12px + var(--safe-bottom)); - background: #111827; - border-right: 1px solid #374151; - box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42); - transform: translateX(-105%); - transition: transform 0.24s ease; - z-index: 40; - overflow: auto; - height: auto; - max-height: none; - color: #e5e7eb; - } - .sidebar.open { - transform: translateX(0); - } - .sidebar strong, - .sidebar label { - color: #d1d5db; - } - .sidebar input { - background: #111827; - border-color: #4b5563; - color: #f9fafb; - } - .sidebar button.secondary { - background: #374151; - border-color: #4b5563; - color: #f9fafb; - } - .sidebar #openConversation { - background: #2563eb; - border-color: #2563eb; - color: #ffffff; - } - .sidebar-mobile-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 0 2px; - } - .mobile-nav-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 32px; - padding: 0 10px; - border-radius: 16px; - background: #374151; - border-color: #4b5563; - color: #f9fafb; - } - .chat-panel, - .sidebar { - gap: 0; - } - .chat-panel { - flex: 1 1 auto; - height: auto; - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - gap: 0; - overflow: hidden; - } - .card { - padding: 0; - border: none; - border-radius: 0; - background: transparent; - } - .chat-topbar { - grid-row: 1; - padding: 10px 12px; - background: #1f2937; - border-bottom: 1px solid #374151; - } - .chat-header { - flex-wrap: nowrap; - align-items: center; - } - .chat-heading { - flex: 1; - min-width: 0; - } - .chat-header-actions { - flex-wrap: nowrap; - justify-content: flex-end; - margin-left: auto; - } - .connection-chip { - min-height: 30px; - padding: 4px 8px; - } - .chat-title { - font-size: 16px; - color: #f9fafb; - } - .chat-subtitle { - display: none; - } - #messages { - grid-row: 2; - min-height: 0; - padding: 12px 10px; - background: - radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 25%), - radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.08), transparent 30%), - #0f172a; - } - .message-meta { - font-size: 11px; - color: #9ca3af; - } - .message-row { - max-width: 88%; - } - .bubble { - max-width: 100%; - padding: 9px 12px; - line-height: 1.5; - border: 1px solid #374151; - box-shadow: none; - } - .day-divider, - .time-divider { - color: #9ca3af; - background: rgba(17, 24, 39, 0.9); - border: 1px solid #374151; - } - .message-row.self .bubble { - background: #1d4ed8; - border-color: #2563eb; - color: #eff6ff; - border-bottom-right-radius: 6px; - } - .message-row.other .bubble { - background: #1f2937; - border-color: #374151; - color: #e5e7eb; - border-bottom-left-radius: 6px; - } - .bubble a { - color: inherit; - } - .image-bubble { - width: min(100%, 280px); - } - .sticker-image { - max-width: min(100%, 280px); - } - .conversation-card, - .sidebar-config-card { - background: transparent; - } - .conversation-item { - border-color: #374151; - background: #111827; - } - .conversation-item.active { - border-color: #2563eb; - background: #172554; - } - .conversation-name { - color: #f9fafb; - } - .conversation-preview { - color: #d1d5db; - } - .conversation-time, - .conversation-id, - .feedback-status { - color: #93c5fd; - } - .toolbar { - flex-direction: column; - align-items: stretch; - } - .toolbar label, - .toolbar button, - #reloadConversations, - #clearAttachment { - width: 100%; - min-width: 0; - } - .url-panel { - grid-template-columns: 1fr; - gap: 10px; - } - .attachment-preview { - align-items: flex-start; - flex-wrap: wrap; - gap: 10px; - padding: 10px; - background: #111827; - border: 1px solid #374151; - border-radius: 12px; - } - .attachment-preview img { - width: 56px; - height: 56px; - } - .attachment-preview-body { - width: calc(100% - 84px); - } - .upload-queue { - padding: 10px; - background: #111827; - border: 1px solid #374151; - border-radius: 12px; - } - .composer { - grid-row: 3; - gap: 8px; - padding: 5px 8px calc(6px + var(--safe-bottom)); - background: #1f2937; - border-top: 1px solid #374151; - box-shadow: 0 -10px 26px rgba(2, 6, 23, 0.22); - max-height: min(34vh, 260px); - overflow: auto; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; - } - .composer-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - grid-template-areas: "field actions send"; - gap: 6px 8px; - align-items: center; - } - .composer-actions { - grid-area: actions; - justify-content: flex-end; - gap: 6px; - height: auto; - } - #sendMessage { - grid-area: send; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 56px; - min-height: 36px; - padding: 0 12px; - border-radius: 999px; - } - .composer-field { - grid-area: field; - display: flex; - align-items: center; - height: 36px; - min-height: 36px; - padding: 0 10px; - border: 1px solid #334155; - border-radius: 18px; - background: #0f172a; - transition: border-color 0.2s ease, box-shadow 0.2s ease; - } - .composer-field:focus-within { - border-color: #3b82f6; - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.16); - } - .composer-field .field-label { - display: none; - } - #messageInput { - min-height: 0; - height: 34px; - max-height: 72px; - border: none; - background: transparent; - padding: 0; - line-height: 34px; - resize: none; - overflow-y: hidden; - box-shadow: none; - color: #f9fafb; - } - #messageInput::placeholder { - color: #9ca3af; - } - #messageInput:focus { - outline: none; - } - textarea, - input, - button { - font-size: 16px; - } - .icon-button { - width: 36px; - height: 36px; - font-size: 17px; - border-radius: 999px; - background: #0f172a; - border-color: #334155; - color: #dbeafe; - } - .sidebar-config-card .field-label, - .url-panel .field-label { - font-size: 12px; - } - .attachment-menu { - position: fixed; - left: 12px; - right: 12px; - bottom: calc(88px + var(--safe-bottom)); - width: auto; - max-width: none; - background: #0f172a; - border-color: #374151; - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); - } - .attachment-menu button { - background: #1f2937; - border-color: #374151; - color: #e5e7eb; - } - .attachment-hint { - color: #9ca3af; - } - .picker-panel { - background: #0f172a; - border-color: #374151; - box-shadow: 0 16px 32px rgba(0, 0, 0, 0.32); - } - .picker-tab { - color: #9ca3af; - } - .picker-tab.active { - color: #e5e7eb; - border-bottom-color: #2563eb; - } - .picker-item:hover { - background: #1e293b; - border-color: #334155; - } - .picker-item { - color: #e5e7eb; - } - .picker-item-name, - .picker-empty { - color: #9ca3af; - } - .image-lightbox-dialog { - width: 100vw; - height: 100vh; - max-width: 100vw; - max-height: 100vh; - padding: 0; - border-radius: 0; - } - .image-lightbox-toolbar { - left: 8px; - top: 8px; - gap: 6px; - padding: 6px; - } - .image-lightbox-toolbar button { - min-width: 46px; - } - .image-lightbox-close { - top: 8px; - right: 8px; - } - .image-lightbox-caption { - left: 8px; - right: 8px; - bottom: calc(8px + var(--safe-bottom)); - } -} -@media (max-width: 600px) { - :root { - --page-padding: 12px; - } - .app { - gap: 0; - } - .chat-topbar { - padding: 8px 10px; - } - .chat-header { - align-items: center; - gap: 6px; - } - .chat-header-actions { - width: auto; - justify-content: flex-end; - gap: 6px; - } - .conversation-top { - align-items: flex-start; - } - .conversation-time { - white-space: nowrap; - } - #messages { - padding: 10px 8px; - } - .attachment-preview-body { - width: 100%; - } - .composer { - padding: 4px 6px calc(4px + var(--safe-bottom)); - max-height: min(36vh, 260px); - } - .composer-row { - grid-template-columns: minmax(0, 1fr) auto auto; - grid-template-areas: "field actions send"; - gap: 6px; - } - .composer-actions { - gap: 6px; - } - .composer-field { - height: 36px; - min-height: 36px; - padding: 0 8px; - } - #messageInput { - max-height: 68px; - } - .sidebar { - width: 92vw; - } -} diff --git a/public/styles/sidebar.css b/public/styles/sidebar.css deleted file mode 100644 index 28ced01..0000000 --- a/public/styles/sidebar.css +++ /dev/null @@ -1,154 +0,0 @@ -.conversation-card { - min-height: 0; -} -.sidebar-list { - overflow: auto; - display: flex; - flex-direction: column; - gap: 8px; - min-height: 0; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; -} -.sidebar-tabs { - display: flex; - gap: 0; - margin-bottom: 12px; - border-bottom: 1px solid #374151; -} -.sidebar-tab { - flex: 1; - padding: 8px 4px; - font-size: 13px; - font-weight: 500; - color: #9ca3af; - background: none; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - transition: color 0.15s, border-color 0.15s; -} -.sidebar-tab:hover { - color: #d1d5db; -} -.sidebar-tab.active { - color: #60a5fa; - border-bottom-color: #2563eb; -} -.sidebar-tab[aria-selected="true"] { - color: #60a5fa; - border-bottom-color: #2563eb; -} -.sidebar-tab-panel { - flex: 1; - min-height: 0; - overflow: auto; - display: flex; - flex-direction: column; -} -.sidebar-tab-panel[hidden] { - display: none; -} -.conversation-item, -.friend-item, -.group-item { - border: 1px solid #374151; - border-radius: 10px; - padding: 10px 12px; - background: #111827; - cursor: pointer; - display: flex; - width: 100%; - text-align: left; - gap: 10px; -} -.conversation-item:hover, -.friend-item:hover, -.group-item:hover { - border-color: #4b5563; -} -.conversation-item { - flex-direction: column; - align-items: stretch; -} -.friend-item, -.group-item { - align-items: center; -} -.friend-avatar { - width: 36px; - height: 36px; - border-radius: 50%; - flex-shrink: 0; - background: #1f2937; -} -.friend-info, .group-info { - flex: 1; - min-width: 0; - overflow: hidden; -} -.friend-name, .group-name { - font-weight: 600; - font-size: 14px; - color: #f9fafb; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.friend-status { - font-size: 12px; - color: #9ca3af; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.friend-status.online { - color: #57cbde; -} -.friend-status.in-game { - color: #a3d977; -} -.friend-id, .group-id { - font-size: 11px; - color: #6b7280; - margin-top: 2px; -} -.conversation-item { - border: 1px solid #374151; - border-radius: 10px; - padding: 12px; - background: #111827; - cursor: pointer; -} -.conversation-item.active { - border-color: #2563eb; - background: #172554; -} -.conversation-top { - display: flex; - justify-content: space-between; - gap: 8px; - font-size: 14px; - margin-bottom: 6px; -} -.conversation-name { - font-weight: 600; - color: #f9fafb; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.conversation-time, .conversation-id { - color: #9ca3af; - font-size: 12px; -} -.conversation-id { - margin-top: 6px; -} -.conversation-preview { - font-size: 13px; - color: #d1d5db; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/src/config/load.ts b/src/config/load.ts new file mode 100644 index 0000000..c77812a --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,28 @@ +'use strict'; + +import type { UnknownRecord } from '../types'; +import { isRecord } from '../types'; + +const path = require('node:path'); +const { PROJECT_ROOT } = require('../paths'); + +function loadConfig(): UnknownRecord { + try { + const loaded: unknown = require(path.join(PROJECT_ROOT, 'config')); + return isRecord(loaded) ? loaded : {}; + } catch (error) { + if (!isRecord(error) || error.code !== 'MODULE_NOT_FOUND') throw error; + const example: unknown = require(path.join(PROJECT_ROOT, 'config.example')); + return isRecord(example) ? example : {}; + } +} + +function isChatEnabled(chatConfig: unknown) { + if (chatConfig === true) return true; + return Boolean(isRecord(chatConfig) && chatConfig.enabled !== false); +} + +module.exports = { + isChatEnabled, + loadConfig +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6173974 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,184 @@ +'use strict'; + +import type { CallbackStyleFunction, Persona, UnknownRecord } from './types'; +import { errorMessage, isRecord } from './types'; + +const winston = require('winston'); + +const { loadConfig, isChatEnabled } = require('./config/load'); +const { REFRESH_TOKEN_PATH } = require('./paths'); +const { createChatService } = require('./server/chat-service'); +const { createSteamLifecycle } = require('./steam/lifecycle'); +const { + steamIdToString +} = require('./storage/chat-log'); +const { createSteamMessageLogger } = require('./steam/message-logger'); + +type PersonaResponse = UnknownRecord & { + personas?: Persona[]; + users?: Record; +}; + +type SteamUserMain = { + users?: Record; + steamID?: unknown; + getPersonas?: (ids: string[], callback: (error: unknown, response: PersonaResponse) => void) => void; + chat?: { + sendFriendMessage?: CallbackStyleFunction; + getEmoticonList?: CallbackStyleFunction; + }; + sendFriendMessage?: CallbackStyleFunction; + getEmoticonList?: CallbackStyleFunction; + myFriends?: UnknownRecord; + myGroups?: unknown; + groups?: unknown; + on: (event: string, listener: (...args: unknown[]) => void) => void; + off?: (event: string, listener: (...args: unknown[]) => void) => void; + logOn: (options: unknown) => void; + webLogOn: () => void; + setPersona?: (state: number) => void; + EPersonaState?: { Online?: number }; + logOff?: () => void; +}; + +type SteamCommunityMain = { + setCookies?: (cookies: string[]) => void; + startConfirmationChecker?: (intervalMs: number, identitySecret: string) => void; + sendImageToUser?: CallbackStyleFunction; +}; + +type SteamUserConstructor = new (options: { renewRefreshTokens: boolean }) => SteamUserMain; +type SteamCommunityConstructor = new () => SteamCommunityMain; +type ChatServiceRuntime = { + start: () => unknown; +}; +type LogInfo = { + timestamp?: string; + level: string; + message: string; + stack?: string; +}; + +const SteamCommunity = require('steamcommunity') as SteamCommunityConstructor; +const SteamUser = require('steam-user') as SteamUserConstructor; + +const config = loadConfig(); +const steamUser = new SteamUser({ renewRefreshTokens: true }); +const steamCommunity = new SteamCommunity(); +const users: Record = {}; + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.printf((info: LogInfo) => `${info.timestamp} ${info.level}: ${info.message}${info.stack ? `\n${info.stack}` : ''}`) + ), + transports: [ + new winston.transports.Console() + ] +}); + +function getPersonaFromCache(id: string) { + return users[id] || steamUser.users?.[id] || null; +} + +function personaFromUnknown(value: unknown): Persona | null { + return isRecord(value) ? value : null; +} + +async function getUserInfo(value: unknown): Promise { + const id = steamIdToString(value); + if (!id) return { player_name: 'Unknown' }; + const cached = getPersonaFromCache(id); + if (cached) return cached; + + if (typeof steamUser.getPersonas !== 'function') { + return { player_name: 'Unknown' }; + } + + try { + const personas = await new Promise((resolve, reject) => { + steamUser.getPersonas?.([id], (error: unknown, response: PersonaResponse) => { + if (error) reject(error); + else resolve(response); + }); + }); + const persona = personas.personas?.[0] || personaFromUnknown(personas[id]) || personas.users?.[id] || null; + if (persona) { + users[id] = persona; + return persona; + } + } catch (error) { + logger.warn('Steam persona lookup failed', { id, error: errorMessage(error) }); + } + return { player_name: 'Unknown' }; +} + +async function getSelfName() { + const selfId = steamIdToString(steamUser.steamID || config.steamID); + if (!selfId) return 'Me'; + const info = await getUserInfo(selfId); + return info.player_name || info.personaName || 'Me'; +} + +const lifecycle = createSteamLifecycle({ + steamUser, + steamCommunity, + config, + logger, + refreshTokenPath: REFRESH_TOKEN_PATH +}); + +createSteamMessageLogger({ + steamUser, + getUserInfo, + getSelfName, + logger +}); + +let steamLoginPromise = lifecycle.waitForLogin(); +let steamWebLoginPromise = lifecycle.waitForWebSession(); +let chatService: ChatServiceRuntime | null = null; + +function start() { + steamLoginPromise = lifecycle.start(); + steamWebLoginPromise = lifecycle.waitForWebSession(); + if (isChatEnabled(config.chat)) { + chatService = createChatService({ + config, + steamUser, + steamCommunity, + logger, + getUserInfo, + getSelfName, + waitForLogin: steamLoginPromise, + waitForWebSession: steamWebLoginPromise, + refreshWebSession: lifecycle.refreshWebSession + }); + chatService.start(); + } + return steamLoginPromise; +} + +if (process.env.STEAM_CHAT_DISABLE_AUTOSTART !== '1') { + start().catch((error: unknown) => { + logger.error('Steam startup failed', { error: errorMessage(error) }); + process.exitCode = 1; + }); +} + +module.exports = { + chatService, + config, + getSelfName, + getUserInfo, + lifecycle, + logger, + start, + steamCommunity, + steamLoginPromise, + steamUser, + steamWebLoginPromise, + users +}; diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..803f69b --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,22 @@ +'use strict'; + +const path = require('node:path'); + +const BUILD_ROOT = path.resolve(__dirname, '..'); +const PROJECT_ROOT = path.basename(BUILD_ROOT) === 'dist' ? path.resolve(BUILD_ROOT, '..') : BUILD_ROOT; +const WEB_DIR = path.join(BUILD_ROOT, 'web'); +const LOG_DIR = path.join(PROJECT_ROOT, 'logs'); +const CHAT_LOG_PATH = path.join(LOG_DIR, 'chat.jsonl'); +const IMAGE_CACHE_DIR = path.join(LOG_DIR, 'images'); +const STICKER_CACHE_DIR = path.join(LOG_DIR, 'stickers'); +const REFRESH_TOKEN_PATH = path.join(PROJECT_ROOT, 'refresh.token'); + +module.exports = { + CHAT_LOG_PATH, + IMAGE_CACHE_DIR, + LOG_DIR, + PROJECT_ROOT, + REFRESH_TOKEN_PATH, + STICKER_CACHE_DIR, + WEB_DIR +}; diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..93cfddc --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,93 @@ +'use strict'; + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Socket } from 'node:net'; +import type { AuthConfig } from '../types'; + +const crypto = require('node:crypto'); +const { isLocalOrLanIp, normalizeIp } = require('./network'); + +type AuthChecker = { + enabled: boolean; + realm: string; + isAuthorized: (req: IncomingMessage) => boolean; + challenge: (res: ServerResponse) => void; + challengeUpgrade: (socket: Socket) => void; +}; + +function parseForwardedFor(headerValue: unknown): string { + const match = String(headerValue || '').match(/(?:^|;)\s*for="?([^";,]+)"?/i); + return match ? match[1] : ''; +} + +function headerText(value: string | string[] | undefined): string { + return Array.isArray(value) ? value[0] || '' : value || ''; +} + +function getClientIp(req: IncomingMessage, authConfig: AuthConfig): string { + if (authConfig.trustProxy) { + const forwarded = parseForwardedFor(req.headers.forwarded); + if (forwarded) return normalizeIp(forwarded); + const xff = headerText(req.headers['x-forwarded-for']).split(',')[0].trim(); + if (xff) return normalizeIp(xff); + const realIp = headerText(req.headers['x-real-ip']); + if (realIp) return normalizeIp(realIp); + } + return normalizeIp(req.socket?.remoteAddress || ''); +} + +function timingSafeTextEqual(left: unknown, right: unknown): boolean { + const leftHash = crypto.createHash('sha256').update(String(left)).digest(); + const rightHash = crypto.createHash('sha256').update(String(right)).digest(); + return crypto.timingSafeEqual(leftHash, rightHash); +} + +function createAuthChecker(authConfig: AuthConfig = {}, defaults: Pick = {}): AuthChecker { + const enabled = Boolean(authConfig.username && authConfig.password); + const realm = authConfig.realm || defaults.realm || 'Steam Chat'; + return { + enabled, + realm, + isAuthorized(req: IncomingMessage) { + if (!enabled) return true; + if (isLocalOrLanIp(getClientIp(req, authConfig))) return true; + const header = headerText(req.headers.authorization); + if (!header.startsWith('Basic ')) return false; + let username = ''; + let password = ''; + try { + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); + const splitAt = decoded.indexOf(':'); + username = splitAt === -1 ? decoded : decoded.slice(0, splitAt); + password = splitAt === -1 ? '' : decoded.slice(splitAt + 1); + } catch (_) { + return false; + } + return timingSafeTextEqual(username, authConfig.username) && timingSafeTextEqual(password, authConfig.password); + }, + challenge(res: ServerResponse) { + res.writeHead(401, { + 'Content-Type': 'application/json; charset=utf-8', + 'WWW-Authenticate': `Basic realm="${realm.replace(/"/g, '')}", charset="UTF-8"` + }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + }, + challengeUpgrade(socket: Socket) { + socket.write([ + 'HTTP/1.1 401 Unauthorized', + `WWW-Authenticate: Basic realm="${realm.replace(/"/g, '')}", charset="UTF-8"`, + 'Connection: close', + '', + '' + ].join('\r\n')); + socket.destroy(); + } + }; +} + +module.exports = { + createAuthChecker, + getClientIp, + parseForwardedFor, + timingSafeTextEqual +}; diff --git a/src/server/chat-service.ts b/src/server/chat-service.ts new file mode 100644 index 0000000..bc752cd --- /dev/null +++ b/src/server/chat-service.ts @@ -0,0 +1,781 @@ +'use strict'; + +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import type { Duplex } from 'node:stream'; +import type { RawData, WebSocket as WsConnection, WebSocketServer as WsServer } from 'ws'; +import type { + AuthConfig, + CallbackStyleFunction, + ChatConfig, + ConversationSummary, + HistoryItem, + LoggerLike, + Persona, + UnknownRecord +} from '../types'; +import { errorCode, errorMessage, isRecord } from '../types'; + +const fs = require('node:fs/promises'); +const http = require('node:http'); +const path = require('node:path'); +const { URL } = require('node:url'); +const { WebSocketServer, WebSocket } = require('ws'); + +const { + DEFAULT_LOG_PATH, + appendLog, + buildConversations, + extractStickerType, + formatDate, + limitFrom, + normalizeHistoryItem, + previewForMessage, + readHistory, + steamIdToString +} = require('../storage/chat-log'); +const { + IMAGE_CACHE_DIR, + STICKER_CACHE_DIR, + cacheKeyForUrl, + inferImageContentType, + isAllowedRemoteImageUrl, + loadOrDownloadRemoteImage, + loadOrDownloadSticker, + stickerUrlForType +} = require('../storage/media-cache'); +const { WEB_DIR } = require('../paths'); +const { createAuthChecker } = require('./auth'); +const { isLocalOrLanIp, normalizeIp } = require('./network'); + +type Waiter = Promise | (() => Promise | unknown); + +type SteamChatApi = { + sendFriendMessage?: CallbackStyleFunction; + getEmoticonList?: CallbackStyleFunction; +}; + +type SteamUserLike = { + chat?: SteamChatApi; + sendFriendMessage?: CallbackStyleFunction; + getEmoticonList?: CallbackStyleFunction; + on?: (event: 'friendMessage' | 'friendMessageEcho', listener: (...args: unknown[]) => void) => void; + myFriends?: UnknownRecord; + users?: Record; + myGroups?: unknown; + groups?: unknown; +}; + +type SteamCommunityLike = { + sendImageToUser?: CallbackStyleFunction; +}; + +type EmoticonPayload = { + emoticons: unknown[]; + stickers: unknown[]; +}; + +type GetEmoticonsOptions = { + steamUser?: SteamUserLike; + waitForLogin: Waiter; + waitForWebSession: Waiter; +}; + +type ChatServiceOptions = { + config?: unknown; + chatConfig?: unknown; + logger?: LoggerLike; + logPath?: string; + steamUser?: SteamUserLike; + steamCommunity?: SteamCommunityLike; + waitForLogin?: Waiter; + steamLoginPromise?: Waiter; + waitForWebSession?: Waiter; + steamWebLoginPromise?: Waiter; + refreshWebSession?: () => Promise | unknown; + getUserInfo?: (value: unknown) => Promise; + getSelfName?: () => Promise; + getEmoticons?: (options: GetEmoticonsOptions) => Promise; + fetchImpl?: typeof fetch; + server?: Server; +}; + +type WsPayload = UnknownRecord & { + requestId?: string; + type?: string; +}; + +type ImageBody = UnknownRecord & { + img?: string; + url?: string; +}; + +type FriendSummary = { + id: string; + name: string; + avatar: string; + personaState: unknown; + online: boolean; + gameName: string; +}; + +type GroupSummary = { + id: string; + clanId: string; + name: string; +}; + +const DEFAULT_CHAT_CONFIG: ChatConfig = { + enabled: true, + host: '0.0.0.0', + port: 3000, + wsPath: '/ws', + auth: { + username: '', + password: '', + realm: 'Steam Chat', + trustProxy: false + } +}; + +const MAX_BODY_BYTES = 10 * 1024 * 1024; +const MAX_WS_CONNECTIONS = 100; +const PUBLIC_DIR = WEB_DIR; + +function stringProp(record: UnknownRecord, key: string, fallback: string): string { + const value = record[key]; + return typeof value === 'string' ? value : fallback; +} + +function numberProp(record: UnknownRecord, key: string, fallback: number): number { + const value = record[key]; + return typeof value === 'number' ? value : fallback; +} + +function arrayFromUnknown(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function resolveWaiter(waiter: Waiter): Promise { + return Promise.resolve(typeof waiter === 'function' ? waiter() : waiter); +} + +function statusCodeForError(error: unknown): number { + if (isRecord(error) && typeof error.statusCode === 'number') return error.statusCode; + return error instanceof SyntaxError ? 400 : 500; +} + +function normalizeChatConfig(config: unknown): ChatConfig { + if (config === true || config == null) { + return { ...DEFAULT_CHAT_CONFIG, auth: { ...DEFAULT_CHAT_CONFIG.auth } }; + } + if (!isRecord(config)) { + return { ...DEFAULT_CHAT_CONFIG, auth: { ...DEFAULT_CHAT_CONFIG.auth } }; + } + const authInput = isRecord(config.auth) ? config.auth : {}; + const auth: AuthConfig = { + username: typeof authInput.username === 'string' ? authInput.username : DEFAULT_CHAT_CONFIG.auth.username, + password: typeof authInput.password === 'string' ? authInput.password : DEFAULT_CHAT_CONFIG.auth.password, + realm: typeof authInput.realm === 'string' ? authInput.realm : DEFAULT_CHAT_CONFIG.auth.realm, + trustProxy: typeof authInput.trustProxy === 'boolean' ? authInput.trustProxy : DEFAULT_CHAT_CONFIG.auth.trustProxy + }; + return { + ...DEFAULT_CHAT_CONFIG, + enabled: typeof config.enabled === 'boolean' ? config.enabled : DEFAULT_CHAT_CONFIG.enabled, + host: stringProp(config, 'host', DEFAULT_CHAT_CONFIG.host), + port: numberProp(config, 'port', DEFAULT_CHAT_CONFIG.port), + wsPath: stringProp(config, 'wsPath', DEFAULT_CHAT_CONFIG.wsPath), + auth + }; +} + +function jsonResponse(res: ServerResponse, statusCode: number, payload: unknown, headers: Record = {}) { + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + ...headers + }); + res.end(JSON.stringify(payload)); +} + +function textResponse(res: ServerResponse, statusCode: number, payload: string | Buffer, headers: Record = {}) { + res.writeHead(statusCode, headers); + res.end(payload); +} + +function readRequestBody(req: IncomingMessage, maxBytes = MAX_BODY_BYTES): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + reject(Object.assign(new Error('Request body too large'), { statusCode: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +async function readJsonBody(req: IncomingMessage): Promise { + const body = await readRequestBody(req); + if (!body.trim()) return {}; + const parsed: unknown = JSON.parse(body); + return isRecord(parsed) ? parsed : {}; +} + +function contentTypeForPath(filePath: string) { + const ext = path.extname(filePath).toLowerCase(); + const types: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml' + }; + return types[ext] || 'application/octet-stream'; +} + +function staticFileForUrl(pathname: string) { + const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, ''); + const filePath = path.resolve(PUBLIC_DIR, relative); + if (!filePath.startsWith(`${PUBLIC_DIR}${path.sep}`) && filePath !== path.join(PUBLIC_DIR, 'index.html')) { + return null; + } + return filePath; +} + +async function serveStatic(req: IncomingMessage, res: ServerResponse, pathname: string) { + const filePath = staticFileForUrl(pathname); + if (!filePath) { + jsonResponse(res, 404, { error: 'Not found' }); + return true; + } + try { + const data = await fs.readFile(filePath); + textResponse(res, 200, data, { + 'Content-Type': contentTypeForPath(filePath), + 'Cache-Control': pathname === '/' ? 'no-store' : 'public, max-age=60' + }); + return true; + } catch (error) { + if (isRecord(error) && error.code === 'ENOENT') return false; + throw error; + } +} + +function isTransientSteamError(error: unknown): boolean { + const text = errorCode(error).toLowerCase(); + return ['timeout', 'econnreset', 'econnrefused', 'socket', 'network', 'temporar', 'busy', 'unavailable'].some((needle) => text.includes(needle)); +} + +function isSessionExpiredError(error: unknown): boolean { + const text = errorCode(error).toLowerCase(); + return ['session', 'not logged in', 'notloggedin', 'access denied', 'forbidden', 'eresult 15'].some((needle) => text.includes(needle)); +} + +async function callMaybeCallback(fn: CallbackStyleFunction, context: unknown, args: unknown[]): Promise { + return new Promise((resolve, reject) => { + let settled = false; + function callback(error: unknown, result: unknown) { + if (settled) return; + settled = true; + if (error) reject(error); + else resolve(result); + } + try { + const result = fn.apply(context, [...args, callback]); + if (isRecord(result) && typeof result.then === 'function') { + Promise.resolve(result).then((value) => { + if (!settled) { + settled = true; + resolve(value); + } + }, (error: unknown) => { + if (!settled) { + settled = true; + reject(error); + } + }); + } else if (fn.length < args.length + 1) { + settled = true; + resolve(result); + } + } catch (error) { + reject(error); + } + }); +} + +function decodeBase64Image(input: unknown): Buffer { + const text = String(input || ''); + const match = text.match(/^data:([^;,]+)?;base64,(.*)$/i); + return Buffer.from(match ? match[2] : text, 'base64'); +} + +function sendWs(ws: WsConnection, payload: unknown) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(payload)); + } +} + +function createChatService(options: ChatServiceOptions = {}) { + const rawConfig = isRecord(options.config) && 'chat' in options.config + ? options.config.chat + : options.chatConfig ?? options.config ?? true; + const config = normalizeChatConfig(rawConfig); + const logger = options.logger || console; + const logPath = options.logPath || DEFAULT_LOG_PATH; + const steamUser = options.steamUser; + const steamCommunity = options.steamCommunity; + const waitForLogin = options.waitForLogin || options.steamLoginPromise || Promise.resolve(); + const waitForWebSession = options.waitForWebSession || options.steamWebLoginPromise || Promise.resolve(); + const refreshWebSession = options.refreshWebSession || (async () => {}); + const getUserInfo: (value: unknown) => Promise = options.getUserInfo || (async () => ({ player_name: 'Unknown' })); + const getSelfName = options.getSelfName || (async () => 'Me'); + const getEmoticons = options.getEmoticons || defaultGetEmoticons; + const fetchImpl = options.fetchImpl; + const auth = createAuthChecker(config.auth); + const clients = new Set(); + const recentSentText = new Map(); + const recentSentImages = new Map(); + + const server: Server = options.server || http.createServer(handleHttpRequest); + const wss: WsServer = new WebSocketServer({ noServer: true }); + + function remember(map: Map, key: string, ttl = 30 * 1000) { + map.set(key, Date.now()); + setTimeout(() => map.delete(key), ttl).unref?.(); + } + + function isRecent(map: Map, key: string) { + return map.has(key); + } + + function broadcast(payload: unknown, except?: WsConnection) { + for (const ws of clients) { + if (ws !== except) sendWs(ws, payload); + } + } + + async function withSteamRetry(operation: () => Promise | T, needsWebSession = false): Promise { + await resolveWaiter(waitForLogin); + if (needsWebSession) { + await resolveWaiter(waitForWebSession); + } + try { + return await operation(); + } catch (error) { + if (isSessionExpiredError(error)) { + await refreshWebSession(); + return operation(); + } + if (isTransientSteamError(error)) { + return operation(); + } + throw error; + } + } + + async function sendTextMessage(id: unknown, msg: unknown): Promise { + if (!id || !String(msg || '').trim()) { + throw Object.assign(new Error('id and msg are required'), { statusCode: 400 }); + } + if (!steamUser?.chat?.sendFriendMessage && !steamUser?.sendFriendMessage) { + throw new Error('Steam chat sender is unavailable'); + } + const message = String(msg); + await withSteamRetry(() => { + const sender = steamUser.chat?.sendFriendMessage || steamUser.sendFriendMessage; + const context = steamUser.chat?.sendFriendMessage ? steamUser.chat : steamUser; + if (!sender) throw new Error('Steam chat sender is unavailable'); + return callMaybeCallback(sender, context, [id, message]); + }); + remember(recentSentText, `${id}:${message}`); + const item = await appendLog({ + type: 'message', + echo: true, + id, + name: await getSelfName(), + message + }, { logPath }); + broadcast({ type: 'message', ...item }); + return item; + } + + async function sendImageMessage(id: unknown, body: ImageBody): Promise { + if (!id) throw Object.assign(new Error('id is required'), { statusCode: 400 }); + if (!steamCommunity || typeof steamCommunity.sendImageToUser !== 'function') { + throw new Error('Steam image sender is unavailable'); + } + let imageBuffer: Buffer; + let imageUrl: string | null = body.url || null; + if (body.img) { + imageBuffer = decodeBase64Image(body.img); + } else if (body.url) { + const downloaded = await loadOrDownloadRemoteImage(body.url, { fetchImpl }); + imageBuffer = downloaded.buffer; + remember(recentSentImages, body.url); + } else { + throw Object.assign(new Error('img or url is required'), { statusCode: 400 }); + } + const imageArgs = steamCommunity.sendImageToUser.length >= 4 ? [id, imageBuffer, 'image.png'] : [id, imageBuffer]; + const result = await withSteamRetry(() => callMaybeCallback(steamCommunity.sendImageToUser, steamCommunity, imageArgs), true); + if (!imageUrl && isRecord(result) && typeof result.url === 'string') imageUrl = result.url; + if (imageUrl) remember(recentSentImages, imageUrl); + const item = await appendLog({ + type: 'image', + echo: true, + id, + name: await getSelfName(), + message: '', + imageUrl, + sentAt: new Date().toISOString() + }, { logPath }); + return item; + } + + function containsRecentImageEcho(message: unknown): boolean { + const text = String(message || ''); + for (const url of recentSentImages.keys()) { + if (text.includes(url)) return true; + } + return false; + } + + async function handleSteamIncoming(steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) { + const id = steamIdToString(steamID); + if (containsRecentImageEcho(message)) return; + const info = await getUserInfo(steamID).catch((): Persona => ({ player_name: id })); + const item = normalizeHistoryItem({ + type: 'message', + id, + name: info.player_name || info.personaName || id, + message: typeof message === 'string' ? message : '', + ordinal: typeof ordinal === 'string' || typeof ordinal === 'number' ? ordinal : null + }); + broadcast({ type: 'message', ...item }); + } + + async function handleSteamEcho(steamID: unknown, message: unknown, ordinal?: unknown) { + const id = steamIdToString(steamID); + if (isRecent(recentSentText, `${id}:${message}`) || containsRecentImageEcho(message)) return; + const item = normalizeHistoryItem({ + type: 'message', + echo: true, + id, + name: await getSelfName(), + message: typeof message === 'string' ? message : '', + ordinal: typeof ordinal === 'string' || typeof ordinal === 'number' ? ordinal : null + }); + broadcast({ type: 'message', ...item }); + } + + async function handleHttpRequest(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + try { + if (!auth.isAuthorized(req)) { + auth.challenge(res); + return; + } + + if (req.method === 'GET') { + if (url.pathname === '/api/config') { + jsonResponse(res, 200, { wsPath: config.wsPath }); + return; + } + if (url.pathname === '/api/emoticons') { + const data = await getEmoticons({ steamUser, waitForLogin, waitForWebSession }); + jsonResponse(res, 200, data); + return; + } + if (url.pathname === '/api/friends') { + jsonResponse(res, 200, await listFriends(steamUser)); + return; + } + if (url.pathname === '/api/groups') { + jsonResponse(res, 200, await listGroups(steamUser)); + return; + } + if (url.pathname === '/history') { + jsonResponse(res, 200, await readHistory({ + logPath, + id: url.searchParams.get('id'), + limit: url.searchParams.get('limit'), + logger + })); + return; + } + if (url.pathname === '/conversations') { + jsonResponse(res, 200, await buildConversations({ + logPath, + limit: url.searchParams.get('limit'), + getUserInfo, + logger + })); + return; + } + if (url.pathname.startsWith('/proxy/sticker/')) { + const type = decodeURIComponent(url.pathname.slice('/proxy/sticker/'.length)); + const sticker = await loadOrDownloadSticker(type, { fetchImpl }); + textResponse(res, 200, sticker.buffer, { 'Content-Type': sticker.contentType, 'Cache-Control': 'public, max-age=86400' }); + return; + } + if (url.pathname === '/proxy/image') { + const source = url.searchParams.get('url') || ''; + const image = await loadOrDownloadRemoteImage(source, { fetchImpl }); + textResponse(res, 200, image.buffer, { 'Content-Type': image.contentType, 'Cache-Control': 'public, max-age=86400' }); + return; + } + if (await serveStatic(req, res, url.pathname)) return; + jsonResponse(res, 404, { error: 'Not found' }); + return; + } + + if (req.method === 'POST' && (url.pathname === '/' || url.pathname === '/message')) { + const body = await readJsonBody(req); + const item = await sendTextMessage(body.id, body.msg); + jsonResponse(res, 200, { ok: true, item }); + return; + } + + if (req.method === 'POST' && (url.pathname === '/image' || url.pathname === '/img')) { + const body = await readJsonBody(req); + const item = await sendImageMessage(body.id, body); + broadcast({ type: 'image', ...item }); + jsonResponse(res, 200, { ok: true, item }); + return; + } + + jsonResponse(res, req.method === 'GET' ? 404 : 405, { error: 'Not found' }); + } catch (error) { + jsonResponse(res, statusCodeForError(error), { error: errorMessage(error) || 'Internal Server Error' }); + } + } + + async function handleWsMessage(ws: WsConnection, raw: RawData) { + let payload: WsPayload; + try { + const parsed: unknown = JSON.parse(raw.toString()); + payload = isRecord(parsed) ? parsed : {}; + } catch (_) { + sendWs(ws, { type: 'error', error: 'Invalid JSON' }); + return; + } + + const requestId = typeof payload.requestId === 'string' ? payload.requestId : ''; + const type = payload.type; + const reply = (message: UnknownRecord) => sendWs(ws, requestId ? { requestId, ...message } : message); + try { + if (type === 'ping') { + reply({ type: 'pong' }); + return; + } + if (type === 'send_message' || type === 'msg') { + const item = await sendTextMessage(payload.id, payload.msg || payload.message || ''); + reply({ type: 'message_sent', item }); + return; + } + if (type === 'send_image' || type === 'img') { + const item = await sendImageMessage(payload.id, payload); + reply({ type: 'image_sent', item }); + broadcast({ type: 'image', ...item }, ws); + return; + } + if (type === 'get_history' || type === 'history') { + reply({ + type: 'history', + items: await readHistory({ logPath, id: payload.id, limit: payload.limit, logger }) + }); + return; + } + if (type === 'get_conversations' || type === 'conversations') { + reply({ + type: 'conversations', + conversations: await buildConversations({ logPath, limit: payload.limit, getUserInfo, logger }) + }); + return; + } + if (type === 'get_emoticons' || type === 'emoticons') { + reply({ type: 'emoticons', ...(await getEmoticons({ steamUser, waitForLogin, waitForWebSession })) }); + return; + } + if (type === 'get_friends' || type === 'friends') { + reply({ type: 'friends', friends: await listFriends(steamUser) }); + return; + } + if (type === 'get_groups' || type === 'groups') { + reply({ type: 'groups', groups: await listGroups(steamUser) }); + return; + } + reply({ type: 'error', error: `Unsupported WebSocket type: ${type || 'unknown'}` }); + } catch (error) { + reply({ type: 'error', error: errorMessage(error) || 'Request failed' }); + } + } + + server.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + if (url.pathname !== config.wsPath) { + socket.destroy(); + return; + } + if (!auth.isAuthorized(req)) { + auth.challengeUpgrade(socket); + return; + } + wss.handleUpgrade(req, socket, head, (ws: WsConnection) => { + wss.emit('connection', ws, req); + }); + }); + + wss.on('connection', (ws: WsConnection) => { + if (clients.size >= MAX_WS_CONNECTIONS) { + ws.close(1013, 'Too many connections'); + return; + } + clients.add(ws); + sendWs(ws, { type: 'ready', wsPath: config.wsPath }); + ws.on('message', (raw: RawData) => handleWsMessage(ws, raw)); + ws.on('close', () => clients.delete(ws)); + ws.on('error', () => clients.delete(ws)); + }); + + if (steamUser?.on) { + steamUser.on('friendMessage', (steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) => { + handleSteamIncoming(steamID, message, type, chatter, ordinal).catch((error) => { + logger.warn?.('Failed to broadcast Steam message', { error: errorMessage(error) }); + }); + }); + steamUser.on('friendMessageEcho', (steamID: unknown, message: unknown, ordinal?: unknown) => { + handleSteamEcho(steamID, message, ordinal).catch((error) => { + logger.warn?.('Failed to broadcast Steam echo', { error: errorMessage(error) }); + }); + }); + } + + return { + config, + server, + wss, + clients, + start(callback?: () => void) { + server.listen(config.port, config.host, () => { + logger.info?.(`Steam Chat listening on ${config.host}:${config.port}`); + callback?.(); + }); + return server; + }, + stop() { + for (const ws of clients) ws.close(); + wss.close(); + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + sendTextMessage, + sendImageMessage, + broadcast, + handleHttpRequest, + handleWsMessage + }; +} + +async function defaultGetEmoticons({ steamUser, waitForLogin, waitForWebSession }: GetEmoticonsOptions): Promise { + await resolveWaiter(waitForLogin); + await resolveWaiter(waitForWebSession); + const source = steamUser?.getEmoticonList || steamUser?.chat?.getEmoticonList; + if (!source) return { emoticons: [], stickers: [] }; + const context = steamUser?.getEmoticonList ? steamUser : steamUser.chat; + const response = await callMaybeCallback(source, context, []); + const emoticons = isRecord(response) + ? arrayFromUnknown(response.emoticons || response.emoticon_list) + : []; + const stickers = isRecord(response) + ? arrayFromUnknown(response.stickers || response.sticker_list) + : []; + return { emoticons, stickers }; +} + +async function listFriends(steamUser?: SteamUserLike): Promise { + if (!steamUser) return []; + const friends = isRecord(steamUser.myFriends) ? steamUser.myFriends : {}; + const ids = Object.keys(friends); + const users = steamUser.users || {}; + return ids.map((id) => { + const persona = users[id] || {}; + const state = persona.persona_state ?? persona.personaState ?? friends[id]; + return { + id, + name: persona.player_name || persona.personaName || persona.name || id, + avatar: persona.avatar_url_icon || persona.avatar_url_medium || persona.avatar || '', + personaState: state, + online: Number(state || 0) > 0, + gameName: persona.game_name || persona.gameName || '' + }; + }).sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name)); +} + +function groupFromUnknown(group: unknown, fallbackId = ''): GroupSummary { + if (!isRecord(group)) { + const id = steamIdToString(group || fallbackId); + return { id, clanId: '', name: id }; + } + const id = steamIdToString(group.steamID || group.id || fallbackId || group); + const clanId = typeof group.clanid === 'string' + ? group.clanid + : typeof group.clanID === 'string' + ? group.clanID + : ''; + const name = typeof group.name === 'string' + ? group.name + : typeof group.group_name === 'string' + ? group.group_name + : id; + return { id, clanId, name }; +} + +async function listGroups(steamUser?: SteamUserLike): Promise { + if (!steamUser) return []; + const groups = steamUser.myGroups || steamUser.groups || {}; + if (Array.isArray(groups)) { + return groups.map((group) => groupFromUnknown(group)); + } + if (!isRecord(groups)) return []; + return Object.entries(groups).map(([id, group]) => groupFromUnknown(group, id)); +} + +module.exports = { + DEFAULT_CHAT_CONFIG, + IMAGE_CACHE_DIR, + MAX_BODY_BYTES, + MAX_WS_CONNECTIONS, + STICKER_CACHE_DIR, + cacheKeyForUrl, + createAuthChecker, + createChatService, + decodeBase64Image, + defaultGetEmoticons, + extractStickerType, + inferImageContentType, + isAllowedRemoteImageUrl, + isLocalOrLanIp, + isSessionExpiredError, + isTransientSteamError, + listFriends, + listGroups, + loadOrDownloadRemoteImage, + loadOrDownloadSticker, + normalizeChatConfig, + normalizeIp, + previewForMessage, + readRequestBody, + stickerUrlForType +}; diff --git a/src/server/network.ts b/src/server/network.ts new file mode 100644 index 0000000..eb69c34 --- /dev/null +++ b/src/server/network.ts @@ -0,0 +1,32 @@ +'use strict'; + +const net = require('node:net'); + +function normalizeIp(raw: unknown): string { + let ip = String(raw || '').trim(); + if (!ip) return ''; + if (ip.startsWith('[') && ip.includes(']')) ip = ip.slice(1, ip.indexOf(']')); + if (ip.startsWith('::ffff:')) ip = ip.slice(7); + const portIndex = ip.lastIndexOf(':'); + if (portIndex > -1 && ip.indexOf(':') === portIndex) ip = ip.slice(0, portIndex); + return ip; +} + +function isLocalOrLanIp(raw: unknown): boolean { + const ip = normalizeIp(raw); + if (!ip) return true; + if (ip === 'localhost' || ip === '::1') return true; + if (net.isIP(ip) === 6) { + const lower = ip.toLowerCase(); + return lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'); + } + if (net.isIP(ip) !== 4) return false; + const parts = ip.split('.').map((part) => Number.parseInt(part, 10)); + const [a, b] = parts; + return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +module.exports = { + isLocalOrLanIp, + normalizeIp +}; diff --git a/src/steam/lifecycle.ts b/src/steam/lifecycle.ts new file mode 100644 index 0000000..d67dc0d --- /dev/null +++ b/src/steam/lifecycle.ts @@ -0,0 +1,319 @@ +'use strict'; + +import type { LoggerLike } from '../types'; +import { errorCode, errorMessage, isRecord } from '../types'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const DEFAULT_REFRESH_TOKEN_PATH = path.resolve(__dirname, '..', '..', 'refresh.token'); +const INITIAL_RETRY_DELAY_MS = 5000; +const MAX_RETRY_DELAY_MS = 5 * 60 * 1000; + +type Deferred = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +}; + +type RefreshTokenFileSystem = Pick; + +type TimerHandle = ReturnType | number; + +type TimerApi = { + setTimeout: (callback: () => void, delay: number) => TimerHandle; + clearTimeout: (handle: TimerHandle) => void; +}; + +type LogOnOptions = { + logonID?: number; + steamID?: string; + refreshToken?: string; + accountName?: string; + password?: string; +}; + +type SteamLifecycleConfig = { + accountName?: string; + password?: string; + logonID?: number; + steamID?: string; + identitySecret?: string; +}; + +type WebSession = { + sessionID: string; + cookies: string[]; +}; + +type SteamUserLifecycleLike = { + on: { + (event: 'loggedOn', listener: () => void): void; + (event: 'webSession', listener: (sessionID: string, cookies: string[]) => void): void; + (event: 'refreshToken', listener: (refreshToken: string) => void): void; + (event: 'error', listener: (error: unknown) => void): void; + (event: 'disconnected', listener: (eresult: unknown, message?: string) => void): void; + }; + logOn: (options: LogOnOptions) => void; + webLogOn: () => void; + setPersona?: (state: number) => void; + EPersonaState?: { Online?: number }; + logOff?: () => void; +}; + +type SteamCommunityLifecycleLike = { + setCookies?: (cookies: string[]) => void; + startConfirmationChecker?: (intervalMs: number, identitySecret: string) => void; +}; + +type SteamLifecycleOptions = { + steamUser: SteamUserLifecycleLike; + steamCommunity?: SteamCommunityLifecycleLike | null; + config?: SteamLifecycleConfig; + logger?: LoggerLike; + refreshTokenPath?: string; + fileSystem?: RefreshTokenFileSystem; + timers?: TimerApi; +}; + +function createDeferred(): Deferred { + let resolve: (value: T | PromiseLike) => void; + let reject: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve: resolve!, reject: reject! }; +} + +function codeOf(error: unknown): string { + if (!error) return ''; + return errorCode(error).toLowerCase(); +} + +function isUnrecoverableLoginError(error: unknown): boolean { + const text = codeOf(error); + return [ + 'invalidpassword', + 'accountlogindenied', + 'accountdisabled', + 'logindenied', + 'rate', + 'captcha', + 'twofactor', + 'steamguard', + 'accessdenied', + 'invalid login' + ].some((needle) => text.includes(needle.toLowerCase())); +} + +function isRecoverableLoginError(error: unknown): boolean { + if (!error) return true; + if (isUnrecoverableLoginError(error)) return false; + const text = codeOf(error); + return [ + 'timeout', + 'timedout', + 'econnreset', + 'econnrefused', + 'enotfound', + 'enet', + 'socket', + 'tls', + 'serviceunavailable', + 'tryanothercm', + 'loggedinelsewhere', + 'disconnected', + 'connect', + 'network' + ].some((needle) => text.includes(needle)); +} + +function readRefreshToken(refreshTokenPath: string, fileSystem: RefreshTokenFileSystem = fs): string | null { + try { + const token = fileSystem.readFileSync(refreshTokenPath, 'utf8').trim(); + return token || null; + } catch (error) { + if (isRecord(error) && error.code !== 'ENOENT') { + throw error; + } + return null; + } +} + +function buildLogOnOptions(config: SteamLifecycleConfig, refreshTokenPath: string, fileSystem: RefreshTokenFileSystem = fs): LogOnOptions { + const refreshToken = readRefreshToken(refreshTokenPath, fileSystem); + const base: LogOnOptions = {}; + if (config.logonID !== undefined) base.logonID = config.logonID; + if (config.steamID) base.steamID = config.steamID; + if (refreshToken) { + return { ...base, refreshToken }; + } + return { + ...base, + accountName: config.accountName, + password: config.password + }; +} + +function createSteamLifecycle(options: SteamLifecycleOptions) { + const { + steamUser, + steamCommunity, + config = {}, + logger = console, + refreshTokenPath = DEFAULT_REFRESH_TOKEN_PATH, + fileSystem = fs, + timers = { setTimeout, clearTimeout } + } = options; + + if (!steamUser) { + throw new Error('steamUser is required'); + } + + let loginDeferred = createDeferred(); + let webDeferred = createDeferred(); + let retryTimer: TimerHandle | null = null; + let retryDelayMs = INITIAL_RETRY_DELAY_MS; + let stopped = false; + let latestWebSession: WebSession | null = null; + + function log(level: 'info' | 'warn' | 'error', message: string, meta?: unknown) { + const method = logger[level] || logger.log || (() => {}); + method.call(logger, message, meta); + } + + function clearRetryTimer() { + if (retryTimer) { + timers.clearTimeout(retryTimer); + retryTimer = null; + } + } + + function logOn() { + if (stopped) return; + const optionsForLogin = buildLogOnOptions(config, refreshTokenPath, fileSystem); + if (!optionsForLogin.refreshToken && (!optionsForLogin.accountName || !optionsForLogin.password)) { + const error = new Error('Steam credentials are missing. Configure accountName/password or refresh.token.'); + loginDeferred.reject(error); + throw error; + } + log('info', optionsForLogin.refreshToken ? 'Logging on Steam with refresh token' : 'Logging on Steam with account credentials'); + steamUser.logOn(optionsForLogin); + } + + function scheduleRetry(reason: unknown) { + if (stopped || retryTimer) return; + const delay = retryDelayMs; + retryDelayMs = Math.min(retryDelayMs * 2, MAX_RETRY_DELAY_MS); + log('warn', `Steam login retry scheduled in ${delay} ms`, { reason: errorMessage(reason) }); + retryTimer = timers.setTimeout(() => { + retryTimer = null; + try { + logOn(); + } catch (error) { + handleError(error); + } + }, delay); + } + + function handleError(error: unknown) { + if (stopped) return; + if (isRecoverableLoginError(error)) { + scheduleRetry(error); + return; + } + log('error', 'Steam login failed with unrecoverable error', { error: errorMessage(error) }); + loginDeferred.reject(error); + webDeferred.reject(error); + } + + function refreshWebSession(): Promise { + if (stopped) return Promise.reject(new Error('Steam lifecycle is stopped')); + webDeferred = createDeferred(); + try { + steamUser.webLogOn(); + } catch (error) { + webDeferred.reject(error); + } + return webDeferred.promise; + } + + steamUser.on('loggedOn', () => { + clearRetryTimer(); + retryDelayMs = INITIAL_RETRY_DELAY_MS; + log('info', 'Steam logged on'); + loginDeferred.resolve(true); + try { + steamUser.setPersona?.(steamUser.EPersonaState?.Online || 1); + } catch (_) { + // setPersona is optional and not important enough to break startup. + } + refreshWebSession().catch((error: unknown) => { + log('warn', 'Steam webLogOn failed after login', { error: errorMessage(error) }); + }); + }); + + steamUser.on('webSession', (sessionID: string, cookies: string[]) => { + latestWebSession = { sessionID, cookies }; + if (steamCommunity && typeof steamCommunity.setCookies === 'function') { + steamCommunity.setCookies(cookies); + } + if (steamCommunity && config.identitySecret && typeof steamCommunity.startConfirmationChecker === 'function') { + steamCommunity.startConfirmationChecker(10 * 1000, config.identitySecret); + } + webDeferred.resolve(latestWebSession); + }); + + steamUser.on('refreshToken', (refreshToken: string) => { + if (!refreshToken) return; + fileSystem.mkdirSync(path.dirname(refreshTokenPath), { recursive: true }); + fileSystem.writeFileSync(refreshTokenPath, `${refreshToken}\n`, 'utf8'); + log('info', 'Steam refresh token saved'); + }); + + steamUser.on('error', handleError); + steamUser.on('disconnected', (eresult: unknown, message?: string) => { + handleError(new Error(message || `Steam disconnected: ${eresult || 'unknown'}`)); + }); + + return { + start() { + stopped = false; + logOn(); + return loginDeferred.promise; + }, + stop() { + stopped = true; + clearRetryTimer(); + if (typeof steamUser.logOff === 'function') { + steamUser.logOff(); + } + }, + waitForLogin() { + return loginDeferred.promise; + }, + waitForWebSession() { + return webDeferred.promise; + }, + refreshWebSession, + getLatestWebSession() { + return latestWebSession; + }, + getRetryDelayMs() { + return retryDelayMs; + } + }; +} + +module.exports = { + DEFAULT_REFRESH_TOKEN_PATH, + INITIAL_RETRY_DELAY_MS, + MAX_RETRY_DELAY_MS, + buildLogOnOptions, + createDeferred, + createSteamLifecycle, + isRecoverableLoginError, + isUnrecoverableLoginError, + readRefreshToken +}; diff --git a/src/steam/message-logger.ts b/src/steam/message-logger.ts new file mode 100644 index 0000000..bb834b6 --- /dev/null +++ b/src/steam/message-logger.ts @@ -0,0 +1,131 @@ +'use strict'; + +import type { LoggerLike, Persona } from '../types'; +import { errorMessage } from '../types'; + +const { + DEFAULT_LOG_PATH, + appendLog, + formatDate, + steamIdToString +} = require('../storage/chat-log'); + +type SteamHistoryMessage = { + imageUrl?: string | null; + accountid?: string | number; + message?: string; + ordinal?: string | number | null; + timestamp?: number; +}; + +type SteamMessageLoggerUser = { + on: { + (event: 'friendMessage', listener: (steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) => void): void; + (event: 'friendMessageEcho', listener: (steamID: unknown, message: unknown, ordinal?: unknown) => void): void; + }; + off?: { + (event: 'friendMessage', listener: (steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) => void): void; + (event: 'friendMessageEcho', listener: (steamID: unknown, message: unknown, ordinal?: unknown) => void): void; + }; + getChatHistory?: (id: string, callback: (error: unknown, messages?: SteamHistoryMessage[]) => void) => void; +}; + +type SteamMessageLoggerOptions = { + steamUser: SteamMessageLoggerUser; + getUserInfo?: (steamID: unknown) => Promise; + getSelfName?: () => Promise; + logPath?: string; + logger?: LoggerLike; +}; + +function createSteamMessageLogger(options: SteamMessageLoggerOptions) { + const { steamUser, getSelfName = async () => 'Me', logPath = DEFAULT_LOG_PATH, logger = console } = options; + const getUserInfo: (steamID: unknown) => Promise = options.getUserInfo || (async () => ({ player_name: 'Unknown' })); + if (!steamUser || typeof steamUser.on !== 'function') { + throw new Error('steamUser EventEmitter is required'); + } + + const echoKeys = new Map(); + const importAttempts = new Set(); + + function echoKey(id: string, message: unknown, ordinal: unknown): string { + return `${id}:${ordinal || ''}:${message}`; + } + + function rememberEcho(key: string): boolean { + if (echoKeys.has(key)) return false; + echoKeys.set(key, true); + setTimeout(() => echoKeys.delete(key), 30 * 1000).unref?.(); + return true; + } + + async function maybeImportSteamHistory(id: string) { + if (!id || importAttempts.has(id) || typeof steamUser.getChatHistory !== 'function') return; + importAttempts.add(id); + try { + const history = await new Promise((resolve) => { + steamUser.getChatHistory?.(id, (error: unknown, messages?: SteamHistoryMessage[]) => resolve(error ? [] : messages || [])); + }); + for (const message of history) { + await appendLog({ + type: message.imageUrl ? 'image' : 'message', + id, + name: message.accountid ? String(message.accountid) : 'Unknown', + message: message.message || '', + imageUrl: message.imageUrl || null, + ordinal: message.ordinal ?? null, + date: message.timestamp ? formatDate(new Date(message.timestamp * 1000)) : undefined + }, { logPath }); + } + } catch (error) { + logger.warn?.('Steam history import failed', { id, error: errorMessage(error) }); + } + } + + const onFriendMessage = async (steamID: unknown, message: unknown, type?: unknown, chatter?: unknown, ordinal?: unknown) => { + const id = steamIdToString(steamID); + try { + await maybeImportSteamHistory(id); + const info = await getUserInfo(steamID); + await appendLog({ + type: 'message', + id, + name: info.player_name || info.personaName || id, + message: typeof message === 'string' ? message : '', + ordinal: typeof ordinal === 'string' || typeof ordinal === 'number' ? ordinal : null + }, { logPath }); + } catch (error) { + logger.error?.('Failed to log friend message', { id, error: errorMessage(error) }); + } + }; + + const onFriendMessageEcho = async (steamID: unknown, message: unknown, ordinal?: unknown) => { + const id = steamIdToString(steamID); + const key = echoKey(id, message, ordinal); + if (!rememberEcho(key)) return; + try { + await appendLog({ + type: 'message', + echo: true, + id, + name: await getSelfName(), + message: typeof message === 'string' ? message : '', + ordinal: typeof ordinal === 'string' || typeof ordinal === 'number' ? ordinal : null + }, { logPath }); + } catch (error) { + logger.error?.('Failed to log echoed message', { id, error: errorMessage(error) }); + } + }; + + steamUser.on('friendMessage', onFriendMessage); + steamUser.on('friendMessageEcho', onFriendMessageEcho); + + return () => { + steamUser.off?.('friendMessage', onFriendMessage); + steamUser.off?.('friendMessageEcho', onFriendMessageEcho); + }; +} + +module.exports = { + createSteamMessageLogger +}; diff --git a/src/storage/chat-log.ts b/src/storage/chat-log.ts new file mode 100644 index 0000000..df63f7e --- /dev/null +++ b/src/storage/chat-log.ts @@ -0,0 +1,227 @@ +'use strict'; + +import type { ConversationSummary, HistoryItem, HistoryRecordInput, LoggerLike } from '../types'; +import { errorMessage, isRecord } from '../types'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { CHAT_LOG_PATH } = require('../paths'); + +const DEFAULT_LOG_PATH = CHAT_LOG_PATH; +const MAX_HISTORY_LIMIT = 500; + +function formatDate(date = new Date()) { + const pad = (value: unknown, width = 2) => String(value).padStart(width, '0'); + return [ + date.getFullYear(), + '-', + pad(date.getMonth() + 1), + '-', + pad(date.getDate()), + ' ', + pad(date.getHours()), + ':', + pad(date.getMinutes()), + ':', + pad(date.getSeconds()), + '.', + pad(date.getMilliseconds(), 3) + ].join(''); +} + +function parseMessageDate(item: Pick): number { + if (item.sentAt) { + const sentAt = Date.parse(item.sentAt); + if (!Number.isNaN(sentAt)) return sentAt; + } + if (item.date) { + const normalized = String(item.date).replace(' ', 'T'); + const parsed = Date.parse(normalized); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +function limitFrom(value: unknown, fallback = 100): number { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, MAX_HISTORY_LIMIT); +} + +function steamIdToString(value: unknown): string { + if (!value) return ''; + if (typeof value === 'string') return value; + if (isRecord(value)) { + const getSteamID64 = value.getSteamID64; + if (typeof getSteamID64 === 'function') return String(getSteamID64.call(value)); + if (value.steamid) return String(value.steamid); + } + return String(value); +} + +function normalizeHistoryItem(record: HistoryRecordInput): HistoryItem { + const item: HistoryItem = { + type: typeof record.type === 'string' ? record.type : (record.imageUrl ? 'image' : 'message'), + date: typeof record.date === 'string' ? record.date : formatDate(record.sentAt ? new Date(record.sentAt) : new Date()), + echo: Boolean(record.echo), + id: steamIdToString(record.id || record.steamID), + name: typeof record.name === 'string' ? record.name : (record.echo ? 'Me' : 'Unknown'), + message: typeof record.message === 'string' ? record.message : '', + imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : null, + ordinal: typeof record.ordinal === 'string' || typeof record.ordinal === 'number' ? record.ordinal : null + }; + if (typeof record.sentAt === 'string') item.sentAt = record.sentAt; + return item; +} + +async function appendLog(item: HistoryRecordInput, options: { logPath?: string } = {}): Promise { + const logPath = options.logPath || DEFAULT_LOG_PATH; + const normalized = normalizeHistoryItem(item); + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.appendFile(logPath, `${JSON.stringify(normalized)}\n`, 'utf8'); + return normalized; +} + +async function readAllLogLines(logPath: string, logger: LoggerLike = console): Promise { + let content; + try { + content = await fs.readFile(logPath, 'utf8'); + } catch (error) { + if (isRecord(error) && error.code === 'ENOENT') return []; + throw error; + } + const records: HistoryItem[] = []; + for (const [index, line] of content.split(/\r?\n/).entries()) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + records.push(normalizeHistoryItem(isRecord(parsed) ? parsed : {})); + } catch (error) { + logger.warn?.('Skipping invalid JSONL chat log line', { line: index + 1, error: errorMessage(error) }); + } + } + return records; +} + +function sortHistoryItems(items: HistoryItem[]) { + return items.sort((left, right) => { + const diff = parseMessageDate(left) - parseMessageDate(right); + if (diff !== 0) return diff; + return Number(left.ordinal || 0) - Number(right.ordinal || 0); + }); +} + +async function readHistory(options: { logPath?: string; limit?: unknown; id?: unknown; logger?: LoggerLike } = {}): Promise { + const logPath = options.logPath || DEFAULT_LOG_PATH; + const limit = limitFrom(options.limit); + const id = options.id ? steamIdToString(options.id) : ''; + let records = await readAllLogLines(logPath, options.logger); + if (id) { + records = records.filter((item) => item.id === id); + } + records = sortHistoryItems(records); + return records.slice(-limit); +} + +function extractStickerType(message: unknown): string { + const match = String(message || '').match(/\[sticker\s+type=["']?([^"'\]\s]+)["']?[^]*?\]\s*\[\/sticker\]/i); + return match ? match[1] : ''; +} + +function extractOpenGraphTitle(message: unknown): string { + const match = String(message || '').match(/\[og\b[^\]]*title=["']([^"']+)["'][^\]]*\]/i); + return match ? match[1] : ''; +} + +function isEmoticonOnly(message: unknown): string { + const text = String(message || '').trim(); + if (/^:[A-Za-z0-9_+\-.]+:$/.test(text)) return text.slice(1, -1); + const named = text.match(/^\[emoticon\s+name=["']?([^"'\]\s]+)["']?\]\s*\[\/emoticon\]$/i); + if (named) return named[1]; + const body = text.match(/^\[emoticon\]([^[]+)\[\/emoticon\]$/i); + return body ? body[1] : ''; +} + +function stripMarkup(message: unknown): string { + return String(message || '') + .replace(/\[url=([^\]]+)\]([^[]+)\[\/url\]/gi, '$2') + .replace(/\[url\]([^[]+)\[\/url\]/gi, '$1') + .replace(/\[img[^\]]*\][^[]*\[\/img\]/gi, '[图片]') + .replace(/]*>/gi, '[图片]') + .replace(/\s+/g, ' ') + .trim(); +} + +function previewForMessage(item: Pick): string { + if (item.type === 'image' || item.imageUrl) return '[图片]'; + const stickerType = extractStickerType(item.message); + if (stickerType) return `[贴纸] ${stickerType}`; + const emoticon = isEmoticonOnly(item.message); + if (emoticon) return `[表情] ${emoticon}`; + const ogTitle = extractOpenGraphTitle(item.message); + if (ogTitle) return ogTitle; + return stripMarkup(item.message) || '[空消息]'; +} + +async function buildConversations(options: { + logPath?: string; + limit?: unknown; + getUserInfo?: (id: string) => Promise<{ player_name?: string; personaName?: string }>; + logger?: LoggerLike; +} = {}): Promise { + const logPath = options.logPath || DEFAULT_LOG_PATH; + const limit = limitFrom(options.limit, 100); + const getUserInfo = options.getUserInfo; + const records = await readHistory({ logPath, limit, logger: options.logger }); + const conversations = new Map(); + + for (const item of records) { + if (!item.id) continue; + const previous = conversations.get(item.id); + const at = parseMessageDate(item); + const name = !item.echo && item.name && item.name !== 'Unknown' ? item.name : previous?.name; + conversations.set(item.id, { + id: item.id, + name: name || item.name || item.id, + updatedAt: item.sentAt || item.date, + updatedAtMs: at, + preview: previewForMessage(item), + lastType: item.type, + lastEcho: item.echo, + messageCount: (previous?.messageCount || 0) + 1 + }); + } + + const result = [...conversations.values()].sort((left, right) => right.updatedAtMs - left.updatedAtMs); + if (getUserInfo) { + await Promise.all(result.map(async (conversation) => { + if (conversation.name && conversation.name !== conversation.id && conversation.name !== 'Unknown') return; + try { + const info = await getUserInfo(conversation.id); + conversation.name = info.player_name || info.personaName || conversation.id; + } catch (_) { + conversation.name = conversation.id; + } + })); + } + return result.map(({ updatedAtMs, ...conversation }) => conversation); +} + +module.exports = { + DEFAULT_LOG_PATH, + MAX_HISTORY_LIMIT, + appendLog, + buildConversations, + extractOpenGraphTitle, + extractStickerType, + formatDate, + isEmoticonOnly, + limitFrom, + normalizeHistoryItem, + parseMessageDate, + previewForMessage, + readHistory, + sortHistoryItems, + steamIdToString, + stripMarkup +}; diff --git a/src/storage/media-cache.ts b/src/storage/media-cache.ts new file mode 100644 index 0000000..449e081 --- /dev/null +++ b/src/storage/media-cache.ts @@ -0,0 +1,167 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { URL } = require('node:url'); +const { IMAGE_CACHE_DIR, STICKER_CACHE_DIR } = require('../paths'); +const { isLocalOrLanIp } = require('../server/network'); +import { isRecord } from '../types'; + +const MAX_REMOTE_IMAGE_BYTES = 10 * 1024 * 1024; +const imageDownloads = new Map>(); +const stickerDownloads = new Map>(); + +type CacheOptions = { + cacheDir?: string; + fetchImpl?: typeof fetch; +}; + +type CachedImage = { + buffer: Buffer; + contentType: string; + fromCache?: boolean; +}; + +function isAllowedRemoteImageUrl(value: unknown): boolean { + let parsed: URL; + try { + parsed = new URL(String(value || '')); + } catch (_) { + return false; + } + if (!['http:', 'https:'].includes(parsed.protocol)) return false; + const host = parsed.hostname.toLowerCase(); + if (host === 'localhost' || host.endsWith('.localhost')) return false; + if (isLocalOrLanIp(host)) return false; + return true; +} + +function inferImageContentType(url: unknown, fallback = ''): string { + const pathname = (() => { + try { + return new URL(String(url || '')).pathname.toLowerCase(); + } catch (_) { + return ''; + } + })(); + if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg'; + if (pathname.endsWith('.gif')) return 'image/gif'; + if (pathname.endsWith('.webp')) return 'image/webp'; + if (pathname.endsWith('.svg')) return 'image/svg+xml'; + if (pathname.endsWith('.bmp')) return 'image/bmp'; + if (fallback && /^image\//i.test(fallback)) return fallback; + return 'image/png'; +} + +function cacheKeyForUrl(url: string): string { + return crypto.createHash('sha1').update(url).digest('hex'); +} + +async function fetchBuffer(url: string, options: CacheOptions = {}): Promise { + const fetchImpl = options.fetchImpl || globalThis.fetch; + if (!fetchImpl) throw new Error('fetch is not available in this Node runtime'); + const response = await fetchImpl(url, { + headers: { + 'User-Agent': 'steam-chat/1.0' + } + }); + if (!response.ok) { + throw new Error(`Remote request failed with HTTP ${response.status}`); + } + const type = response.headers?.get?.('content-type') || ''; + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_REMOTE_IMAGE_BYTES) { + throw Object.assign(new Error('Remote image is too large'), { statusCode: 413 }); + } + return { + buffer: Buffer.from(arrayBuffer), + contentType: inferImageContentType(url, type) + }; +} + +async function loadOrDownloadRemoteImage(url: string, options: CacheOptions = {}): Promise { + if (!isAllowedRemoteImageUrl(url)) { + throw Object.assign(new Error('Remote image URL is not allowed'), { statusCode: 400 }); + } + const cacheDir = options.cacheDir || IMAGE_CACHE_DIR; + const key = cacheKeyForUrl(url); + const binPath = path.join(cacheDir, `${key}.bin`); + const metaPath = path.join(cacheDir, `${key}.json`); + + try { + const [buffer, metaRaw] = await Promise.all([ + fs.readFile(binPath), + fs.readFile(metaPath, 'utf8') + ]); + const meta: unknown = JSON.parse(metaRaw); + const contentType = isRecord(meta) && typeof meta.contentType === 'string' + ? meta.contentType + : inferImageContentType(url); + return { buffer, contentType, fromCache: true }; + } catch (_) { + // Cache misses fall through to a single shared download promise. + } + + if (!imageDownloads.has(url)) { + imageDownloads.set(url, (async () => { + await fs.mkdir(cacheDir, { recursive: true }); + const downloaded = await fetchBuffer(url, options); + await Promise.all([ + fs.writeFile(binPath, downloaded.buffer), + fs.writeFile(metaPath, JSON.stringify({ + url, + contentType: downloaded.contentType, + cachedAt: new Date().toISOString() + }, null, 2)) + ]); + return { ...downloaded, fromCache: false }; + })().finally(() => imageDownloads.delete(url))); + } + const pending = imageDownloads.get(url); + if (!pending) throw new Error('Remote image download was not queued'); + return pending; +} + +function stickerUrlForType(type: string): string { + return `https://steamcommunity-a.akamaihd.net/economy/sticker/${encodeURIComponent(type)}`; +} + +function safeCacheName(value: string): string { + return encodeURIComponent(value).replace(/%/g, '_'); +} + +async function loadOrDownloadSticker(type: string, options: CacheOptions = {}): Promise { + if (!type) throw Object.assign(new Error('Sticker type is required'), { statusCode: 400 }); + const cacheDir = options.cacheDir || STICKER_CACHE_DIR; + const cachePath = path.join(cacheDir, `${safeCacheName(type)}.bin`); + try { + return { buffer: await fs.readFile(cachePath), contentType: 'image/png', fromCache: true }; + } catch (_) { + // Cache miss. + } + if (!stickerDownloads.has(type)) { + stickerDownloads.set(type, (async () => { + await fs.mkdir(cacheDir, { recursive: true }); + const downloaded = await fetchBuffer(stickerUrlForType(type), options); + await fs.writeFile(cachePath, downloaded.buffer); + return { buffer: downloaded.buffer, contentType: downloaded.contentType, fromCache: false }; + })().finally(() => stickerDownloads.delete(type))); + } + const pending = stickerDownloads.get(type); + if (!pending) throw new Error('Sticker download was not queued'); + return pending; +} + +module.exports = { + IMAGE_CACHE_DIR, + MAX_REMOTE_IMAGE_BYTES, + STICKER_CACHE_DIR, + cacheKeyForUrl, + fetchBuffer, + inferImageContentType, + isAllowedRemoteImageUrl, + loadOrDownloadRemoteImage, + loadOrDownloadSticker, + stickerUrlForType +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fee4635 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,106 @@ +export type UnknownRecord = Record; + +export type LoggerLike = { + info?: (message: string, meta?: unknown) => void; + warn?: (message: string, meta?: unknown) => void; + error?: (message: string, meta?: unknown) => void; + log?: (message: string, meta?: unknown) => void; +}; + +export type SteamIdLike = { + getSteamID64?: () => string; + steamid?: string | number; +}; + +export type Persona = UnknownRecord & { + player_name?: string; + personaName?: string; + name?: string; + avatar_url_icon?: string; + avatar_url_medium?: string; + avatar?: string; + persona_state?: number; + personaState?: number; + game_name?: string; + gameName?: string; +}; + +export type ChatConfig = { + enabled: boolean; + host: string; + port: number; + wsPath: string; + auth: AuthConfig; +}; + +export type AuthConfig = { + username?: string; + password?: string; + realm?: string; + trustProxy?: boolean; +}; + +export type HistoryRecordInput = UnknownRecord & { + type?: string; + date?: string; + echo?: boolean; + id?: string | number | SteamIdLike; + steamID?: string | number | SteamIdLike; + name?: string; + message?: string; + imageUrl?: string | null; + ordinal?: string | number | null; + sentAt?: string; +}; + +export type HistoryItem = { + type: string; + date: string; + echo: boolean; + id: string; + name: string; + message: string; + imageUrl: string | null; + ordinal: number | string | null; + sentAt?: string; +}; + +export type ConversationSummary = { + id: string; + name: string; + updatedAt: string; + preview: string; + lastType: string; + lastEcho: boolean; + messageCount: number; +}; + +export type ChatMessagePayload = HistoryItem & { + type: string; + requestId?: string; + error?: string; + wsPath?: string; +}; + +export type CallbackStyleFunction = (...args: unknown[]) => unknown; + +export function isRecord(value: unknown): value is UnknownRecord { + return value !== null && typeof value === 'object'; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error || ''); +} + +export function errorCode(error: unknown): string { + if (!isRecord(error)) return errorMessage(error); + return String(error.eresult || error.code || error.message || error); +} + +export function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function optionalNumber(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} diff --git a/steam-lifecycle.js b/steam-lifecycle.js deleted file mode 100644 index 712151a..0000000 --- a/steam-lifecycle.js +++ /dev/null @@ -1,325 +0,0 @@ -const fs = require('fs'); -const EResult = require('steam-user/enums/EResult'); - -const DEFAULT_INITIAL_RETRY_DELAY_MS = 5000; -const DEFAULT_MAX_RETRY_DELAY_MS = 5 * 60 * 1000; - -const RECOVERABLE_ERESULTS = new Set([ - EResult.Invalid, - EResult.Fail, - EResult.NoConnection, - EResult.Busy, - EResult.Timeout, - EResult.ServiceUnavailable, - EResult.TryAnotherCM, -]); - -const NON_RECOVERABLE_ERESULTS = new Set([ - EResult.InvalidPassword, - EResult.LoggedInElsewhere, - EResult.Banned, - EResult.AccountNotFound, - EResult.InvalidSteamID, - EResult.AccountDisabled, - EResult.AlreadyLoggedInElsewhere, - EResult.Suspended, - EResult.PasswordUnset, - EResult.IllegalPassword, - EResult.AccountLogonDenied, - EResult.InvalidLoginAuthCode, - EResult.AccountLogonDeniedNoMail, - EResult.ExpiredLoginAuthCode, - EResult.IPLoginRestrictionFailed, - EResult.AccountLockedDown, - EResult.AccountLogonDeniedVerifiedEmailRequired, - EResult.RequirePasswordReEntry, - EResult.RateLimitExceeded, - EResult.AccountLoginDeniedNeedTwoFactor, - EResult.AccountLoginDeniedThrottle, - EResult.TwoFactorCodeMismatch, - EResult.TimeNotSynced, - EResult.NeedCaptcha, - EResult.IPBanned, - EResult.LimitedUserAccount, -].filter((value) => typeof value === 'number')); - -const NON_RECOVERABLE_MESSAGE_PATTERNS = [ - /invalid password/i, - /invalid refresh token/i, - /refreshToken is not/i, - /not valid for logging in/i, - /does not match refreshToken/i, - /steam guard/i, - /two[- ]?factor/i, - /account login denied/i, - /logged in elsewhere/i, -]; - -const RECOVERABLE_MESSAGE_PATTERNS = [ - /no steam servers available/i, - /no connection/i, - /service unavailable/i, - /try another cm/i, - /timeout/i, - /timed out/i, - /econnreset/i, - /econnrefused/i, - /enotfound/i, - /eai_again/i, - /socket/i, - /tls/i, - /network/i, - /rate limit/i, -]; - -function getEResultName(eresult) { - if (eresult === undefined || eresult === null) { - return undefined; - } - - return EResult[eresult] || String(eresult); -} - -function buildLogOnOptions(config, fsModule = fs, refreshTokenPath = 'refresh.token') { - try { - const refreshToken = fsModule.readFileSync(refreshTokenPath, 'utf8').trim(); - - if (refreshToken.length > 0) { - return { - options: { - logonID: config.logonID, - refreshToken, - steamID: config.steamID, - }, - mode: 'refreshToken', - }; - } - } catch (err) { - // Missing or unreadable refresh tokens are expected on first start. - } - - return { - options: { - accountName: config.accountName, - password: config.password, - logonID: config.logonID, - steamID: config.steamID, - }, - mode: 'credentials', - }; -} - -function isRecoverableSteamError(err) { - if (!err) { - return true; - } - - const message = String(err.message || err); - if (NON_RECOVERABLE_ERESULTS.has(err.eresult)) { - return false; - } - - if (NON_RECOVERABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) { - return false; - } - - if (RECOVERABLE_ERESULTS.has(err.eresult)) { - return true; - } - - if (RECOVERABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) { - return true; - } - - return false; -} - -function createSteamLifecycle({ - steamUser, - steamCommunity, - logger, - config, - fsModule = fs, - setTimeoutFn = setTimeout, - clearTimeoutFn = clearTimeout, - refreshTokenPath = 'refresh.token', - initialRetryDelayMs = DEFAULT_INITIAL_RETRY_DELAY_MS, - maxRetryDelayMs = DEFAULT_MAX_RETRY_DELAY_MS, -}) { - let loginResolved = false; - let loginRejected = false; - let retryDelayMs = initialRetryDelayMs; - let retryTimer = null; - let confirmationCheckerStarted = false; - - let resolveLogin; - let rejectLogin; - const steamLoginPromise = new Promise((resolve, reject) => { - resolveLogin = resolve; - rejectLogin = reject; - }); - - let resolveWebLogin; - const steamWebLoginPromise = new Promise((resolve) => { - resolveWebLogin = resolve; - }); - - function clearRetryTimer() { - if (retryTimer) { - clearTimeoutFn(retryTimer); - retryTimer = null; - } - } - - function logOn(reason) { - const { options, mode } = buildLogOnOptions(config, fsModule, refreshTokenPath); - - logger.info('steam logon started', { - reason, - mode, - steamID: config.steamID, - }); - - steamUser.logOn(options); - } - - function scheduleRetry(err) { - if (retryTimer) { - logger.warn('steam reconnect already scheduled', { - delayMs: retryDelayMs, - error: err && err.message, - eresult: err && err.eresult, - eresultName: err && getEResultName(err.eresult), - }); - return; - } - - const delayMs = retryDelayMs; - logger.warn('steam reconnect scheduled', { - delayMs, - error: err && err.message, - eresult: err && err.eresult, - eresultName: err && getEResultName(err.eresult), - }); - - retryTimer = setTimeoutFn(() => { - retryTimer = null; - retryDelayMs = Math.min(retryDelayMs * 2, maxRetryDelayMs); - - try { - logOn('retry'); - } catch (retryErr) { - logger.error('steam reconnect attempt failed to start', { - error: retryErr.message, - }); - scheduleRetry(retryErr); - } - }, delayMs); - } - - steamUser.setOption('renewRefreshTokens', true); - - steamUser.on('refreshToken', (refreshToken) => { - logger.info('steam refresh token received'); - - try { - fsModule.writeFileSync(refreshTokenPath, refreshToken); - } catch (err) { - logger.error('failed to write steam refresh token', { - error: err.message, - }); - } - }); - - steamUser.on('loggedOn', () => { - clearRetryTimer(); - retryDelayMs = initialRetryDelayMs; - loginResolved = true; - - logger.info(`login to Steam as ${steamUser.steamID}`); - - try { - steamUser.webLogOn(); - } catch (err) { - logger.warn(`failed to start web login: ${err.message}`); - } - - resolveLogin(); - }); - - steamUser.on('disconnected', (eresult, msg) => { - logger.warn('steam disconnected', { - eresult, - eresultName: getEResultName(eresult), - message: msg, - autoRelogin: steamUser.options && steamUser.options.autoRelogin, - }); - - // Schedule a retry as fallback — if autoRelogin succeeds, - // loggedOn will clear this timer. This ensures reconnection - // even if autoRelogin is disabled or fails silently. - const err = new Error(msg || `Steam disconnected (eresult: ${eresult})`); - err.eresult = eresult; - scheduleRetry(err); - }); - - steamUser.on('error', (err) => { - const recoverable = isRecoverableSteamError(err); - - logger.error('steam error', { - error: err && err.message, - eresult: err && err.eresult, - eresultName: err && getEResultName(err.eresult), - recoverable, - }); - - if (recoverable) { - scheduleRetry(err); - return; - } - - clearRetryTimer(); - - if (!loginResolved && !loginRejected) { - loginRejected = true; - rejectLogin(err); - } - }); - - steamUser.on('webSession', (sessionID, cookies) => { - logger.info(`web session received: ${sessionID}`); - - steamCommunity.setCookies(cookies); - if (config.identitySecret && !confirmationCheckerStarted) { - try { - steamCommunity.startConfirmationChecker(10000, config.identitySecret); - confirmationCheckerStarted = true; - } catch (err) { - logger.warn('failed to start confirmation checker', { - error: err.message, - }); - } - } - - resolveWebLogin(); - }); - - logOn('initial'); - - return { - steamLoginPromise, - steamWebLoginPromise, - isRecoverableSteamError, - buildLogOnOptions, - getEResultName, - }; -} - -module.exports = { - DEFAULT_INITIAL_RETRY_DELAY_MS, - DEFAULT_MAX_RETRY_DELAY_MS, - buildLogOnOptions, - createSteamLifecycle, - getEResultName, - isRecoverableSteamError, -}; diff --git a/test/auth-network.test.ts b/test/auth-network.test.ts new file mode 100644 index 0000000..9db3b31 --- /dev/null +++ b/test/auth-network.test.ts @@ -0,0 +1,100 @@ +'use strict'; + +import type { IncomingMessage } from 'node:http'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + createAuthChecker, + getClientIp, + parseForwardedFor, + timingSafeTextEqual +} = require('../src/server/auth'); +const { + isLocalOrLanIp, + normalizeIp +} = require('../src/server/network'); + +function requestWith(headers: Record, remoteAddress = ''): IncomingMessage { + return { + headers, + socket: { remoteAddress } + } as IncomingMessage; +} + +test('normalizeIp strips wrappers and ports without breaking IPv6 literals', () => { + assert.equal(normalizeIp(' 8.8.8.8:443 '), '8.8.8.8'); + assert.equal(normalizeIp('[2001:db8::1]:443'), '2001:db8::1'); + assert.equal(normalizeIp('::ffff:192.168.1.20'), '192.168.1.20'); + assert.equal(normalizeIp('2001:db8::1'), '2001:db8::1'); +}); + +test('isLocalOrLanIp identifies local, private, link-local, and public addresses', () => { + assert.equal(isLocalOrLanIp(''), true); + assert.equal(isLocalOrLanIp('localhost'), true); + assert.equal(isLocalOrLanIp('10.1.2.3'), true); + assert.equal(isLocalOrLanIp('172.16.0.1'), true); + assert.equal(isLocalOrLanIp('172.31.255.255'), true); + assert.equal(isLocalOrLanIp('172.32.0.1'), false); + assert.equal(isLocalOrLanIp('192.168.1.1'), true); + assert.equal(isLocalOrLanIp('169.254.1.2'), true); + assert.equal(isLocalOrLanIp('fd00::1'), true); + assert.equal(isLocalOrLanIp('fe80::1'), true); + assert.equal(isLocalOrLanIp('8.8.8.8'), false); +}); + +test('getClientIp trusts Forwarded, X-Forwarded-For, and X-Real-IP only when configured', () => { + assert.equal(parseForwardedFor('for="203.0.113.10:8443";proto=https'), '203.0.113.10:8443'); + assert.equal(getClientIp(requestWith({ + forwarded: 'for="203.0.113.10:8443";proto=https', + 'x-forwarded-for': '198.51.100.1', + 'x-real-ip': '198.51.100.2' + }, '127.0.0.1'), { trustProxy: true }), '203.0.113.10'); + assert.equal(getClientIp(requestWith({ + 'x-forwarded-for': '198.51.100.1, 198.51.100.2' + }, '127.0.0.1'), { trustProxy: true }), '198.51.100.1'); + assert.equal(getClientIp(requestWith({ + 'x-real-ip': '198.51.100.2' + }, '127.0.0.1'), { trustProxy: true }), '198.51.100.2'); + assert.equal(getClientIp(requestWith({ + 'x-forwarded-for': '198.51.100.1' + }, '127.0.0.1'), { trustProxy: false }), '127.0.0.1'); +}); + +test('auth checker sanitizes challenges and uses constant-shape credential comparison', () => { + const auth = createAuthChecker({ + username: 'user', + password: 'pass', + realm: 'Steam "Chat"', + trustProxy: true + }); + + assert.equal(timingSafeTextEqual('same', 'same'), true); + assert.equal(timingSafeTextEqual('same', 'different length'), false); + assert.equal(auth.isAuthorized(requestWith({ + 'x-forwarded-for': '8.8.8.8', + authorization: `Basic ${Buffer.from('user:pass').toString('base64')}` + }, '127.0.0.1')), true); + assert.equal(auth.isAuthorized(requestWith({ + 'x-forwarded-for': '8.8.8.8', + authorization: `Basic ${Buffer.from('user:wrong').toString('base64')}` + }, '127.0.0.1')), false); + + let statusCode = 0; + let headers: Record = {}; + let body = ''; + auth.challenge({ + writeHead(code: number, nextHeaders: Record) { + statusCode = code; + headers = nextHeaders; + }, + end(nextBody: string) { + body = nextBody; + } + }); + + assert.equal(statusCode, 401); + assert.equal(headers['WWW-Authenticate'], 'Basic realm="Steam Chat", charset="UTF-8"'); + assert.deepEqual(JSON.parse(body), { error: 'Unauthorized' }); +}); diff --git a/test/chat-service-helpers.test.ts b/test/chat-service-helpers.test.ts new file mode 100644 index 0000000..1bfcd0f --- /dev/null +++ b/test/chat-service-helpers.test.ts @@ -0,0 +1,107 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { PassThrough } = require('node:stream'); +const test = require('node:test'); + +const { + decodeBase64Image, + defaultGetEmoticons, + listFriends, + listGroups, + readRequestBody +} = require('../src/server/chat-service'); + +test('listFriends normalizes persona fields and sorts online friends by name', async () => { + const friends = await listFriends({ + myFriends: { + offline: 0, + zed: 1, + amy: 2 + }, + users: { + offline: { name: 'Bob' }, + zed: { personaName: 'Zed', avatar_url_icon: 'zed-icon', game_name: 'Playing Z' }, + amy: { player_name: 'Amy', avatar_url_medium: 'amy-avatar', personaState: 2, gameName: 'Playing A' } + } + }); + + assert.deepEqual(friends.map((friend: { id: string }) => friend.id), ['amy', 'zed', 'offline']); + assert.deepEqual(friends.map((friend: { online: boolean }) => friend.online), [true, true, false]); + assert.equal(friends[0].avatar, 'amy-avatar'); + assert.equal(friends[0].gameName, 'Playing A'); + assert.equal(friends[1].avatar, 'zed-icon'); + assert.equal(friends[1].gameName, 'Playing Z'); +}); + +test('listGroups accepts array and object group shapes', async () => { + const fromArray = await listGroups({ + myGroups: [ + { steamID: { getSteamID64: () => '100' }, clanID: 'clan-100', group_name: 'Array Group' }, + 'raw-group' + ] + }); + assert.deepEqual(fromArray, [ + { id: '100', clanId: 'clan-100', name: 'Array Group' }, + { id: 'raw-group', clanId: '', name: 'raw-group' } + ]); + + const fromObject = await listGroups({ + groups: { + fallback: { clanid: 'clan-fallback', name: 'Object Group' }, + simple: null + } + }); + assert.deepEqual(fromObject, [ + { id: 'fallback', clanId: 'clan-fallback', name: 'Object Group' }, + { id: 'simple', clanId: '', name: 'simple' } + ]); +}); + +test('defaultGetEmoticons waits for login and supports callback-style chat APIs', async () => { + const order: string[] = []; + const steamUser = { + chat: { + getEmoticonList(callback: (error: unknown, response?: unknown) => void) { + order.push('source'); + callback(null, { + emoticon_list: [{ name: ':wave:' }], + sticker_list: [{ name: 'sticker' }] + }); + } + } + }; + + const data = await defaultGetEmoticons({ + steamUser, + waitForLogin: async () => { + order.push('login'); + }, + waitForWebSession: async () => { + order.push('web'); + } + }); + + assert.deepEqual(order, ['login', 'web', 'source']); + assert.deepEqual(data.emoticons, [{ name: ':wave:' }]); + assert.deepEqual(data.stickers, [{ name: 'sticker' }]); +}); + +test('decodeBase64Image accepts data URLs and readRequestBody enforces byte limits', async () => { + const encoded = Buffer.from('hello image').toString('base64'); + assert.deepEqual(decodeBase64Image(`data:image/png;base64,${encoded}`), Buffer.from('hello image')); + + const okReq = new PassThrough(); + const okBody = readRequestBody(okReq, 5); + okReq.end('12345'); + assert.equal(await okBody, '12345'); + + const largeReq = new PassThrough(); + const tooLarge = readRequestBody(largeReq, 3); + largeReq.end('1234'); + await assert.rejects(tooLarge, (error: Error & { statusCode?: number }) => { + assert.equal(error.statusCode, 413); + assert.match(error.message, /too large/); + return true; + }); +}); diff --git a/test/chat.test.js b/test/chat.test.js deleted file mode 100644 index 7fb922c..0000000 --- a/test/chat.test.js +++ /dev/null @@ -1,1072 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { EventEmitter } = require('node:events'); -const { once } = require('node:events'); -const { Readable } = require('node:stream'); - -process.env.STEAM_CHAT_DISABLE_AUTOSTART = '1'; - -const { - CHAT_LOG_FILE, - IMAGE_CACHE_DIR, - STICKER_CACHE_DIR, - buildConversationPreview, - buildImageCachePaths, - buildStickerCachePath, - buildSteamStickerCandidateUrls, - createChatService, - extractEmoticonNames, - extractImageUrls, - extractOpenGraphEmbeds, - getClientIp, - guessImageContentType, - isLanIp, - normalizeAuthConfig, - normalizeChatConfig, - normalizeHistoryEntry, - normalizeIpAddress, - normalizeWsRequest, - parseBasicAuthHeader, - parseForwardedHeader, - requiresHttpAuth, -} = require('../chat'); - -class FakeWebSocketServer { - constructor(options) { - const { server, path } = options; - this.server = server; - this.path = path; - this.options = options; - this.clients = new Set(); - this.handlers = new Map(); - } - - on(event, handler) { - this.handlers.set(event, handler); - } -} - -const FakeWebSocket = { - OPEN: 1, - Server: FakeWebSocketServer, -}; - -class FakeSteamUser extends EventEmitter { - constructor() { - super(); - this.steamID = 'self-id'; - this.chat = new EventEmitter(); - } - - webLogOn() {} -} - -function createMockResponse() { - return { - statusCode: 200, - headers: {}, - body: '', - writeHead(statusCode, headers) { - this.statusCode = statusCode; - if (headers) { - Object.assign(this.headers, headers); - } - }, - setHeader(name, value) { - this.headers[name] = value; - }, - end(body) { - this.body = body; - }, - }; -} - -function createMockRequest(method, url, payload) { - const req = new EventEmitter(); - req.method = method; - req.url = url; - req.headers = {}; - req.socket = { - remoteAddress: '127.0.0.1', - }; - - process.nextTick(() => { - if (payload !== undefined) { - req.emit('data', Buffer.from(payload)); - } - req.emit('end'); - }); - - return req; -} - -function createBroadcastClient() { - return { - readyState: FakeWebSocket.OPEN, - messages: [], - send(payload) { - this.messages.push(JSON.parse(payload)); - }, - }; -} - -function createService(overrides = {}) { - const logger = { - info() {}, - warn() {}, - error() {}, - }; - - const fsCalls = []; - let logContent = overrides.logContent || ''; - const extraFiles = new Map(Object.entries(overrides.extraFiles || {})); - const fsModule = { - mkdir(_path, _options, cb) { - cb(null); - }, - appendFile(path, data, cb) { - fsCalls.push({ path, data }); - if (path === CHAT_LOG_FILE) { - logContent += data; - } - cb(null); - }, - readFile(path, encoding, cb) { - if (typeof encoding === 'function') { - cb = encoding; - encoding = undefined; - } - - if (path === CHAT_LOG_FILE) { - if (encoding) { - assert.equal(encoding, 'utf8'); - } - cb(null, logContent); - return; - } - - if (extraFiles.has(path)) { - cb(null, extraFiles.get(path)); - return; - } - - const err = new Error('not found'); - err.code = 'ENOENT'; - cb(err); - }, - writeFile(path, data, cb) { - extraFiles.set(path, data); - cb(null); - }, - createReadStream(path, options) { - assert.equal(path, CHAT_LOG_FILE); - const stream = new Readable(); - stream.push(logContent); - stream.push(null); - return stream; - }, - }; - - const server = { - listenArgs: null, - listen(port, host, cb) { - this.listenArgs = { port, host }; - cb(null); - }, - }; - - const httpModule = { - createServer(handler) { - server.handler = handler; - return server; - }, - }; - - const steamUser = new FakeSteamUser(); - steamUser.chat.sendFriendMessage = (uid, msg, cb) => { - cb(null, { - server_timestamp: new Date('2024-01-02T03:04:05.678Z'), - modified_message: msg, - ordinal: 42, - }); - }; - - const steamCommunity = { - sendImageToUser(uid, imageBuffer, cb) { - cb(null, `https://image/${uid}/${imageBuffer.length}`); - }, - }; - - const client = { - steamUser, - steamCommunity, - steamLoginPromise: Promise.resolve(), - steamWebLoginPromise: Promise.resolve(), - async getUserInfo(steamID) { - return { - player_name: steamID === 'self-id' ? 'Self User' : 'Friend User', - }; - }, - }; - - const service = createChatService({ - useDefaultDeps: false, - rawChatConfig: { - enabled: true, - host: '127.0.0.1', - port: 4000, - wsPath: '/chat', - }, - client, - logger, - steamUser, - steamCommunity, - fsModule, - httpModule, - onceFn: once, - axiosInstance: { - get: async (url) => ({ data: Buffer.from(String(url).includes('/sticker/') ? 'sticker-image' : 'image-by-url') }), - }, - WebSocketImpl: FakeWebSocket, - dateToString: () => 'formatted-date', - ...overrides, - }); - - return { - service, - client, - steamUser, - steamCommunity, - server, - fsCalls, - }; -} - -test('normalizeChatConfig supports boolean and object configs', () => { - assert.deepEqual(normalizeChatConfig(true), { - enabled: true, - host: '0.0.0.0', - port: 3000, - wsPath: '/ws', - auth: { - username: '', - password: '', - realm: 'Steam Chat', - trustProxy: false, - }, - }); - - assert.deepEqual(normalizeChatConfig({ - enabled: false, - host: '127.0.0.1', - port: 8080, - wsPath: '/chat', - auth: { - username: 'alice', - password: 'secret', - trustProxy: true, - }, - }), { - enabled: false, - host: '127.0.0.1', - port: 8080, - wsPath: '/chat', - auth: { - username: 'alice', - password: 'secret', - realm: 'Steam Chat', - trustProxy: true, - }, - }); -}); - -test('auth and client ip helpers support proxy-aware LAN checks', () => { - assert.deepEqual(normalizeAuthConfig({ - username: 'alice', - password: 'secret', - trustProxy: true, - }), { - username: 'alice', - password: 'secret', - realm: 'Steam Chat', - trustProxy: true, - }); - - assert.equal(normalizeIpAddress('::ffff:192.168.1.10'), '192.168.1.10'); - assert.equal(normalizeIpAddress('[2001:db8::1]:443'), '2001:db8::1'); - assert.equal(parseForwardedHeader('for=192.168.1.20;proto=https, for=8.8.8.8'), '192.168.1.20'); - - assert.equal(isLanIp('192.168.1.20'), true); - assert.equal(isLanIp('172.20.1.9'), true); - assert.equal(isLanIp('8.8.8.8'), false); - assert.equal(isLanIp('fd00::1234'), true); - - const proxiedReq = { - headers: { - 'x-forwarded-for': '8.8.8.8, 192.168.1.20', - authorization: `Basic ${Buffer.from('alice:secret').toString('base64')}`, - }, - socket: { - remoteAddress: '127.0.0.1', - }, - }; - - assert.equal(getClientIp(proxiedReq, true), '8.8.8.8'); - assert.deepEqual(parseBasicAuthHeader(proxiedReq.headers.authorization), { - username: 'alice', - password: 'secret', - }); - assert.equal(requiresHttpAuth(proxiedReq, { - auth: { - username: 'alice', - password: 'secret', - trustProxy: true, - }, - }), true); -}); - -test('normalizeWsRequest maps legacy and new websocket message types', () => { - assert.deepEqual(normalizeWsRequest({ - type: 'msg', - requestId: '1', - id: 'friend', - msg: 'hello', - }), { - action: 'send_message', - requestId: '1', - id: 'friend', - msg: 'hello', - }); - - assert.deepEqual(normalizeWsRequest({ - type: 'send_image', - requestId: '2', - id: 'friend', - url: 'https://example.com/a.png', - }), { - action: 'send_image', - requestId: '2', - id: 'friend', - img: undefined, - url: 'https://example.com/a.png', - }); - - assert.deepEqual(normalizeWsRequest({ - type: 'get_history', - requestId: '3', - id: 'friend', - limit: 50, - }), { - action: 'get_history', - requestId: '3', - id: 'friend', - limit: 50, - }); - - assert.deepEqual(normalizeWsRequest({ - type: 'get_conversations', - requestId: '4', - limit: 20, - }), { - action: 'get_conversations', - requestId: '4', - limit: 20, - }); -}); - -test('normalizeHistoryEntry fills defaults for old log format', () => { - assert.deepEqual(normalizeHistoryEntry({ - date: '2026-03-20 00:00:00.000', - echo: false, - id: 'friend', - name: 'Friend', - message: 'hello', - ordinal: 1, - }), { - type: 'message', - date: '2026-03-20 00:00:00.000', - echo: false, - id: 'friend', - name: 'Friend', - message: 'hello', - imageUrl: null, - ordinal: 1, - sentAt: null, - }); -}); - -test('extractEmoticonNames parses steam emoticon syntax', () => { - assert.deepEqual( - extractEmoticonNames('hi :steamhappy: [emoticon name="cozy"][/emoticon]').sort(), - ['cozy', 'steamhappy'], - ); - - assert.equal(buildConversationPreview({ - type: 'message', - message: ':steamhappy: :cozy:', - }), '[表情] steamhappy cozy'); -}); - -test('extractEmoticonNames parses [emoticon]name[/emoticon] format', () => { - assert.deepEqual( - extractEmoticonNames('[emoticon]angrylolo[/emoticon]'), - ['angrylolo'], - ); - - assert.deepEqual( - extractEmoticonNames('[emoticon]angrylolo[/emoticon] [emoticon name="cozy"][/emoticon] :steamhappy:').sort(), - ['angrylolo', 'cozy', 'steamhappy'], - ); - - assert.equal(buildConversationPreview({ - type: 'message', - message: '[emoticon]angrylolo[/emoticon]', - }), '[表情] angrylolo'); - - assert.equal(buildConversationPreview({ - type: 'message', - message: '[emoticon]angrylolo[/emoticon][emoticon]steamhappy[/emoticon]', - }), '[表情] angrylolo steamhappy'); -}); - -test('extractImageUrls parses bbcode img, html img and raw image urls', () => { - assert.deepEqual( - extractImageUrls('[img]https://a.com/1.png[/img] https://c.com/3.webp').sort(), - ['https://a.com/1.png', 'https://b.com/2.jpg', 'https://c.com/3.webp'], - ); - - assert.equal(buildConversationPreview({ - type: 'message', - message: '[img]https://a.com/1.png[/img]', - }), '[图片]'); -}); - -test('extractOpenGraphEmbeds parses steam og embed and uses title as preview', () => { - const embeds = extractOpenGraphEmbeds('[og url="https://www.bilibili.com/video/BV1n6A5zAEb7/" img="https://community.steamstatic.com/chat/image/share_image.png" title="伊朗:击中美军F-35战机_哔哩哔哩_bilibili"]https://www.bilibili.com/video/BV1n6A5zAEb7/[/og]'); - assert.deepEqual(embeds, [{ - url: 'https://www.bilibili.com/video/BV1n6A5zAEb7/', - img: 'https://community.steamstatic.com/chat/image/share_image.png', - title: '伊朗:击中美军F-35战机_哔哩哔哩_bilibili', - }]); - - assert.equal(buildConversationPreview({ - type: 'message', - message: '[og url="https://www.bilibili.com/video/BV1n6A5zAEb7/" img="https://community.steamstatic.com/chat/image/share_image.png" title="伊朗:击中美军F-35战机_哔哩哔哩_bilibili"]https://www.bilibili.com/video/BV1n6A5zAEb7/[/og]', - }), '伊朗:击中美军F-35战机_哔哩哔哩_bilibili'); - - assert.deepEqual( - extractOpenGraphEmbeds('[og url="https://www.bilibili.com/video/av116186884935795" img="https://community.steamstatic.com/chat/image/ht6wqt0rqW0CLNV0RzFC0nkBpimO7nDqFKftPDtI2M4oDWov4xFO5mWdNM5W1keOmLyp4sg5qbmKqxjRCAFx34WeM5-AxxkNc9h8Kelj5m1raqVeV8436wdU1iQIPxbL_A/share_image.png" title="1899年,吸铁石和生瓜蛋子的时代已然走到尽头_哔哩哔哩_bilibili"]https://www.bilibili.com/video/av116186884935795[/og]'), - [{ - url: 'https://www.bilibili.com/video/av116186884935795', - img: 'https://community.steamstatic.com/chat/image/ht6wqt0rqW0CLNV0RzFC0nkBpimO7nDqFKftPDtI2M4oDWov4xFO5mWdNM5W1keOmLyp4sg5qbmKqxjRCAFx34WeM5-AxxkNc9h8Kelj5m1raqVeV8436wdU1iQIPxbL_A/share_image.png', - title: '1899年,吸铁石和生瓜蛋子的时代已然走到尽头_哔哩哔哩_bilibili', - }], - ); -}); - -test('image cache helpers build stable paths and types', () => { - const paths = buildImageCachePaths('https://example.com/a.png?x=1'); - const normalizedCacheDir = IMAGE_CACHE_DIR.replace(/^\.\//, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - assert.match(paths.dataPath, new RegExp(`^${normalizedCacheDir}/[a-f0-9]+\\.bin$`)); - assert.match(paths.metaPath, new RegExp(`^${normalizedCacheDir}/[a-f0-9]+\\.json$`)); - assert.equal(guessImageContentType('https://example.com/a.webp'), 'image/webp'); -}); - -test('buildSteamStickerCandidateUrls returns fallback sticker urls', () => { - assert.deepEqual(buildSteamStickerCandidateUrls('Sticker_MalteseCry'), [ - 'https://steamcommunity-a.akamaihd.net/economy/sticker/Sticker_MalteseCry', - 'https://steamcommunity-a.akamaihd.net/economy/stickerlarge/Sticker_MalteseCry', - 'https://steamcommunity.com/economy/sticker/Sticker_MalteseCry', - 'https://steamcommunity.com/economy/stickerlarge/Sticker_MalteseCry', - ]); - assert.equal( - buildStickerCachePath('Sticker_MalteseCry'), - `${STICKER_CACHE_DIR.replace(/^\.\//, '')}/Sticker_MalteseCry.bin`, - ); -}); - -test('handleHttp returns config for GET /api/config', async () => { - const { service } = createService(); - const req = createMockRequest('GET', '/api/config'); - const res = createMockResponse(); - - await service.handleHttp(req, res); - - assert.equal(res.statusCode, 200); - assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8'); - assert.deepEqual(JSON.parse(res.body), { wsPath: '/chat' }); -}); - -test('handleHttp requires basic auth for non-LAN requests when configured', async () => { - const { service } = createService({ - rawChatConfig: { - enabled: true, - host: '127.0.0.1', - port: 4000, - wsPath: '/chat', - auth: { - username: 'alice', - password: 'secret', - trustProxy: true, - }, - }, - }); - - const req = createMockRequest('GET', '/api/config'); - req.headers['x-forwarded-for'] = '8.8.8.8'; - const res = createMockResponse(); - - await service.handleHttp(req, res); - - assert.equal(res.statusCode, 401); - assert.match(res.headers['WWW-Authenticate'], /^Basic realm="Steam Chat"$/); - assert.deepEqual(JSON.parse(res.body), { error: 'Authentication Required' }); -}); - -test('handleHttp allows LAN requests and authenticated proxied requests', async () => { - const rawChatConfig = { - enabled: true, - host: '127.0.0.1', - port: 4000, - wsPath: '/chat', - auth: { - username: 'alice', - password: 'secret', - trustProxy: true, - }, - }; - - const { service } = createService({ rawChatConfig }); - - const lanReq = createMockRequest('GET', '/api/config'); - lanReq.headers['x-forwarded-for'] = '192.168.1.20'; - const lanRes = createMockResponse(); - - await service.handleHttp(lanReq, lanRes); - - assert.equal(lanRes.statusCode, 200); - - const authReq = createMockRequest('GET', '/api/config'); - authReq.headers['x-forwarded-for'] = '8.8.8.8'; - authReq.headers.authorization = `Basic ${Buffer.from('alice:secret').toString('base64')}`; - const authRes = createMockResponse(); - - await service.handleHttp(authReq, authRes); - - assert.equal(authRes.statusCode, 200); - assert.deepEqual(JSON.parse(authRes.body), { wsPath: '/chat' }); -}); - -test('handleSendMessageRequest broadcasts messages and deduplicates echoed messages', async () => { - const { service, fsCalls } = createService(); - const wsClient = createBroadcastClient(); - service.wss.clients.add(wsClient); - - const data = await service.handleSendMessageRequest({ - id: 'friend-id', - msg: 'hello', - }); - - assert.equal(data.echo, true); - assert.equal(data.name, 'Self User'); - assert.equal(wsClient.messages.length, 1); - assert.deepEqual(wsClient.messages[0], { - type: 'message', - data, - }); - assert.equal(fsCalls.length, 1); - - await service.broadcastSteamMessage({ - server_timestamp: new Date('2024-01-02T03:04:05.678Z'), - steamid_friend: 'friend-id', - message: 'hello', - ordinal: 42, - }, true, { dedupe: true }); - - assert.equal(wsClient.messages.length, 1); -}); - -test('sendImageToUser retries after refreshing web session', async () => { - const expectedBuffer = Buffer.from('fake-image'); - const steamUser = new FakeSteamUser(); - let uploadAttempts = 0; - let webLogOnCalled = 0; - - steamUser.webLogOn = () => { - webLogOnCalled += 1; - setImmediate(() => { - steamUser.emit('webSession', 'session-id', []); - }); - }; - - const service = createService({ - steamUser, - steamCommunity: { - sendImageToUser(_uid, imageBuffer, cb) { - uploadAttempts += 1; - assert.deepEqual(imageBuffer, expectedBuffer); - if (uploadAttempts === 1) { - cb(new Error('expired session')); - return; - } - cb(null, 'https://image/friend-id/retried'); - }, - }, - client: { - steamUser, - steamCommunity: null, - steamLoginPromise: Promise.resolve(), - steamWebLoginPromise: Promise.resolve(), - async getUserInfo(steamID) { - return { - player_name: steamID === 'self-id' ? 'Self User' : 'Friend User', - }; - }, - }, - }).service; - - const imageUrl = await service.sendImageToUser('friend-id', expectedBuffer.toString('base64')); - - assert.equal(imageUrl, 'https://image/friend-id/retried'); - assert.equal(uploadAttempts, 2); - assert.equal(webLogOnCalled, 1); -}); - -test('sendImageToUser retries once for transient TLS error before refreshing web session', async () => { - const expectedBuffer = Buffer.from('fake-image'); - const steamUser = new FakeSteamUser(); - let uploadAttempts = 0; - let webLogOnCalled = 0; - - steamUser.webLogOn = () => { - webLogOnCalled += 1; - }; - - const service = createService({ - steamUser, - steamCommunity: { - sendImageToUser(_uid, imageBuffer, cb) { - uploadAttempts += 1; - assert.deepEqual(imageBuffer, expectedBuffer); - if (uploadAttempts === 1) { - const error = new Error('Client network socket disconnected before secure TLS connection was established'); - error.code = 'ECONNRESET'; - cb(error); - return; - } - cb(null, 'https://image/friend-id/retried'); - }, - }, - client: { - steamUser, - steamCommunity: null, - steamLoginPromise: Promise.resolve(), - steamWebLoginPromise: Promise.resolve(), - async getUserInfo(steamID) { - return { - player_name: steamID === 'self-id' ? 'Self User' : 'Friend User', - }; - }, - }, - }).service; - - const imageUrl = await service.sendImageToUser('friend-id', expectedBuffer.toString('base64')); - - assert.equal(imageUrl, 'https://image/friend-id/retried'); - assert.equal(uploadAttempts, 2); - assert.equal(webLogOnCalled, 0); -}); - -test('handleHttp returns JSON response for message endpoint', async () => { - const { service } = createService(); - const req = createMockRequest('POST', '/message', JSON.stringify({ - id: 'friend-id', - msg: 'hello via http', - })); - const res = createMockResponse(); - - await service.handleHttp(req, res); - - assert.equal(res.statusCode, 200); - assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8'); - assert.deepEqual(JSON.parse(res.body), { - type: 'message', - date: 'formatted-date', - echo: true, - id: 'friend-id', - name: 'Self User', - message: 'hello via http', - ordinal: 42, - imageUrl: null, - sentAt: null, - }); -}); - -test('handleWsCommand responds to ping requests', async () => { - const { service } = createService(); - const ws = createBroadcastClient(); - - await service.handleWsCommand(ws, { - type: 'ping', - requestId: 'ping-1', - }); - - assert.equal(ws.messages.length, 1); - assert.equal(ws.messages[0].type, 'pong'); - assert.equal(ws.messages[0].requestId, 'ping-1'); - assert.ok(ws.messages[0].data.now); -}); - -test('websocket verifyClient requires auth for non-LAN requests', async () => { - const { service } = createService({ - rawChatConfig: { - enabled: true, - host: '127.0.0.1', - port: 4000, - wsPath: '/chat', - auth: { - username: 'alice', - password: 'secret', - trustProxy: true, - }, - }, - }); - - const verifyClient = service.wss.options.verifyClient; - - const denied = await new Promise((resolve) => { - verifyClient({ - req: { - headers: { - 'x-forwarded-for': '8.8.8.8', - }, - socket: { - remoteAddress: '127.0.0.1', - }, - }, - }, (...args) => resolve(args)); - }); - - assert.deepEqual(denied, [ - false, - 401, - 'Authentication Required', - { - 'WWW-Authenticate': 'Basic realm="Steam Chat"', - }, - ]); - - const allowed = await new Promise((resolve) => { - verifyClient({ - req: { - headers: { - 'x-forwarded-for': '8.8.8.8', - authorization: `Basic ${Buffer.from('alice:secret').toString('base64')}`, - }, - socket: { - remoteAddress: '127.0.0.1', - }, - }, - }, (...args) => resolve(args)); - }); - - assert.deepEqual(allowed, [true]); -}); - -test('handleHttp serves homepage for GET /', async () => { - const { service } = createService(); - const req = createMockRequest('GET', '/'); - const res = createMockResponse(); - - await service.handleHttp(req, res); - - // serveStaticFile reads the real public/index.html via fs.readFile (async callback), - // so we need to wait for the callback to complete - await new Promise((resolve) => setTimeout(resolve, 50)); - - assert.equal(res.statusCode, 200); - assert.equal(res.headers['Content-Type'], 'text/html; charset=utf-8'); - const body = typeof res.body === 'string' ? res.body : res.body.toString(); - assert.match(body, /Steam Chat/); -}); - -test('handleHttp proxies sticker and caches it locally', async () => { - const { service } = createService(); - const req = createMockRequest('GET', '/proxy/sticker/Sticker_MalteseCry'); - const res = createMockResponse(); - - await service.handleHttp(req, res); - - assert.equal(res.statusCode, 200); - assert.equal(res.headers['Content-Type'], 'image/png'); - assert.deepEqual(res.body, Buffer.from('sticker-image')); - - const cached = await service.fetchStickerBuffer('Sticker_MalteseCry'); - assert.deepEqual(cached, Buffer.from('sticker-image')); -}); - -test('fetchStickerBuffer coalesces concurrent requests for the same sticker', async () => { - let axiosCalls = 0; - let releaseFetch; - const fetchGate = new Promise((resolve) => { - releaseFetch = resolve; - }); - - const { service } = createService({ - axiosInstance: { - get: async (url) => { - axiosCalls += 1; - await fetchGate; - return { - data: Buffer.from('shared-sticker'), - headers: { - 'content-type': 'image/png', - }, - }; - }, - }, - }); - - const firstFetch = service.fetchStickerBuffer('Sticker_MalteseCry'); - const secondFetch = service.fetchStickerBuffer('Sticker_MalteseCry'); - - releaseFetch(); - - const [firstSticker, secondSticker] = await Promise.all([firstFetch, secondFetch]); - - assert.equal(axiosCalls, 1); - assert.deepEqual(firstSticker, Buffer.from('shared-sticker')); - assert.deepEqual(secondSticker, firstSticker); - - const cached = await service.fetchStickerBuffer('Sticker_MalteseCry'); - assert.equal(axiosCalls, 1); - assert.deepEqual(cached, firstSticker); -}); - -test('handleHttp proxies remote image and caches it locally', async () => { - const { service } = createService(); - const req = createMockRequest('GET', '/proxy/image?url=' + encodeURIComponent('https://example.com/a.png')); - const res = createMockResponse(); - - await service.handleHttp(req, res); - - assert.equal(res.statusCode, 200); - assert.equal(res.headers['Content-Type'], 'image/png'); - assert.deepEqual(res.body, Buffer.from('image-by-url')); - - const cached = await service.fetchCachedImage('https://example.com/a.png'); - assert.equal(cached.contentType, 'image/png'); - assert.deepEqual(cached.buffer, Buffer.from('image-by-url')); -}); - -test('fetchCachedImage coalesces concurrent requests for the same image', async () => { - let axiosCalls = 0; - let releaseFetch; - const fetchGate = new Promise((resolve) => { - releaseFetch = resolve; - }); - - const { service } = createService({ - axiosInstance: { - get: async (url) => { - axiosCalls += 1; - await fetchGate; - return { - data: Buffer.from('shared-image'), - headers: { - 'content-type': 'image/png', - }, - }; - }, - }, - }); - - const firstFetch = service.fetchCachedImage('https://example.com/shared.png'); - const secondFetch = service.fetchCachedImage('https://example.com/shared.png'); - - releaseFetch(); - - const [firstImage, secondImage] = await Promise.all([firstFetch, secondFetch]); - - assert.equal(axiosCalls, 1); - assert.deepEqual(firstImage, { - buffer: Buffer.from('shared-image'), - contentType: 'image/png', - }); - assert.deepEqual(secondImage, firstImage); - - const cached = await service.fetchCachedImage('https://example.com/shared.png'); - assert.equal(axiosCalls, 1); - assert.deepEqual(cached, firstImage); -}); - -test('readChatHistory filters by steam id and limits results', async () => { - const { service } = createService({ - logContent: [ - JSON.stringify({ date: '1', echo: false, id: 'a', name: 'A', message: 'x', ordinal: 1 }), - JSON.stringify({ type: 'image', date: '2', echo: true, id: 'b', name: 'Self', imageUrl: 'https://img/1' }), - JSON.stringify({ date: '3', echo: false, id: 'a', name: 'A', message: 'y', ordinal: 2 }), - ].join('\n') + '\n', - }); - - const items = await service.readChatHistory({ id: 'a', limit: 1 }); - assert.deepEqual(items, [{ - type: 'message', - date: '3', - echo: false, - id: 'a', - name: 'A', - message: 'y', - imageUrl: null, - ordinal: 2, - sentAt: null, - }]); -}); - -test('handleWsCommand returns history from local logs', async () => { - const { service } = createService({ - logContent: [ - JSON.stringify({ date: '2026-03-20 10:00:00.000', echo: false, id: 'friend-id', name: 'Friend', message: 'hello', ordinal: 1 }), - JSON.stringify({ type: 'image', date: '2026-03-20 10:01:00.000', echo: true, id: 'friend-id', name: 'Self User', imageUrl: 'https://image/friend-id/1' }), - ].join('\n') + '\n', - }); - const ws = createBroadcastClient(); - - await service.handleWsCommand(ws, { - type: 'get_history', - requestId: 'history-1', - id: 'friend-id', - limit: 10, - }); - - assert.equal(ws.messages.length, 1); - assert.deepEqual(ws.messages[0], { - type: 'history', - requestId: 'history-1', - data: { - items: [ - { - type: 'message', - date: '2026-03-20 10:00:00.000', - echo: false, - id: 'friend-id', - name: 'Friend', - message: 'hello', - imageUrl: null, - ordinal: 1, - sentAt: null, - }, - { - type: 'image', - date: '2026-03-20 10:01:00.000', - echo: true, - id: 'friend-id', - name: 'Self User', - message: '', - imageUrl: 'https://image/friend-id/1', - ordinal: null, - sentAt: null, - }, - ], - }, - }); -}); - -test('handleSendImageRequest appends image log and broadcasts image payload', async () => { - const { service, fsCalls } = createService(); - const wsClient = createBroadcastClient(); - service.wss.clients.add(wsClient); - - const data = await service.handleSendImageRequest({ - id: 'friend-id', - img: Buffer.from('fake-image').toString('base64'), - }); - - assert.equal(data.type, 'image'); - assert.equal(data.id, 'friend-id'); - assert.equal(data.name, 'Self User'); - assert.equal(data.imageUrl, 'https://image/friend-id/10'); - assert.equal(wsClient.messages.length, 1); - assert.deepEqual(wsClient.messages[0], { - type: 'image', - data, - }); - assert.equal(fsCalls.length, 1); - assert.match(fsCalls[0].data, /"type":"image"/); -}); - -test('readConversationSummaries groups recent conversations', async () => { - const { service } = createService({ - logContent: [ - JSON.stringify({ date: '2026-03-20 09:00:00.000', echo: false, id: 'a', name: 'Alice', message: '早', ordinal: 1 }), - JSON.stringify({ date: '2026-03-20 09:10:00.000', echo: true, id: 'b', name: 'Self User', message: 'https://example.com/a.png', ordinal: 1 }), - JSON.stringify({ date: '2026-03-20 09:20:00.000', echo: false, id: 'a', name: 'Alice', message: '[sticker type="Sticker_MalteseCry" limit="0"][/sticker]', ordinal: 2 }), - ].join('\n') + '\n', - }); - - const items = await service.readConversationSummaries({ limit: 10 }); - assert.deepEqual(items, [ - { - id: 'a', - name: 'Alice', - updatedAt: '2026-03-20 09:20:00.000', - preview: '[贴纸] MalteseCry', - lastType: 'message', - lastEcho: false, - messageCount: 2, - }, - { - id: 'b', - name: 'Friend User', - updatedAt: '2026-03-20 09:10:00.000', - preview: '[图片]', - lastType: 'message', - lastEcho: true, - messageCount: 1, - }, - ]); -}); - -test('handleWsCommand returns conversation summaries', async () => { - const { service } = createService({ - logContent: [ - JSON.stringify({ date: '2026-03-20 11:00:00.000', echo: false, id: 'friend-1', name: 'Alice', message: 'hello', ordinal: 1 }), - JSON.stringify({ type: 'image', date: '2026-03-20 11:05:00.000', echo: true, id: 'friend-2', name: 'Self User', imageUrl: 'https://image/friend-2/1' }), - ].join('\n') + '\n', - }); - const ws = createBroadcastClient(); - - await service.handleWsCommand(ws, { - type: 'get_conversations', - requestId: 'conv-1', - limit: 20, - }); - - assert.equal(ws.messages.length, 1); - assert.deepEqual(ws.messages[0], { - type: 'conversations', - requestId: 'conv-1', - data: { - items: [ - { - id: 'friend-2', - name: 'Friend User', - updatedAt: '2026-03-20 11:05:00.000', - preview: '[图片]', - lastType: 'image', - lastEcho: true, - messageCount: 1, - }, - { - id: 'friend-1', - name: 'Alice', - updatedAt: '2026-03-20 11:00:00.000', - preview: 'hello', - lastType: 'message', - lastEcho: false, - messageCount: 1, - }, - ], - }, - }); -}); diff --git a/test/chat.test.ts b/test/chat.test.ts new file mode 100644 index 0000000..ec6e472 --- /dev/null +++ b/test/chat.test.ts @@ -0,0 +1,210 @@ +'use strict'; + +import type { EventEmitter as EventEmitterType } from 'node:events'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { TestContext } from 'node:test'; +import type { RawData, WebSocket as WsConnection } from 'ws'; +import { isRecord } from '../src/types'; + +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const WebSocket = require('ws'); + +const { + createAuthChecker, + createChatService, + inferImageContentType, + isAllowedRemoteImageUrl, + normalizeChatConfig +} = require('../src/server/chat-service'); + +type WsMessage = Record & { + items?: Array>; +}; + +type WsInbox = { + next: () => Promise; +}; + +type TestSteamUser = EventEmitterType & { + chat: { + sendFriendMessage: (id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) => void; + }; +}; + +type ChatServiceRuntime = { + server: Server; + stop: () => Promise; +}; + +type WsConstructor = new (url: string) => WsConnection; +const WsClient = WebSocket as WsConstructor; + +function listen(server: Server): Promise { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address() as AddressInfo; + resolve(address.port); + }); + }); +} + +function wsOpen(url: string): Promise<{ ws: WsConnection; inbox: WsInbox }> { + return new Promise((resolve, reject) => { + const ws = new WsClient(url); + const inbox = createWsInbox(ws); + ws.once('open', () => resolve({ ws, inbox })); + ws.once('error', reject); + }); +} + +function createWsInbox(ws: WsConnection): WsInbox { + const queue: WsMessage[] = []; + const waiters: Array<{ resolve: (value: WsMessage) => void }> = []; + ws.on('message', (data: RawData) => { + const parsed: unknown = JSON.parse(data.toString()); + const payload: WsMessage = isRecord(parsed) ? parsed : {}; + const waiter = waiters.shift(); + if (waiter) waiter.resolve(payload); + else queue.push(payload); + }); + return { + next() { + if (queue.length) return Promise.resolve(queue.shift()); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for WebSocket message')), 2000); + waiters.push({ + resolve(value: WsMessage) { + clearTimeout(timer); + resolve(value); + } + }); + }); + } + }; +} + +test('normalizeChatConfig accepts boolean shorthand', () => { + assert.equal(normalizeChatConfig(true).enabled, true); + assert.equal(normalizeChatConfig({ port: 3100 }).port, 3100); +}); + +test('image proxy URL guard rejects local and private targets', () => { + assert.equal(isAllowedRemoteImageUrl('https://example.com/a.png'), true); + assert.equal(isAllowedRemoteImageUrl('ftp://example.com/a.png'), false); + assert.equal(isAllowedRemoteImageUrl('http://localhost/a.png'), false); + assert.equal(isAllowedRemoteImageUrl('http://127.0.0.1/a.png'), false); + assert.equal(isAllowedRemoteImageUrl('http://192.168.1.2/a.png'), false); + assert.equal(inferImageContentType('https://x.test/a.webp'), 'image/webp'); +}); + +test('auth checker bypasses local clients and validates proxied public clients with timing safe hashes', () => { + const auth = createAuthChecker({ + username: 'u', + password: 'p', + trustProxy: true + }); + assert.equal(auth.isAuthorized({ + headers: {}, + socket: { remoteAddress: '127.0.0.1' } + }), true); + assert.equal(auth.isAuthorized({ + headers: { 'x-forwarded-for': '8.8.8.8' }, + socket: { remoteAddress: '127.0.0.1' } + }), false); + assert.equal(auth.isAuthorized({ + headers: { + 'x-forwarded-for': '8.8.8.8', + authorization: `Basic ${Buffer.from('u:p').toString('base64')}` + }, + socket: { remoteAddress: '127.0.0.1' } + }), true); +}); + +test('HTTP API sends messages, writes history, and builds conversations', async (t: TestContext) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-http-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + steamUser.chat = { + sendFriendMessage(id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) { + callback(null, { id, msg }); + } + }; + const service = createChatService({ + config: { host: '127.0.0.1', port: 0, wsPath: '/ws' }, + steamUser, + logPath, + getSelfName: async () => 'Me', + logger: { info() {}, warn() {}, error() {} } + }) as ChatServiceRuntime; + t.after(() => service.stop().catch(() => {})); + const port = await listen(service.server); + + const configResponse = await fetch(`http://127.0.0.1:${port}/api/config`); + assert.deepEqual(await configResponse.json(), { wsPath: '/ws' }); + + const sendResponse = await fetch(`http://127.0.0.1:${port}/message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: '7656119', msg: 'hello' }) + }); + assert.equal(sendResponse.status, 200); + const sendPayload = await sendResponse.json(); + assert.equal(sendPayload.item.message, 'hello'); + assert.equal(sendPayload.item.echo, true); + + const history = await (await fetch(`http://127.0.0.1:${port}/history?id=7656119`)).json(); + assert.equal(history.length, 1); + assert.equal(history[0].name, 'Me'); + + const conversations = await (await fetch(`http://127.0.0.1:${port}/conversations`)).json(); + assert.equal(conversations[0].id, '7656119'); + assert.equal(conversations[0].preview, 'hello'); +}); + +test('WebSocket sends ready, handles ping, rejects invalid JSON, and supports history requests', async (t: TestContext) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-ws-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + steamUser.chat = { + sendFriendMessage(id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) { + callback(null); + } + }; + const service = createChatService({ + config: { host: '127.0.0.1', port: 0, wsPath: '/ws' }, + steamUser, + logPath, + getSelfName: async () => 'Me', + logger: { info() {}, warn() {}, error() {} } + }) as ChatServiceRuntime; + t.after(() => service.stop().catch(() => {})); + const port = await listen(service.server); + const { ws, inbox } = await wsOpen(`ws://127.0.0.1:${port}/ws`); + t.after(() => ws.close()); + + assert.deepEqual(await inbox.next(), { type: 'ready', wsPath: '/ws' }); + + ws.send(JSON.stringify({ type: 'ping', requestId: 'p1' })); + assert.deepEqual(await inbox.next(), { requestId: 'p1', type: 'pong' }); + + ws.send('bad json'); + assert.deepEqual(await inbox.next(), { type: 'error', error: 'Invalid JSON' }); + + ws.send(JSON.stringify({ type: 'send_message', id: '42', msg: 'via ws', requestId: 'm1' })); + const sent = await inbox.next(); + assert.equal(sent.type, 'message'); + const receipt = await inbox.next(); + assert.equal(receipt.requestId, 'm1'); + assert.equal(receipt.type, 'message_sent'); + + ws.send(JSON.stringify({ type: 'history', id: '42', requestId: 'h1' })); + const history = await inbox.next(); + assert.equal(history.requestId, 'h1'); + assert.equal(history.items[0].message, 'via ws'); +}); diff --git a/test/logger.test.ts b/test/logger.test.ts new file mode 100644 index 0000000..0551507 --- /dev/null +++ b/test/logger.test.ts @@ -0,0 +1,68 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + appendLog, + buildConversations, + previewForMessage, + readHistory +} = require('../src/storage/chat-log'); + +test('readHistory normalizes, filters, limits, sorts, and skips invalid JSONL lines', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + await fs.writeFile(logPath, [ + '{"id":"2","name":"B","message":"later","date":"2026-06-23 10:12:00.000","ordinal":2}', + 'not json', + '{"id":"1","name":"A","message":"first","date":"2026-06-23 10:10:00.000","ordinal":1}', + '{"id":"1","name":"A","message":"second","date":"2026-06-23 10:10:00.000","ordinal":2}' + ].join('\n')); + + const history = await readHistory({ + logPath, + id: '1', + limit: 10, + logger: { warn() {} } + }); + + assert.equal(history.length, 2); + assert.equal(history[0].type, 'message'); + assert.equal(history[0].message, 'first'); + assert.equal(history[1].message, 'second'); +}); + +test('appendLog and buildConversations generate previews and newest-first summaries', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + await appendLog({ + id: '1', + name: 'Alice', + message: ':wave:', + date: '2026-06-23 10:00:00.000' + }, { logPath }); + await appendLog({ + id: '2', + name: 'Bob', + message: '[sticker type="happy" limit="0"][/sticker]', + date: '2026-06-23 10:02:00.000' + }, { logPath }); + await appendLog({ + type: 'image', + id: '1', + name: 'Alice', + imageUrl: 'https://example.com/a.png', + date: '2026-06-23 10:03:00.000' + }, { logPath }); + + assert.equal(previewForMessage({ type: 'message', message: '[og url="https://e.test" title="OG Title"]x[/og]' }), 'OG Title'); + + const conversations = await buildConversations({ logPath }); + assert.equal(conversations[0].id, '1'); + assert.equal(conversations[0].preview, '[图片]'); + assert.equal(conversations[1].preview, '[贴纸] happy'); +}); diff --git a/test/media-cache.test.ts b/test/media-cache.test.ts new file mode 100644 index 0000000..7e0af01 --- /dev/null +++ b/test/media-cache.test.ts @@ -0,0 +1,135 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + MAX_REMOTE_IMAGE_BYTES, + cacheKeyForUrl, + fetchBuffer, + loadOrDownloadRemoteImage, + loadOrDownloadSticker, + stickerUrlForType +} = require('../src/storage/media-cache'); + +function exactArrayBuffer(bytes: number[]): ArrayBuffer { + const data = Uint8Array.from(bytes); + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); +} + +function responseWith(bytes: number[], contentType = 'image/png'): Response { + return { + ok: true, + status: 200, + headers: { + get(name: string) { + return name.toLowerCase() === 'content-type' ? contentType : ''; + } + }, + async arrayBuffer() { + return exactArrayBuffer(bytes); + } + } as Response; +} + +test('loadOrDownloadRemoteImage shares in-flight downloads and reads later cache hits', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-image-cache-')); + const url = 'https://example.com/unit-image'; + const key = cacheKeyForUrl(url); + let calls = 0; + let userAgent = ''; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fetchImpl = (async (_url: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + userAgent = String((init?.headers as Record)?.['User-Agent'] || ''); + await gate; + return responseWith([1, 2, 3], 'image/jpeg'); + }) as typeof fetch; + + const first = loadOrDownloadRemoteImage(url, { cacheDir, fetchImpl }); + const second = loadOrDownloadRemoteImage(url, { cacheDir, fetchImpl }); + release(); + + const [firstImage, secondImage] = await Promise.all([first, second]); + assert.equal(calls, 1); + assert.equal(userAgent, 'steam-chat/1.0'); + assert.equal(firstImage.fromCache, false); + assert.equal(secondImage.fromCache, false); + assert.deepEqual([...firstImage.buffer], [1, 2, 3]); + assert.equal(firstImage.contentType, 'image/jpeg'); + + const cached = await loadOrDownloadRemoteImage(url, { + cacheDir, + fetchImpl: (async () => { + throw new Error('cache hit should not fetch'); + }) as typeof fetch + }); + assert.equal(cached.fromCache, true); + assert.deepEqual([...cached.buffer], [1, 2, 3]); + assert.equal(cached.contentType, 'image/jpeg'); + assert.equal(await fs.readFile(path.join(cacheDir, `${key}.json`), 'utf8').then((raw: string) => JSON.parse(raw).url), url); +}); + +test('fetchBuffer rejects failed responses and oversized images with status metadata', async () => { + await assert.rejects( + fetchBuffer('https://example.com/missing.png', { + fetchImpl: (async () => ({ + ok: false, + status: 404, + headers: { get() { return ''; } }, + async arrayBuffer() { return exactArrayBuffer([]); } + } as unknown as Response)) as typeof fetch + }), + /Remote request failed with HTTP 404/ + ); + + await assert.rejects( + fetchBuffer('https://example.com/large.png', { + fetchImpl: (async () => ({ + ok: true, + status: 200, + headers: { get() { return 'image/png'; } }, + async arrayBuffer() { return new ArrayBuffer(MAX_REMOTE_IMAGE_BYTES + 1); } + } as unknown as Response)) as typeof fetch + }), + (error: Error & { statusCode?: number }) => { + assert.equal(error.statusCode, 413); + assert.match(error.message, /too large/); + return true; + } + ); +}); + +test('loadOrDownloadSticker encodes remote URL and stores a reusable cache file', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-sticker-cache-')); + const type = 'fun sticker/one'; + let requestedUrl = ''; + let calls = 0; + const fetchImpl = (async (url: RequestInfo | URL) => { + calls += 1; + requestedUrl = String(url); + return responseWith([9, 8, 7], 'image/webp'); + }) as typeof fetch; + + const first = await loadOrDownloadSticker(type, { cacheDir, fetchImpl }); + const second = await loadOrDownloadSticker(type, { + cacheDir, + fetchImpl: (async () => { + throw new Error('sticker cache hit should not fetch'); + }) as typeof fetch + }); + + assert.equal(requestedUrl, stickerUrlForType(type)); + assert.equal(calls, 1); + assert.equal(first.fromCache, false); + assert.equal(first.contentType, 'image/webp'); + assert.equal(second.fromCache, true); + assert.equal(second.contentType, 'image/png'); + assert.deepEqual([...second.buffer], [9, 8, 7]); +}); diff --git a/test/message-logger.test.ts b/test/message-logger.test.ts new file mode 100644 index 0000000..4e9d611 --- /dev/null +++ b/test/message-logger.test.ts @@ -0,0 +1,105 @@ +'use strict'; + +import type { EventEmitter as EventEmitterType } from 'node:events'; + +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { setTimeout: delay } = require('node:timers/promises'); + +const { createSteamMessageLogger } = require('../src/steam/message-logger'); +const { readHistory } = require('../src/storage/chat-log'); + +type SteamHistoryMessage = { + imageUrl?: string | null; + accountid?: string | number; + message?: string; + ordinal?: string | number | null; + timestamp?: number; +}; + +type TestSteamUser = EventEmitterType & { + getChatHistory?: (id: string, callback: (error: unknown, messages?: SteamHistoryMessage[]) => void) => void; +}; + +async function historyUntil(logPath: string, expectedLength: number) { + let last = []; + for (let attempt = 0; attempt < 50; attempt += 1) { + last = await readHistory({ logPath, limit: 50, logger: { warn() {} } }); + if (last.length >= expectedLength) return last; + await delay(10); + } + assert.fail(`Timed out waiting for ${expectedLength} chat log rows, got ${last.length}`); +} + +test('createSteamMessageLogger imports Steam history once before live friend messages', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-message-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + const steamID = { getSteamID64: () => '76561198000000000' }; + let historyCalls = 0; + steamUser.getChatHistory = (id: string, callback: (error: unknown, messages?: SteamHistoryMessage[]) => void) => { + historyCalls += 1; + assert.equal(id, '76561198000000000'); + callback(null, [ + { accountid: 'history-user', message: 'old text', ordinal: 1, timestamp: 1710000000 }, + { imageUrl: 'https://example.com/old.png', message: '', ordinal: 2, timestamp: 1710000001 } + ]); + }; + + const dispose = createSteamMessageLogger({ + steamUser, + getUserInfo: async () => ({ player_name: 'Alice' }), + getSelfName: async () => 'Me', + logPath, + logger: { info() {}, warn() {}, error() {} } + }); + + steamUser.emit('friendMessage', steamID, 'live one', undefined, undefined, 3); + let history = await historyUntil(logPath, 3); + assert.equal(historyCalls, 1); + assert.deepEqual(history.map((item: { message: string }) => item.message), ['old text', '', 'live one']); + assert.equal(history[1].type, 'image'); + assert.equal(history[2].name, 'Alice'); + + steamUser.emit('friendMessage', steamID, 'live two', undefined, undefined, 4); + history = await historyUntil(logPath, 4); + assert.equal(historyCalls, 1); + assert.equal(history[3].message, 'live two'); + + dispose(); + steamUser.emit('friendMessage', steamID, 'after dispose', undefined, undefined, 5); + await delay(20); + history = await readHistory({ logPath, limit: 50 }); + assert.equal(history.length, 4); +}); + +test('createSteamMessageLogger records one echoed message for duplicate echo events', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-message-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + const steamUser = new EventEmitter() as TestSteamUser; + + const dispose = createSteamMessageLogger({ + steamUser, + getSelfName: async () => 'Self', + logPath, + logger: { info() {}, warn() {}, error() {} } + }); + + steamUser.emit('friendMessageEcho', '42', 'echo text', 7); + steamUser.emit('friendMessageEcho', '42', 'echo text', 7); + + let history = await historyUntil(logPath, 1); + await delay(20); + history = await readHistory({ logPath, limit: 50 }); + assert.equal(history.length, 1); + assert.equal(history[0].echo, true); + assert.equal(history[0].id, '42'); + assert.equal(history[0].name, 'Self'); + assert.equal(history[0].message, 'echo text'); + + dispose(); +}); diff --git a/test/steam-lifecycle.test.js b/test/steam-lifecycle.test.js deleted file mode 100644 index 9b66b54..0000000 --- a/test/steam-lifecycle.test.js +++ /dev/null @@ -1,321 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { EventEmitter } = require('node:events'); - -const EResult = require('steam-user/enums/EResult'); -const { - buildLogOnOptions, - createSteamLifecycle, - isRecoverableSteamError, -} = require('../steam-lifecycle'); - -class FakeSteamUser extends EventEmitter { - constructor() { - super(); - this.options = { autoRelogin: true }; - this.logOnCalls = []; - this.webLogOnCalls = 0; - this.steamID = null; - } - - setOption(name, value) { - this.options[name] = value; - } - - logOn(options) { - this.logOnCalls.push(options); - } - - webLogOn() { - this.webLogOnCalls += 1; - } -} - -function createLogger() { - const entries = []; - return { - entries, - info(message, meta) { - entries.push({ level: 'info', message, meta }); - }, - warn(message, meta) { - entries.push({ level: 'warn', message, meta }); - }, - error(message, meta) { - entries.push({ level: 'error', message, meta }); - }, - }; -} - -function createFsModule(token = '') { - const writes = []; - return { - writes, - readFileSync(path, encoding) { - assert.equal(path, 'refresh.token'); - assert.equal(encoding, 'utf8'); - if (!token) { - const err = new Error('missing'); - err.code = 'ENOENT'; - throw err; - } - return token; - }, - writeFileSync(path, data) { - writes.push({ path, data }); - }, - }; -} - -function createTimerControls() { - const timers = []; - return { - timers, - setTimeoutFn(fn, delayMs) { - const timer = { fn, delayMs, cleared: false }; - timers.push(timer); - return timer; - }, - clearTimeoutFn(timer) { - timer.cleared = true; - }, - runNextTimer() { - const timer = timers.find((item) => !item.cleared && !item.ran); - assert.ok(timer, 'expected a pending timer'); - timer.ran = true; - timer.fn(); - return timer; - }, - }; -} - -function createLifecycle(overrides = {}) { - const steamUser = overrides.steamUser || new FakeSteamUser(); - const steamCommunity = overrides.steamCommunity || { - cookies: [], - checkerCalls: [], - setCookies(cookies) { - this.cookies.push(cookies); - }, - startConfirmationChecker(intervalMs, identitySecret) { - this.checkerCalls.push({ intervalMs, identitySecret }); - }, - }; - const logger = overrides.logger || createLogger(); - const fsModule = overrides.fsModule || createFsModule(overrides.refreshToken); - const timerControls = overrides.timerControls || createTimerControls(); - const config = { - accountName: 'account', - password: 'password', - logonID: 123, - steamID: 'self-id', - identitySecret: 'identity-secret', - ...overrides.config, - }; - - const lifecycle = createSteamLifecycle({ - steamUser, - steamCommunity, - logger, - config, - fsModule, - setTimeoutFn: timerControls.setTimeoutFn, - clearTimeoutFn: timerControls.clearTimeoutFn, - initialRetryDelayMs: 10, - maxRetryDelayMs: 40, - }); - - return { - lifecycle, - steamUser, - steamCommunity, - logger, - fsModule, - timerControls, - }; -} - -test('buildLogOnOptions uses refresh token when available', () => { - const fsModule = createFsModule(' token-value \n'); - - assert.deepEqual(buildLogOnOptions({ - accountName: 'account', - password: 'password', - logonID: 7, - steamID: 'self-id', - }, fsModule), { - mode: 'refreshToken', - options: { - logonID: 7, - refreshToken: 'token-value', - steamID: 'self-id', - }, - }); -}); - -test('buildLogOnOptions falls back to credentials without a refresh token', () => { - const fsModule = createFsModule(''); - - assert.deepEqual(buildLogOnOptions({ - accountName: 'account', - password: 'password', - logonID: 7, - steamID: 'self-id', - }, fsModule), { - mode: 'credentials', - options: { - accountName: 'account', - password: 'password', - logonID: 7, - steamID: 'self-id', - }, - }); -}); - -test('steam disconnected logs warning and lets steam-user autoRelogin handle reconnect', () => { - const { steamUser, logger } = createLifecycle(); - - assert.equal(steamUser.logOnCalls.length, 1); - - steamUser.emit('disconnected', EResult.NoConnection, 'connection closed'); - - assert.equal(steamUser.logOnCalls.length, 1); - assert.deepEqual(logger.entries.find((entry) => entry.message === 'steam disconnected'), { - level: 'warn', - message: 'steam disconnected', - meta: { - eresult: EResult.NoConnection, - eresultName: 'NoConnection', - message: 'connection closed', - autoRelogin: true, - }, - }); -}); - -test('recoverable steam errors schedule a single retry and re-log on', () => { - const { steamUser, logger, timerControls } = createLifecycle({ - refreshToken: 'refresh-token', - }); - - const err = new Error('No Steam servers available'); - err.eresult = EResult.NoConnection; - - steamUser.emit('error', err); - steamUser.emit('error', err); - - assert.equal(timerControls.timers.length, 1); - assert.equal(timerControls.timers[0].delayMs, 10); - assert.equal(steamUser.logOnCalls.length, 1); - assert.equal(logger.entries.filter((entry) => entry.message === 'steam reconnect scheduled').length, 1); - assert.equal(logger.entries.filter((entry) => entry.message === 'steam reconnect already scheduled').length, 1); - - timerControls.runNextTimer(); - - assert.equal(steamUser.logOnCalls.length, 2); - assert.deepEqual(steamUser.logOnCalls[1], { - logonID: 123, - refreshToken: 'refresh-token', - steamID: 'self-id', - }); -}); - -test('non-recoverable initial steam errors reject login and do not retry', async () => { - const { lifecycle, steamUser, timerControls } = createLifecycle(); - const err = new Error('InvalidPassword'); - err.eresult = EResult.InvalidPassword; - - steamUser.emit('error', err); - - await assert.rejects(lifecycle.steamLoginPromise, err); - assert.equal(timerControls.timers.length, 0); -}); - -test('loggedOn clears pending retry, resets backoff, resolves login, and starts web login', async () => { - const { lifecycle, steamUser, timerControls } = createLifecycle({ - refreshToken: 'refresh-token', - }); - const err = new Error('timeout'); - err.eresult = EResult.Timeout; - - steamUser.emit('error', err); - assert.equal(timerControls.timers.length, 1); - - steamUser.steamID = 'self-id'; - steamUser.emit('loggedOn'); - await lifecycle.steamLoginPromise; - - assert.equal(timerControls.timers[0].cleared, true); - assert.equal(steamUser.webLogOnCalls, 1); - - steamUser.emit('error', err); - assert.equal(timerControls.timers.length, 2); - assert.equal(timerControls.timers[1].delayMs, 10); -}); - -test('webSession refreshes cookies repeatedly but starts confirmation checker once', async () => { - const { lifecycle, steamUser, steamCommunity } = createLifecycle(); - - steamUser.emit('webSession', 'session-1', ['cookie-1']); - steamUser.emit('webSession', 'session-2', ['cookie-2']); - - await lifecycle.steamWebLoginPromise; - assert.deepEqual(steamCommunity.cookies, [['cookie-1'], ['cookie-2']]); - assert.deepEqual(steamCommunity.checkerCalls, [{ - intervalMs: 10000, - identitySecret: 'identity-secret', - }]); -}); - -test('refreshToken writes token and logs write failures without creating connection log files', () => { - const fsModule = createFsModule(); - const logger = createLogger(); - const { steamUser } = createLifecycle({ fsModule, logger }); - - steamUser.emit('refreshToken', 'new-token'); - - assert.deepEqual(fsModule.writes, [{ - path: 'refresh.token', - data: 'new-token', - }]); - - const failingFsModule = { - readFileSync() { - const err = new Error('missing'); - err.code = 'ENOENT'; - throw err; - }, - writeFileSync() { - throw new Error('disk full'); - }, - }; - const failingLogger = createLogger(); - const { steamUser: failingSteamUser } = createLifecycle({ - fsModule: failingFsModule, - logger: failingLogger, - }); - - failingSteamUser.emit('refreshToken', 'new-token'); - - assert.equal( - failingLogger.entries.some((entry) => entry.message === 'failed to write steam refresh token'), - true - ); -}); - -test('isRecoverableSteamError classifies expected recoverable and fatal errors', () => { - const networkErr = new Error('socket disconnected before secure TLS connection was established'); - networkErr.eresult = EResult.Fail; - assert.equal(isRecoverableSteamError(networkErr), true); - - const authErr = new Error('InvalidPassword'); - authErr.eresult = EResult.InvalidPassword; - assert.equal(isRecoverableSteamError(authErr), false); - - const replacedSessionErr = new Error('LoggedInElsewhere'); - replacedSessionErr.eresult = EResult.LoggedInElsewhere; - assert.equal(isRecoverableSteamError(replacedSessionErr), false); - - const throttledErr = new Error('AccountLoginDeniedThrottle'); - throttledErr.eresult = EResult.AccountLoginDeniedThrottle; - assert.equal(isRecoverableSteamError(throttledErr), false); -}); diff --git a/test/steam-lifecycle.test.ts b/test/steam-lifecycle.test.ts new file mode 100644 index 0000000..fd4b6c8 --- /dev/null +++ b/test/steam-lifecycle.test.ts @@ -0,0 +1,110 @@ +'use strict'; + +import type { EventEmitter as EventEmitterType } from 'node:events'; + +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + buildLogOnOptions, + createSteamLifecycle, + isRecoverableLoginError, + isUnrecoverableLoginError +} = require('../src/steam/lifecycle'); + +type LogOnOptions = { + accountName?: string; + password?: string; + refreshToken?: string; + logonID?: number; + steamID?: string; +}; + +type ScheduledTimer = { + fn: () => void; + delay: number; +}; + +type LifecycleTestUser = EventEmitterType & { + logOn: (options: LogOnOptions) => void; + webLogOn: () => void; + logOff: () => void; +}; + +test('buildLogOnOptions prefers refresh.token over account password', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'steam-chat-life-')); + const tokenPath = path.join(dir, 'refresh.token'); + fs.writeFileSync(tokenPath, 'refresh-value\n'); + + assert.deepEqual(buildLogOnOptions({ + accountName: 'account', + password: 'secret', + logonID: 42, + steamID: '7656' + }, tokenPath), { + refreshToken: 'refresh-value', + logonID: 42, + steamID: '7656' + }); +}); + +test('login error classification separates recoverable and unrecoverable errors', () => { + assert.equal(isRecoverableLoginError(new Error('ECONNRESET socket closed')), true); + assert.equal(isRecoverableLoginError(new Error('InvalidPassword')), false); + assert.equal(isUnrecoverableLoginError(new Error('SteamGuard required')), true); +}); + +test('createSteamLifecycle logs on, resolves web session, saves refresh token, and avoids duplicate retry timers', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'steam-chat-life-')); + const tokenPath = path.join(dir, 'refresh.token'); + const user = new EventEmitter() as LifecycleTestUser; + const community = { cookies: null as string[] | null, setCookies(cookies: string[]) { this.cookies = cookies; } }; + const logOnCalls: LogOnOptions[] = []; + let scheduled: ScheduledTimer | null = null; + let clearCount = 0; + user.logOn = (options: LogOnOptions) => logOnCalls.push(options); + user.webLogOn = () => user.emit('webSession', 'session-id', ['a=b']); + user.logOff = () => {}; + + const lifecycle = createSteamLifecycle({ + steamUser: user, + steamCommunity: community, + config: { accountName: 'name', password: 'pass' }, + refreshTokenPath: tokenPath, + logger: { info() {}, warn() {}, error() {} }, + timers: { + setTimeout(fn: () => void, delay: number) { + scheduled = { fn, delay }; + return 7; + }, + clearTimeout() { + clearCount += 1; + } + } + }); + + lifecycle.start(); + assert.equal(logOnCalls.length, 1); + assert.equal(logOnCalls[0].accountName, 'name'); + + user.emit('error', new Error('timeout')); + user.emit('error', new Error('timeout again')); + assert.ok(scheduled); + assert.equal(scheduled.delay, 5000); + scheduled.fn(); + assert.equal(logOnCalls.length, 2); + + user.emit('loggedOn'); + await lifecycle.waitForLogin(); + const webSession = await lifecycle.waitForWebSession(); + assert.deepEqual(webSession, { sessionID: 'session-id', cookies: ['a=b'] }); + assert.deepEqual(community.cookies, ['a=b']); + assert.equal(clearCount, 0); + + user.emit('refreshToken', 'next-token'); + assert.equal(fs.readFileSync(tokenPath, 'utf8'), 'next-token\n'); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..63eaa0a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "rootDir": ".", + "outDir": "dist", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noImplicitAny": true, + "strict": false + }, + "include": ["src/**/*.ts", "test/**/*.ts", "web/**/*.ts"] +} diff --git a/web/app.ts b/web/app.ts new file mode 100644 index 0000000..389f1b8 --- /dev/null +++ b/web/app.ts @@ -0,0 +1,1174 @@ +type Tone = 'muted' | 'ok' | 'warn' | 'error'; +type PickerTab = 'emoticons' | 'stickers'; + +type ListEntry = Record & { + id: string; + name?: string; + avatar?: string; + preview?: string; + updatedAt?: string; + gameName?: string; + game?: string; + online?: boolean; + clanId?: string; + clanid?: string; + messageCount?: number; +}; + +type MessageItem = ListEntry & { + type?: string; + echo?: boolean; + date?: string; + sentAt?: string; + message?: string; + imageUrl?: string | null; + lastType?: string; + lastEcho?: boolean; +}; + +type InventoryItem = Record & { + name?: string; + use_count?: number | string; +}; + +type WsPayload = Record & { + requestId?: string; + type?: string; + error?: string; + id?: string; + msg?: string; + message?: string; + wsPath?: string; + items?: MessageItem[]; + history?: MessageItem[]; + conversations?: ListEntry[]; + friends?: ListEntry[]; + groups?: ListEntry[]; + emoticons?: InventoryItem[]; + stickers?: InventoryItem[]; +}; + +type PendingRequest = { + resolve: (value: WsPayload) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +type MessageRow = HTMLElement & { + _item?: MessageItem; +}; + +type TextPart = { type: 'text'; text: string }; +type LinkPart = { type: 'link'; href: string; label: string }; +type EmoticonPart = { type: 'emoticon'; name: string }; +type StickerPart = { type: 'sticker'; stickerType: string }; +type ImagePart = { type: 'image'; url: string }; +type OgPart = { type: 'og'; url: string; image: string; title: string; fallback: string }; +type RichPart = TextPart | LinkPart | EmoticonPart | StickerPart | ImagePart | OgPart; + +type Els = { + sidebar: HTMLElement; + backdrop: HTMLElement; + openSidebar: HTMLButtonElement; + closeSidebar: HTMLButtonElement; + connectionText: HTMLElement; + feedback: HTMLElement; + openForm: HTMLFormElement; + targetInput: HTMLInputElement; + historyLimit: HTMLInputElement; + refreshAll: HTMLButtonElement; + conversationList: HTMLElement; + friendList: HTMLElement; + groupList: HTMLElement; + chatTitle: HTMLElement; + chatSubtitle: HTMLElement; + messages: HTMLElement; + uploadTray: HTMLElement; + picker: HTMLElement; + pickerGrid: HTMLElement; + pickerSearch: HTMLInputElement; + pickerToggle: HTMLButtonElement; + autocomplete: HTMLElement; + fileButton: HTMLButtonElement; + fileInput: HTMLInputElement; + messageInput: HTMLTextAreaElement; + sendButton: HTMLButtonElement; + imageUrlForm: HTMLFormElement; + imageUrlInput: HTMLInputElement; + dropOverlay: HTMLElement; + lightbox: HTMLElement; + lightboxImage: HTMLImageElement; + lightboxClose: HTMLButtonElement; + zoomIn: HTMLButtonElement; + zoomOut: HTMLButtonElement; + zoomReset: HTMLButtonElement; +}; + +type AppState = { + activeId: string; + activeName: string; + historyLimit: number; + conversations: ListEntry[]; + friends: ListEntry[]; + groups: ListEntry[]; + emoticons: InventoryItem[]; + stickers: InventoryItem[]; + knownEmoticons: Set; + ws: WebSocket | null; + pending: Map; + reconnectTimer: ReturnType | null; + pickerTab: PickerTab; + autocompleteIndex: number; + unread: number; + objectUrls: Set; + imageRequests: Set; +}; + +const $ = (selector: string, root: ParentNode = document): T => { + const node = root.querySelector(selector); + if (!node) throw new Error(`Missing element: ${selector}`); + return node; +}; +const $$ = (selector: string, root: ParentNode = document): T[] => [...root.querySelectorAll(selector)]; + +const els: Els = { + sidebar: $('#sidebar'), + backdrop: $('#backdrop'), + openSidebar: $('#openSidebar'), + closeSidebar: $('#closeSidebar'), + connectionText: $('#connectionText'), + feedback: $('#feedback'), + openForm: $('#openForm'), + targetInput: $('#targetInput'), + historyLimit: $('#historyLimit'), + refreshAll: $('#refreshAll'), + conversationList: $('#conversationList'), + friendList: $('#friendList'), + groupList: $('#groupList'), + chatTitle: $('#chatTitle'), + chatSubtitle: $('#chatSubtitle'), + messages: $('#messages'), + uploadTray: $('#uploadTray'), + picker: $('#picker'), + pickerGrid: $('#pickerGrid'), + pickerSearch: $('#pickerSearch'), + pickerToggle: $('#pickerToggle'), + autocomplete: $('#autocomplete'), + fileButton: $('#fileButton'), + fileInput: $('#fileInput'), + messageInput: $('#messageInput'), + sendButton: $('#sendButton'), + imageUrlForm: $('#imageUrlForm'), + imageUrlInput: $('#imageUrlInput'), + dropOverlay: $('#dropOverlay'), + lightbox: $('#lightbox'), + lightboxImage: $('#lightboxImage'), + lightboxClose: $('#lightboxClose'), + zoomIn: $('#zoomIn'), + zoomOut: $('#zoomOut'), + zoomReset: $('#zoomReset') +}; + +const state: AppState = { + activeId: localStorage.getItem('steam-chat.target') || '', + activeName: '', + historyLimit: clampLimit(localStorage.getItem('steam-chat.history-limit') || 100), + conversations: [], + friends: [], + groups: [], + emoticons: [], + stickers: [], + knownEmoticons: new Set(), + ws: null, + pending: new Map(), + reconnectTimer: null, + pickerTab: 'emoticons', + autocompleteIndex: 0, + unread: 0, + objectUrls: new Set(), + imageRequests: new Set() +}; + +const baseTitle = document.title; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error || ''); +} + +function asListEntries(value: unknown): ListEntry[] { + return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; +} + +function asMessageItems(value: unknown): MessageItem[] { + return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; +} + +function messageItemFromPayload(payload: WsPayload): MessageItem { + return { + ...payload, + id: String(payload.id || ''), + name: typeof payload.name === 'string' ? payload.name : undefined, + type: typeof payload.type === 'string' ? payload.type : undefined, + echo: Boolean(payload.echo), + date: typeof payload.date === 'string' ? payload.date : undefined, + sentAt: typeof payload.sentAt === 'string' ? payload.sentAt : undefined, + message: typeof payload.message === 'string' ? payload.message : undefined, + imageUrl: typeof payload.imageUrl === 'string' ? payload.imageUrl : null + }; +} + +function asInventoryItems(value: unknown): InventoryItem[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function clampLimit(value: unknown): number { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return 100; + return Math.min(parsed, 500); +} + +function setFeedback(text: string, tone: Tone = 'muted') { + els.feedback.textContent = text; + els.feedback.dataset.tone = tone; + els.feedback.style.color = { + muted: 'var(--muted)', + ok: 'var(--green)', + warn: 'var(--orange)', + error: 'var(--danger)' + }[tone] || 'var(--muted)'; +} + +function setConnection(text: string, online = false) { + els.connectionText.textContent = text; + els.connectionText.style.color = online ? 'var(--green)' : 'var(--muted)'; +} + +async function apiGet(path: string): Promise { + const response = await fetch(path, { headers: { Accept: 'application/json' } }); + const payload: unknown = await response.json().catch((): null => null); + if (!response.ok) throw new Error(isRecord(payload) && typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`); + return payload; +} + +async function apiPost(path: string, body: Record): Promise { + const response = await fetch(path, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const payload: unknown = await response.json().catch((): null => null); + if (!response.ok) throw new Error(isRecord(payload) && typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`); + return payload; +} + +function requestId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function wsIsOpen(): boolean { + return Boolean(state.ws && state.ws.readyState === WebSocket.OPEN); +} + +function wsRequest(payload: WsPayload, timeoutMs = 20000): Promise { + if (!wsIsOpen()) return Promise.reject(new Error('WebSocket 未连接')); + const id = payload.requestId || requestId(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + state.pending.delete(id); + reject(new Error('请求超时')); + }, timeoutMs); + state.pending.set(id, { resolve, reject, timer }); + state.ws?.send(JSON.stringify({ ...payload, requestId: id })); + }); +} + +function settlePending(payload: WsPayload): boolean { + if (!payload.requestId || !state.pending.has(payload.requestId)) return false; + const pending = state.pending.get(payload.requestId); + if (!pending) return false; + clearTimeout(pending.timer); + state.pending.delete(payload.requestId); + if (payload.type === 'error') pending.reject(new Error(payload.error || '请求失败')); + else pending.resolve(payload); + return true; +} + +async function wsOrHttp(wsPayload: WsPayload, httpPath: string, fallback: unknown): Promise { + if (wsIsOpen()) return wsRequest(wsPayload); + if (!httpPath) return fallback; + return apiGet(httpPath); +} + +function connectWebSocket(wsPath: string) { + if (state.reconnectTimer) clearTimeout(state.reconnectTimer); + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${location.host}${wsPath || '/ws'}`); + state.ws = ws; + ws.addEventListener('open', () => setConnection('已连接', true)); + ws.addEventListener('message', (event) => { + let payload: WsPayload; + try { + const parsed: unknown = JSON.parse(String(event.data)); + payload = isRecord(parsed) ? parsed : {}; + } catch { + return; + } + if (settlePending(payload)) return; + handleRealtime(payload); + }); + ws.addEventListener('close', () => { + setConnection('正在重连', false); + for (const pending of state.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error('WebSocket 已断开')); + } + state.pending.clear(); + state.reconnectTimer = setTimeout(() => connectWebSocket(wsPath), 1500); + markUploadsFailed('连接断开'); + }); + ws.addEventListener('error', () => setConnection('连接异常', false)); +} + +function handleRealtime(payload: WsPayload) { + if (payload.type === 'ready') { + refreshInitialData(); + return; + } + if (payload.type === 'message' || payload.type === 'image') { + const item = messageItemFromPayload(payload); + mergeConversation(item); + if (item.id === state.activeId) appendMessage(item); + notify(item); + renderLists(); + return; + } + if (payload.type === 'error') setFeedback(payload.error || '请求失败', 'error'); +} + +function create(tag: K, className = '', text = ''): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== '') node.textContent = text; + return node; +} + +function formatShortTime(value: unknown): string { + const date = parseDate(value); + if (!date) return ''; + if (date.toDateString() === new Date().toDateString()) { + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }); +} + +function parseDate(value: unknown): Date | null { + if (!value) return null; + const parsed = new Date(String(value).replace(' ', 'T')); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function avatar(item: ListEntry) { + const node = create('span', 'avatar'); + if (item.avatar) { + const image = document.createElement('img'); + image.src = item.avatar; + image.alt = ''; + node.append(image); + } else { + node.textContent = String(item.name || item.id || '?').slice(0, 1).toUpperCase(); + } + return node; +} + +type ListItemOptions = { + preview?: string; + meta?: string; + topRight?: string; + online?: boolean; + avatar?: string; +}; + +function listItem(item: ListEntry, { preview = '', meta = '', topRight = '', online = false }: ListItemOptions = {}) { + const button = create('button', `list-item${item.id === state.activeId ? ' is-active' : ''}`); + button.type = 'button'; + button.dataset.id = item.id; + button.append(avatar(item)); + + const body = create('span', 'item-body'); + const top = create('span', 'item-top'); + const presence = create('span', `presence${online ? ' is-online' : ''}`); + top.append(presence, create('span', 'item-name', item.name || item.id)); + if (topRight) top.append(create('span', 'item-time', topRight)); + body.append(top); + if (preview) body.append(create('span', 'item-preview', preview)); + if (meta) body.append(create('span', 'item-meta', meta)); + button.append(body); + button.addEventListener('click', () => openConversation(item.id, item.name || item.id)); + return button; +} + +function renderList(container: HTMLElement, items: T[], mapItem: (item: T) => HTMLElement) { + if (!items.length) { + container.replaceChildren(create('div', 'empty', '暂无数据')); + return; + } + container.replaceChildren(...items.map(mapItem)); +} + +function renderLists() { + renderList(els.conversationList, state.conversations, (item) => listItem(item, { + preview: item.preview, + meta: item.id, + topRight: formatShortTime(item.updatedAt) + })); + renderList(els.friendList, state.friends, (item) => listItem(item, { + avatar: item.avatar, + preview: item.gameName || item.game || item.id, + meta: item.online ? '在线' : '离线', + online: item.online + })); + renderList(els.groupList, state.groups, (item) => listItem(item, { + preview: item.clanId || item.clanid || item.id + })); +} + +function setActiveConversation(id: unknown, name = '') { + state.activeId = String(id || '').trim(); + state.activeName = name || state.activeId; + localStorage.setItem('steam-chat.target', state.activeId); + els.targetInput.value = state.activeId; + els.chatTitle.textContent = state.activeName || '未选择会话'; + els.chatSubtitle.textContent = state.activeId || '选择好友、群组或输入 SteamID64'; + renderLists(); +} + +async function loadHistory() { + if (!state.activeId) { + renderHistory([]); + return; + } + const result = await wsOrHttp( + { type: 'get_history', id: state.activeId, limit: state.historyLimit }, + `/history?id=${encodeURIComponent(state.activeId)}&limit=${state.historyLimit}`, + [] + ); + const items = isRecord(result) ? result.items || result.history : result; + renderHistory(asMessageItems(items)); + setFeedback('历史已更新', 'ok'); +} + +async function loadConversations() { + const result = await wsOrHttp( + { type: 'get_conversations', limit: state.historyLimit }, + `/conversations?limit=${state.historyLimit}`, + { conversations: [] } + ); + const items = isRecord(result) ? result.conversations : result; + state.conversations = asListEntries(items); + renderLists(); + return state.conversations; +} + +async function loadFriends() { + const result = await wsOrHttp({ type: 'get_friends' }, '/api/friends', { friends: [] }); + const items = isRecord(result) ? result.friends : result; + state.friends = asListEntries(items); + renderLists(); +} + +async function loadGroups() { + const result = await wsOrHttp({ type: 'get_groups' }, '/api/groups', { groups: [] }); + const items = isRecord(result) ? result.groups : result; + state.groups = asListEntries(items); + renderLists(); +} + +async function loadInventory() { + const result = await wsOrHttp({ type: 'get_emoticons' }, '/api/emoticons', { emoticons: [], stickers: [] }); + state.emoticons = isRecord(result) ? asInventoryItems(result.emoticons) : []; + state.stickers = isRecord(result) ? asInventoryItems(result.stickers) : []; +} + +async function openConversation(id: unknown, name = '') { + const target = String(id || '').trim(); + if (!target) { + setFeedback('请输入 SteamID64', 'warn'); + return; + } + setActiveConversation(target, name || target); + closeSidebar(); + try { + await loadHistory(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function refreshInitialData() { + try { + const [conversations] = await Promise.all([ + loadConversations(), + loadFriends(), + loadGroups(), + loadInventory() + ]); + if (state.activeId) await openConversation(state.activeId, state.activeName || state.activeId); + else if (conversations[0]) await openConversation(conversations[0].id, conversations[0].name); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +function mergeConversation(item: MessageItem) { + const preview = item.imageUrl || item.type === 'image' ? '[图片]' : item.message || '[消息]'; + const previous = state.conversations.find((conversation) => conversation.id === item.id); + const next = { + id: item.id, + name: item.echo ? (previous?.name || state.activeName || item.id) : (item.name || item.id), + updatedAt: item.sentAt || item.date, + preview, + lastType: item.type, + lastEcho: item.echo, + messageCount: (previous?.messageCount || 0) + 1 + }; + state.conversations = [next, ...state.conversations.filter((conversation) => conversation.id !== item.id)]; +} + +function releaseImages() { + for (const xhr of state.imageRequests) xhr.abort(); + state.imageRequests.clear(); + for (const url of state.objectUrls) URL.revokeObjectURL(url); + state.objectUrls.clear(); +} + +function renderHistory(items: MessageItem[]) { + releaseImages(); + els.messages.replaceChildren(); + if (!items.length) { + els.messages.append(create('div', 'empty', '暂无消息')); + return; + } + let previous: MessageItem | null = null; + for (const item of items) { + appendSeparator(previous, item); + appendMessage(item, false); + previous = item; + } + scrollMessages(); +} + +function itemDate(item: MessageItem): Date | null { + return parseDate(item.sentAt || item.date); +} + +function appendSeparator(previous: MessageItem | null, current: MessageItem) { + const now = itemDate(current); + if (!now) return; + const prev = previous ? itemDate(previous) : null; + let label = ''; + if (!prev || prev.toDateString() !== now.toDateString()) { + label = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' }); + } else if (now.getTime() - prev.getTime() > 10 * 60 * 1000) { + label = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + } + if (label) els.messages.append(create('div', 'separator', label)); +} + +function appendMessage(item: MessageItem, withSeparator = true) { + const empty = $('.empty', els.messages); + if (empty) empty.remove(); + if (withSeparator) { + const rows = $$('.msg-row', els.messages); + const previous = rows.length ? rows[rows.length - 1]._item : null; + appendSeparator(previous, item); + } + rememberEmoticons(item.message); + const row = create('article', `msg-row${item.echo ? ' is-self' : ''}`) as MessageRow; + row._item = item; + const bubble = create('div', 'bubble'); + const meta = create('div', 'meta'); + const date = itemDate(item); + meta.append( + create('span', '', item.name || (item.echo ? '我' : item.id || 'Unknown')), + create('span', '', date ? date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : item.date || '') + ); + const content = create('div', 'content'); + const parts: RichPart[] = item.imageUrl ? [{ type: 'image', url: item.imageUrl }] : parseRichParts(item.message || ''); + const meaningful = parts.filter((part) => part.type !== 'text' || part.text.trim()); + if (meaningful.length === 1 && ['image', 'sticker', 'og'].includes(meaningful[0].type)) bubble.classList.add('is-visual'); + for (const part of parts) appendRichPart(content, part); + bubble.append(meta, content); + row.append(bubble); + els.messages.append(row); + scrollMessages(); +} + +function scrollMessages() { + requestAnimationFrame(() => { + els.messages.scrollTop = els.messages.scrollHeight; + }); +} + +function rememberEmoticons(text: unknown) { + for (const match of String(text || '').matchAll(/:([A-Za-z0-9_+\-.]+):/g)) { + state.knownEmoticons.add(match[1]); + } +} + +const tokenPattern = /\[sticker\s+type=["']?([^"'\]\s]+)["']?[^\]]*\]\s*\[\/sticker\]|\[img\]([^\[]+)\[\/img\]|\[img\s+src=["']?([^"'\]\s]+)["']?[^\]]*\][\s\S]*?\[\/img\]|]+src=["']([^"']+)["'][^>]*>|\[og\b([^\]]*)\]([\s\S]*?)\[\/og\]|\[url=([^\]]+)\]([^\[]+)\[\/url\]|\[url\]([^\[]+)\[\/url\]|\[emoticon\s+name=["']?([^"'\]\s]+)["']?\]\s*\[\/emoticon\]|\[emoticon\]([^\[]+)\[\/emoticon\]|:([A-Za-z0-9_+\-.]+):|(https?:\/\/[^\s<>"']+)/gi; + +function parseAttrs(text: unknown): Record { + const attrs: Record = {}; + for (const match of String(text || '').matchAll(/([A-Za-z0-9_-]+)=["']([^"']*)["']/g)) { + attrs[match[1]] = match[2]; + } + return attrs; +} + +function parseRichParts(message: unknown): RichPart[] { + const text = String(message || ''); + const parts: RichPart[] = []; + let lastIndex = 0; + tokenPattern.lastIndex = 0; + for (const match of text.matchAll(tokenPattern)) { + if (match.index > lastIndex) parts.push({ type: 'text', text: text.slice(lastIndex, match.index) }); + if (match[1]) parts.push({ type: 'sticker', stickerType: match[1] }); + else if (match[2] || match[3] || match[4]) parts.push({ type: 'image', url: (match[2] || match[3] || match[4]).trim() }); + else if (match[5] !== undefined) { + const attrs = parseAttrs(match[5]); + parts.push({ type: 'og', url: attrs.url || '', image: attrs.img || attrs.image || '', title: attrs.title || match[6] || '', fallback: match[6] || attrs.url || '' }); + } else if (match[7]) parts.push({ type: 'link', href: match[7], label: match[8] }); + else if (match[9]) parts.push({ type: 'link', href: match[9], label: match[9] }); + else if (match[10] || match[11] || match[12]) parts.push({ type: 'emoticon', name: match[10] || match[11] || match[12] }); + else if (match[13]) { + if (/^https?:\/\/\S+\.(png|jpe?g|gif|webp)(\?\S*)?$/i.test(match[13])) { + parts.push({ type: 'image', url: match[13] }); + } else { + parts.push({ type: 'link', href: match[13], label: match[13] }); + } + } + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) parts.push({ type: 'text', text: text.slice(lastIndex) }); + return parts.length ? parts : [{ type: 'text', text }]; +} + +function appendRichPart(container: HTMLElement, part: RichPart) { + if (part.type === 'text') { + container.append(document.createTextNode(part.text)); + } else if (part.type === 'link') { + const link = create('a', '', part.label || part.href); + link.href = part.href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + container.append(link); + } else if (part.type === 'emoticon') { + const image = document.createElement('img'); + image.className = 'emoticon'; + image.src = `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(part.name)}`; + image.alt = `:${part.name}:`; + image.title = `:${part.name}:`; + container.append(image); + } else if (part.type === 'sticker') { + const image = document.createElement('img'); + image.className = 'sticker'; + image.src = `/proxy/sticker/${encodeURIComponent(part.stickerType)}`; + image.alt = part.stickerType; + image.addEventListener('click', () => openLightbox(image.src)); + container.append(image); + } else if (part.type === 'image') { + container.append(managedImage(part.url)); + } else if (part.type === 'og') { + const card = create('a', 'og-card'); + card.href = part.url || '#'; + card.target = '_blank'; + card.rel = 'noopener noreferrer'; + if (part.image) card.append(managedImage(part.image)); + const body = create('div'); + body.append(create('strong', '', part.title || part.fallback || part.url || 'OpenGraph'), create('span', '', part.url || part.fallback || '')); + card.append(body); + container.append(card); + } +} + +function managedImage(sourceUrl: string) { + const shell = create('div', 'image-shell', '加载中'); + const xhr = new XMLHttpRequest(); + state.imageRequests.add(xhr); + xhr.open('GET', `/proxy/image?url=${encodeURIComponent(sourceUrl)}`); + xhr.responseType = 'blob'; + xhr.onprogress = (event) => { + if (event.lengthComputable) shell.textContent = `${Math.round((event.loaded / event.total) * 100)}%`; + }; + xhr.onload = () => { + state.imageRequests.delete(xhr); + if (xhr.status < 200 || xhr.status >= 300) { + shell.textContent = '加载失败'; + return; + } + const objectUrl = URL.createObjectURL(xhr.response); + state.objectUrls.add(objectUrl); + const image = document.createElement('img'); + image.className = 'message-image'; + image.src = objectUrl; + image.alt = '图片'; + image.addEventListener('click', () => openLightbox(objectUrl)); + shell.replaceWith(image); + }; + xhr.onerror = () => { + state.imageRequests.delete(xhr); + shell.textContent = '加载失败'; + }; + xhr.onabort = () => state.imageRequests.delete(xhr); + xhr.send(); + return shell; +} + +let lightboxScale = 1; +let lightboxX = 0; +let lightboxY = 0; +let lightboxDragging = false; +let lightboxStartX = 0; +let lightboxStartY = 0; +let lastFocus: HTMLElement | null = null; + +function applyLightboxTransform() { + els.lightboxImage.style.transform = `translate(${lightboxX}px, ${lightboxY}px) scale(${lightboxScale})`; +} + +function openLightbox(src: string) { + lastFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + lightboxScale = 1; + lightboxX = 0; + lightboxY = 0; + els.lightboxImage.src = src; + els.lightbox.hidden = false; + applyLightboxTransform(); + els.lightboxClose.focus(); +} + +function closeLightbox() { + els.lightbox.hidden = true; + els.lightboxImage.src = ''; + lastFocus?.focus?.(); +} + +function zoomLightbox(delta: number) { + lightboxScale = Math.min(6, Math.max(0.2, lightboxScale + delta)); + applyLightboxTransform(); +} + +function autoSizeInput() { + els.messageInput.style.height = 'auto'; + els.messageInput.style.height = `${Math.min(els.messageInput.scrollHeight, window.innerWidth <= 900 ? 108 : 160)}px`; +} + +function activeIdOrWarn() { + if (state.activeId) return state.activeId; + setFeedback('请选择会话', 'warn'); + return ''; +} + +async function sendText() { + const id = activeIdOrWarn(); + const msg = els.messageInput.value.trim(); + if (!id || !msg) { + if (!msg) setFeedback('请输入消息', 'warn'); + return; + } + els.sendButton.disabled = true; + try { + if (wsIsOpen()) await wsRequest({ type: 'send_message', id, msg }); + else await apiPost('/message', { id, msg }); + els.messageInput.value = ''; + autoSizeInput(); + setFeedback('已发送', 'ok'); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } finally { + els.sendButton.disabled = false; + } +} + +function addUpload(label: string): HTMLElement { + const row = create('div', 'upload-item'); + row.append(create('strong', '', label), create('span', '', '等待')); + els.uploadTray.append(row); + return row; +} + +function setUpload(row: HTMLElement, text: string) { + const status = row.querySelector('span'); + if (status) status.textContent = text; +} + +function markUploadsFailed(reason: string) { + for (const row of $$('.upload-item', els.uploadTray)) { + if (!/成功|失败/.test(row.textContent)) setUpload(row, `失败:${reason}`); + } +} + +function fileToDataUrl(file: File, progress: (percent: number) => void): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error('读取图片失败')); + reader.onprogress = (event) => { + if (event.lengthComputable) progress(Math.round((event.loaded / event.total) * 100)); + }; + reader.onload = () => { + if (typeof reader.result === 'string') resolve(reader.result); + else reject(new Error('读取图片失败')); + }; + reader.readAsDataURL(file); + }); +} + +async function sendImagePayload(label: string, payload: Record) { + const id = activeIdOrWarn(); + if (!id) return; + const row = addUpload(label); + setUpload(row, '发送中'); + try { + if (wsIsOpen()) await wsRequest({ type: 'send_image', id, ...payload }); + else await apiPost('/image', { id, ...payload }); + setUpload(row, '成功'); + setFeedback('图片已发送', 'ok'); + setTimeout(() => row.remove(), 1800); + } catch (error) { + setUpload(row, `失败:${errorMessage(error)}`); + setFeedback(errorMessage(error), 'error'); + } +} + +async function sendFiles(files: FileList | File[] | null) { + if (!files) return; + for (const file of [...files].filter((item) => item.type.startsWith('image/'))) { + const row = addUpload(file.name); + try { + const dataUrl = await fileToDataUrl(file, (progress) => setUpload(row, `${progress}%`)); + row.remove(); + await sendImagePayload(file.name, { img: dataUrl }); + } catch (error) { + setUpload(row, `失败:${errorMessage(error)}`); + } + } +} + +function sortInventory(items: InventoryItem[]): InventoryItem[] { + return [...items].sort((left, right) => { + const usage = Number(right.use_count || 0) - Number(left.use_count || 0); + return usage || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); + }); +} + +function inventoryNames(): string[] { + return [...new Set([...state.emoticons.map((item) => item.name).filter((name): name is string => Boolean(name)), ...state.knownEmoticons])].sort(); +} + +function renderPicker() { + $$('.picker-head button').forEach((button) => button.classList.toggle('is-active', button.dataset.picker === state.pickerTab)); + const query = els.pickerSearch.value.trim().toLowerCase(); + const source = state.pickerTab === 'emoticons' ? state.emoticons : state.stickers; + const items = sortInventory(source).filter((item) => String(item.name || '').toLowerCase().includes(query)).slice(0, 240); + if (!items.length) { + els.pickerGrid.replaceChildren(create('div', 'empty', '暂无内容')); + return; + } + els.pickerGrid.replaceChildren(...items.map((item) => { + const name = item.name || ''; + const button = create('button'); + button.type = 'button'; + const image = document.createElement('img'); + image.src = state.pickerTab === 'emoticons' + ? `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(name)}` + : `/proxy/sticker/${encodeURIComponent(name)}`; + image.alt = ''; + button.append(image, create('span', '', name)); + button.addEventListener('click', () => { + if (state.pickerTab === 'emoticons') insertAtCursor(`:${name}:`); + else { + els.messageInput.value = `[sticker type="${name}" limit="0"][/sticker]`; + sendText(); + } + els.picker.hidden = true; + }); + return button; + })); +} + +function insertAtCursor(value: string) { + const start = els.messageInput.selectionStart; + const end = els.messageInput.selectionEnd; + els.messageInput.value = `${els.messageInput.value.slice(0, start)}${value}${els.messageInput.value.slice(end)}`; + const next = start + value.length; + els.messageInput.setSelectionRange(next, next); + autoSizeInput(); + els.messageInput.focus(); +} + +function autocompletePrefix(): string { + const before = els.messageInput.value.slice(0, els.messageInput.selectionStart); + const match = before.match(/:([A-Za-z0-9_+\-.]{1,32})$/); + return match ? match[1] : ''; +} + +function renderAutocomplete() { + const prefix = autocompletePrefix(); + if (!prefix) { + els.autocomplete.hidden = true; + return; + } + const matches = inventoryNames().filter((name) => name.toLowerCase().includes(prefix.toLowerCase())).slice(0, 8); + if (!matches.length) { + els.autocomplete.hidden = true; + return; + } + state.autocompleteIndex = Math.min(state.autocompleteIndex, matches.length - 1); + els.autocomplete.replaceChildren(...matches.map((name, index) => { + const button = create('button', index === state.autocompleteIndex ? 'is-active' : ''); + button.type = 'button'; + const image = document.createElement('img'); + image.className = 'emoticon'; + image.src = `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(name)}`; + image.alt = ''; + button.append(image, create('span', '', `:${name}:`)); + button.addEventListener('mousedown', (event: MouseEvent) => { + event.preventDefault(); + applyAutocomplete(name); + }); + return button; + })); + els.autocomplete.hidden = false; +} + +function applyAutocomplete(name: string) { + const start = els.messageInput.selectionStart; + const before = els.messageInput.value.slice(0, start).replace(/:([A-Za-z0-9_+\-.]{1,32})$/, `:${name}:`); + els.messageInput.value = `${before}${els.messageInput.value.slice(start)}`; + els.messageInput.setSelectionRange(before.length, before.length); + els.autocomplete.hidden = true; + autoSizeInput(); +} + +function updateTitle() { + document.title = state.unread ? `(${state.unread}) ${baseTitle}` : baseTitle; +} + +function requestNotificationPermission() { + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission().catch(() => {}); + } +} + +function notify(item: MessageItem) { + if (!item || item.echo) return; + const inactive = item.id !== state.activeId || document.hidden || !document.hasFocus(); + if (!inactive) return; + state.unread += 1; + updateTitle(); + if ('Notification' in window && Notification.permission === 'granted') { + const notification = new Notification(item.name || 'Steam Chat', { + body: item.message || (item.imageUrl ? '[图片]' : '[消息]'), + tag: item.id + }); + notification.onclick = () => { + window.focus(); + openConversation(item.id, item.name || item.id); + notification.close(); + }; + } +} + +function clearUnread() { + state.unread = 0; + updateTitle(); +} + +function openSidebar() { + document.body.classList.add('sidebar-open'); +} + +function closeSidebar() { + document.body.classList.remove('sidebar-open'); +} + +function setupEvents() { + els.openSidebar.addEventListener('click', openSidebar); + els.closeSidebar.addEventListener('click', closeSidebar); + els.backdrop.addEventListener('click', closeSidebar); + window.addEventListener('resize', () => { + if (window.innerWidth > 900) closeSidebar(); + }); + $$('.tabs button').forEach((tab, index, tabs) => { + tab.addEventListener('click', () => { + tabs.forEach((item) => item.classList.toggle('is-active', item === tab)); + $$('.panel').forEach((panel) => panel.classList.toggle('is-active', panel.dataset.panel === tab.dataset.tab)); + }); + tab.addEventListener('keydown', (event) => { + const keyMap = { ArrowRight: (index + 1) % tabs.length, ArrowLeft: (index - 1 + tabs.length) % tabs.length, Home: 0, End: tabs.length - 1 }; + const key = event.key as keyof typeof keyMap; + if (key in keyMap) { + event.preventDefault(); + tabs[keyMap[key]].click(); + tabs[keyMap[key]].focus(); + } + }); + }); + els.openForm.addEventListener('submit', (event) => { + event.preventDefault(); + openConversation(els.targetInput.value.trim()); + }); + els.refreshAll.addEventListener('click', () => refreshInitialData()); + els.historyLimit.addEventListener('change', () => { + state.historyLimit = clampLimit(els.historyLimit.value); + els.historyLimit.value = String(state.historyLimit); + localStorage.setItem('steam-chat.history-limit', String(state.historyLimit)); + loadHistory().catch((error: unknown) => setFeedback(errorMessage(error), 'error')); + loadConversations().catch((error: unknown) => setFeedback(errorMessage(error), 'error')); + }); + els.sendButton.addEventListener('click', sendText); + els.messageInput.addEventListener('input', () => { + autoSizeInput(); + renderAutocomplete(); + }); + els.messageInput.addEventListener('keydown', (event) => { + if (!els.autocomplete.hidden && ['ArrowDown', 'ArrowUp', 'Tab', 'Enter', 'Escape'].includes(event.key)) { + const buttons = $$('.autocomplete button'); + if (event.key === 'Escape') { + els.autocomplete.hidden = true; + return; + } + event.preventDefault(); + if (event.key === 'ArrowDown') state.autocompleteIndex = (state.autocompleteIndex + 1) % buttons.length; + if (event.key === 'ArrowUp') state.autocompleteIndex = (state.autocompleteIndex - 1 + buttons.length) % buttons.length; + if (event.key === 'Tab' || event.key === 'Enter') { + buttons[state.autocompleteIndex]?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + return; + } + renderAutocomplete(); + return; + } + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + sendText(); + } + }); + els.fileButton.addEventListener('click', () => els.fileInput.click()); + els.fileInput.addEventListener('change', () => { + sendFiles(els.fileInput.files); + els.fileInput.value = ''; + }); + els.imageUrlForm.addEventListener('submit', (event) => { + event.preventDefault(); + const url = els.imageUrlInput.value.trim(); + if (!url) return; + els.imageUrlInput.value = ''; + sendImagePayload(url, { url }); + }); + els.pickerToggle.addEventListener('click', () => { + els.picker.hidden = !els.picker.hidden; + renderPicker(); + }); + $$('.picker-head button').forEach((button) => { + button.addEventListener('click', () => { + state.pickerTab = button.dataset.picker === 'stickers' ? 'stickers' : 'emoticons'; + renderPicker(); + }); + }); + els.pickerSearch.addEventListener('input', renderPicker); + document.addEventListener('paste', (event) => { + const files = [...(event.clipboardData?.files || [])].filter((file) => file.type.startsWith('image/')); + if (files.length) { + event.preventDefault(); + sendFiles(files); + } + }); + document.addEventListener('dragenter', (event) => { + if ([...(event.dataTransfer?.items || [])].some((item) => item.type.startsWith('image/'))) { + els.dropOverlay.classList.add('is-visible'); + } + }); + document.addEventListener('dragover', (event) => { + if (els.dropOverlay.classList.contains('is-visible')) event.preventDefault(); + }); + document.addEventListener('dragleave', (event) => { + if (!event.relatedTarget) els.dropOverlay.classList.remove('is-visible'); + }); + document.addEventListener('drop', (event) => { + const files = [...(event.dataTransfer?.files || [])].filter((file) => file.type.startsWith('image/')); + els.dropOverlay.classList.remove('is-visible'); + if (files.length) { + event.preventDefault(); + sendFiles(files); + } + }); + window.addEventListener('pointerdown', requestNotificationPermission, { once: true }); + window.addEventListener('keydown', requestNotificationPermission, { once: true }); + window.addEventListener('focus', clearUnread); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) clearUnread(); + }); + els.lightboxClose.addEventListener('click', closeLightbox); + els.lightbox.addEventListener('click', (event) => { + if (event.target === els.lightbox) closeLightbox(); + }); + els.zoomIn.addEventListener('click', () => zoomLightbox(0.25)); + els.zoomOut.addEventListener('click', () => zoomLightbox(-0.25)); + els.zoomReset.addEventListener('click', () => { + lightboxScale = 1; + lightboxX = 0; + lightboxY = 0; + applyLightboxTransform(); + }); + els.lightbox.addEventListener('wheel', (event) => { + event.preventDefault(); + zoomLightbox(event.deltaY > 0 ? -0.12 : 0.12); + }, { passive: false }); + els.lightboxImage.addEventListener('dblclick', () => { + lightboxScale = lightboxScale === 1 ? 2 : 1; + applyLightboxTransform(); + }); + els.lightboxImage.addEventListener('pointerdown', (event) => { + lightboxDragging = true; + lightboxStartX = event.clientX - lightboxX; + lightboxStartY = event.clientY - lightboxY; + els.lightboxImage.setPointerCapture(event.pointerId); + }); + els.lightboxImage.addEventListener('pointermove', (event) => { + if (!lightboxDragging) return; + lightboxX = event.clientX - lightboxStartX; + lightboxY = event.clientY - lightboxStartY; + applyLightboxTransform(); + }); + els.lightboxImage.addEventListener('pointerup', () => { + lightboxDragging = false; + }); + window.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !els.lightbox.hidden) closeLightbox(); + }); +} + +async function bootstrap() { + els.historyLimit.value = String(state.historyLimit); + setActiveConversation(state.activeId, state.activeId); + renderHistory([]); + renderLists(); + setupEvents(); + let wsPath = '/ws'; + try { + const config = await apiGet('/api/config'); + if (isRecord(config) && typeof config.wsPath === 'string') wsPath = config.wsPath; + } catch { + // Keep the default WebSocket path when the config endpoint is temporarily unavailable. + } + connectWebSocket(wsPath); +} + +bootstrap().catch((error: unknown) => { + console.error(error); + setFeedback(errorMessage(error) || '启动失败', 'error'); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..3f2480a --- /dev/null +++ b/web/index.html @@ -0,0 +1,100 @@ + + + + + + Steam Chat + + + +
+ + +
+ +
+
+ +
+

未选择会话

+

选择好友、群组或输入 SteamID64

+
+ +
+ +
+ +
+
+ + +
+ + + + + +
+
+ + +
+
+
+
+ +
释放发送图片
+ + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..17e3e7f --- /dev/null +++ b/web/style.css @@ -0,0 +1,691 @@ +:root { + --bg: #f6f8f5; + --panel: #ffffff; + --soft: #edf3ee; + --line: #d8dfd8; + --text: #1c2421; + --muted: #66736d; + --green: #197653; + --green-strong: #11593e; + --green-soft: #ddefe6; + --orange: #a26016; + --danger: #a33a35; + --shadow: 0 18px 42px rgba(20, 32, 27, 0.16); +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +input, +textarea { + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +input, +textarea { + min-width: 0; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text); + outline: none; +} + +input:focus, +textarea:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px var(--green-soft); +} + +.shell { + display: grid; + grid-template-columns: 330px minmax(0, 1fr); + min-height: 100dvh; +} + +.sidebar { + display: grid; + grid-template-rows: auto auto auto auto minmax(0, 1fr); + min-height: 100dvh; + border-right: 1px solid var(--line); + background: var(--panel); +} + +.side-head, +.chat-head { + display: flex; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--line); +} + +.side-head { + justify-content: space-between; + padding: 16px; +} + +.side-head h1, +.chat-title h2 { + margin: 0; + font-size: 18px; + letter-spacing: 0; +} + +.side-head p, +.chat-title p { + margin: 2px 0 0; + color: var(--muted); + font-size: 12px; +} + +.icon-btn { + display: inline-grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 6px; + background: var(--soft); + color: var(--text); +} + +.open-form, +.side-tools { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding: 12px; + border-bottom: 1px solid var(--line); +} + +.open-form input, +.side-tools input { + height: 36px; + padding: 0 10px; +} + +.open-form button, +.side-tools button, +#sendButton, +.url-row button { + min-height: 36px; + border-radius: 6px; + background: var(--green); + color: #fff; + padding: 0 14px; +} + +.side-tools label { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; + color: var(--muted); +} + +.tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + padding: 10px; + border-bottom: 1px solid var(--line); +} + +.tabs button { + height: 34px; + border-radius: 6px; + background: transparent; + color: var(--muted); +} + +.tabs button.is-active { + background: var(--green-soft); + color: var(--green-strong); +} + +.panel { + display: none; + min-height: 0; + overflow: auto; + padding: 8px; +} + +.panel.is-active { + display: block; +} + +.list-item { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + gap: 10px; + width: 100%; + margin-bottom: 4px; + padding: 9px; + border-radius: 8px; + background: transparent; + color: var(--text); + text-align: left; +} + +.list-item:hover, +.list-item.is-active { + background: var(--soft); +} + +.avatar { + display: grid; + place-items: center; + width: 40px; + height: 40px; + overflow: hidden; + border-radius: 8px; + background: var(--green-soft); + color: var(--green-strong); + font-weight: 700; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.item-body { + min-width: 0; +} + +.item-top { + display: flex; + align-items: center; + gap: 6px; +} + +.item-name, +.item-preview, +.item-meta, +.chat-title h2, +.chat-title p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item-name { + min-width: 0; + font-weight: 650; +} + +.item-preview, +.item-meta, +.item-time { + color: var(--muted); + font-size: 12px; +} + +.item-time { + margin-left: auto; + white-space: nowrap; +} + +.presence { + width: 8px; + height: 8px; + border-radius: 50%; + background: #aeb8b2; +} + +.presence.is-online { + background: var(--green); +} + +.chat { + display: grid; + grid-template-rows: 64px minmax(0, 1fr) auto; + min-height: 100dvh; + min-width: 0; +} + +.chat-head { + padding: 0 18px; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(12px); +} + +.menu-btn { + display: none; +} + +.chat-title { + min-width: 0; +} + +.feedback { + margin-left: auto; + color: var(--muted); + white-space: nowrap; +} + +.messages { + min-height: 0; + overflow: auto; + padding: 18px 22px 24px; +} + +.empty { + padding: 18px; + color: var(--muted); + text-align: center; +} + +.separator { + display: flex; + align-items: center; + gap: 10px; + margin: 18px 0; + color: var(--muted); + font-size: 12px; +} + +.separator::before, +.separator::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); +} + +.msg-row { + display: grid; + margin: 10px 0; +} + +.msg-row.is-self { + justify-items: end; +} + +.bubble { + max-width: min(720px, 78%); + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + overflow: hidden; +} + +.msg-row.is-self .bubble { + border-color: #c4ddce; + background: #effaf4; +} + +.bubble.is-visual { + max-width: min(480px, 86%); +} + +.meta { + display: flex; + gap: 8px; + padding: 8px 10px 0; + color: var(--muted); + font-size: 12px; +} + +.content { + padding: 8px 10px 10px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.content a { + color: var(--green-strong); +} + +.emoticon { + width: 28px; + height: 28px; + vertical-align: middle; +} + +.sticker { + width: 128px; + height: 128px; + object-fit: contain; + cursor: zoom-in; +} + +.image-shell { + min-width: 220px; + padding: 28px 18px; + border-radius: 6px; + background: var(--soft); + color: var(--muted); + text-align: center; +} + +.message-image { + display: block; + max-width: min(420px, 100%); + border-radius: 6px; + cursor: zoom-in; +} + +.og-card { + display: grid; + grid-template-columns: 90px minmax(0, 1fr); + gap: 10px; + margin-top: 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + text-decoration: none; +} + +.og-card strong, +.og-card span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.og-card span { + color: var(--muted); + font-size: 12px; +} + +.composer { + position: relative; + border-top: 1px solid var(--line); + background: rgba(255, 255, 255, 0.96); + padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); +} + +.compose-row { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + gap: 8px; + align-items: end; +} + +textarea { + width: 100%; + min-height: 38px; + max-height: 160px; + resize: none; + padding: 9px 10px; +} + +.url-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + margin-top: 8px; +} + +.url-row input { + height: 36px; + padding: 0 10px; +} + +.upload-tray { + display: grid; + gap: 6px; + margin-bottom: 8px; +} + +.upload-item { + display: flex; + justify-content: space-between; + gap: 8px; + border-radius: 6px; + background: var(--soft); + padding: 7px 9px; + color: var(--muted); + font-size: 12px; +} + +.picker, +.autocomplete { + position: absolute; + z-index: 8; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.picker { + left: 16px; + bottom: calc(100% - 6px); + width: min(480px, calc(100vw - 32px)); + overflow: hidden; +} + +.picker-head { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + gap: 6px; + padding: 8px; + border-bottom: 1px solid var(--line); +} + +.picker-head button, +.picker-grid button, +.autocomplete button { + border-radius: 6px; + background: var(--soft); + color: var(--text); +} + +.picker-head button { + padding: 7px 10px; +} + +.picker-head button.is-active { + background: var(--green-soft); + color: var(--green-strong); +} + +.picker-head input { + padding: 0 9px; +} + +.picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); + gap: 6px; + max-height: 280px; + overflow: auto; + padding: 8px; +} + +.picker-grid button { + display: grid; + justify-items: center; + gap: 4px; + min-width: 0; + padding: 8px; +} + +.picker-grid img { + width: 34px; + height: 34px; + object-fit: contain; +} + +.picker-grid span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.autocomplete { + left: 104px; + bottom: calc(100% + 8px); + width: 240px; + max-height: 220px; + overflow: auto; +} + +.autocomplete button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + background: transparent; + text-align: left; +} + +.autocomplete button.is-active, +.autocomplete button:hover { + background: var(--soft); +} + +.drop-overlay { + position: fixed; + inset: 18px; + display: none; + place-items: center; + z-index: 30; + border: 2px dashed var(--green); + border-radius: 8px; + background: rgba(246, 248, 245, 0.9); + color: var(--green-strong); + font-size: 24px; + font-weight: 700; +} + +.drop-overlay.is-visible { + display: grid; +} + +.lightbox { + position: fixed; + inset: 0; + display: grid; + place-items: center; + z-index: 40; + background: rgba(12, 18, 15, 0.88); +} + +.lightbox[hidden] { + display: none; +} + +.lightbox img { + max-width: 92vw; + max-height: 88vh; + transform-origin: center; + cursor: grab; + user-select: none; +} + +.lightbox-close, +.lightbox-tools { + position: fixed; +} + +.lightbox-close { + top: 18px; + right: 18px; + background: rgba(255, 255, 255, 0.18); + color: #fff; +} + +.lightbox-tools { + right: 18px; + bottom: 24px; + display: flex; + gap: 6px; +} + +.lightbox-tools button { + height: 34px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.18); + color: #fff; + padding: 0 12px; +} + +.backdrop { + display: none; +} + +@media (max-width: 900px) { + .shell { + display: block; + } + + .sidebar { + position: fixed; + inset: 0 auto 0 0; + width: min(88vw, 340px); + z-index: 20; + transform: translateX(-102%); + transition: transform 160ms ease; + box-shadow: var(--shadow); + } + + body.sidebar-open .sidebar { + transform: translateX(0); + } + + .backdrop { + position: fixed; + inset: 0; + z-index: 19; + background: rgba(13, 19, 16, 0.32); + } + + body.sidebar-open .backdrop { + display: block; + } + + .menu-btn { + display: inline-grid; + } + + .chat-head { + padding: 0 12px; + } + + .messages { + padding: 14px 12px 18px; + } + + .bubble { + max-width: 92%; + } + + .composer { + padding: 10px 10px calc(10px + env(safe-area-inset-bottom)); + } + + textarea { + max-height: 108px; + } + + .url-row { + grid-template-columns: 1fr; + } +}