chore: migrate Steam Chat to TypeScript and expand tests
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,3 +26,7 @@ coverage/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
.omc
|
||||
.sisyphus
|
||||
.claude
|
||||
|
||||
907
AGENTS.md
907
AGENTS.md
@@ -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 启动自评审流程,目标:<TARGET> [| 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 ./<PACKAGE>...` + `go test ./<PACKAGE>...`
|
||||
- 若跨多个包 → 对每个涉及的独立包分别运行 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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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="""
|
||||
<review_type>CODE CORRECTNESS + QUALITY REVIEW</review_type>
|
||||
<module>{MODULE_NAME}</module>
|
||||
<files>{NEWLINE_SEPARATED_FILE_LIST_WITH_FULL_CONTENT}</files>
|
||||
<context>{MODULE_SPECIFIC_CONTEXT_FROM_USER}</context>
|
||||
|
||||
通用要求:
|
||||
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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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: <verdict>PASS or FAIL</verdict> <findings>each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation</findings>
|
||||
""")
|
||||
```
|
||||
|
||||
### Agent 2: 安全 + 边界条件
|
||||
|
||||
```
|
||||
task(subagent_type="oracle", load_skills=[], run_in_background=true,
|
||||
description="Review security of MODULE_NAME",
|
||||
prompt="""
|
||||
<review_type>SECURITY + EDGE CASE REVIEW</review_type>
|
||||
<module>{MODULE_NAME}</module>
|
||||
<files>{FILE_LIST}</files>
|
||||
|
||||
通用要求:
|
||||
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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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: <verdict>PASS or FAIL</verdict> <findings>each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, and concrete explanation</findings>
|
||||
""")
|
||||
```
|
||||
|
||||
### Agent 3: 架构 + 模式
|
||||
|
||||
```
|
||||
task(subagent_type="oracle", load_skills=[], run_in_background=true,
|
||||
description="Review architecture of MODULE_NAME",
|
||||
prompt="""
|
||||
<review_type>ARCHITECTURE + PATTERN REVIEW</review_type>
|
||||
<module>{MODULE_NAME}</module>
|
||||
<files>{FILE_LIST}</files>
|
||||
|
||||
通用要求:
|
||||
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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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: <verdict>PASS or FAIL</verdict> <findings>each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation</findings>
|
||||
""")
|
||||
```
|
||||
|
||||
### Agent 4: 攻击者测试
|
||||
|
||||
这是新增角色,专门从"破坏系统"角度审查。它不检查"代码好不好看",只检查"有什么方式能让系统坏掉"。
|
||||
|
||||
```
|
||||
task(subagent_type="oracle", load_skills=[], run_in_background=true,
|
||||
description="Adversarial review of MODULE_NAME",
|
||||
prompt="""
|
||||
<review_type>ADVERSARIAL + DESTRUCTIVE TESTING</review_type>
|
||||
<module>{MODULE_NAME}</module>
|
||||
<files>{FILE_LIST}</files>
|
||||
|
||||
角色: 你是恶意攻击者/系统破坏者。你的目标是找到所有方式让这个模块出错、崩溃、数据损坏、或行为异常。
|
||||
你不关心代码风格或架构优雅性。只关心:**我怎么搞坏它?**
|
||||
|
||||
通用要求:
|
||||
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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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: <verdict>PASS or FAIL</verdict> <findings>each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, concrete exploit scenario, and expected impact</findings>
|
||||
""")
|
||||
```
|
||||
|
||||
## 交叉验证
|
||||
|
||||
所有 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
|
||||
```
|
||||
|
||||
交叉验证的输出格式:
|
||||
```
|
||||
<cross_validation>
|
||||
<agent4_supplements>
|
||||
<finding ref="agent3_finding_5">严重度从 MINOR 修正为 MAJOR: ...</finding>
|
||||
<finding ref="new">Agent 1-3 未发现的路径: ...</finding>
|
||||
</agent4_supplements>
|
||||
<agent1_3_review>
|
||||
<finding ref="agent4_finding_3">与 agent1_finding_7 重复,合并</finding>
|
||||
<finding ref="agent4_finding_8">确认遗漏,补充到主清单</finding>
|
||||
</agent1_3_review>
|
||||
<misjudgment_check>
|
||||
<finding ref="agent1_finding_2" verdict="MISJUDGMENT">
|
||||
原判定: "sync.Map.Delete 幂等安全,无实际 bug"
|
||||
纠正: 虽然 Delete 不 panic,但此时 map 中存的是其他 goroutine 的 token,删除它导致该 goroutine 状态泄露
|
||||
</finding>
|
||||
</misjudgment_check>
|
||||
</cross_validation>
|
||||
```
|
||||
|
||||
## 修复 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}
|
||||
```
|
||||
643
API.md
643
API.md
@@ -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://<host>:<port><wsPath>
|
||||
```
|
||||
|
||||
默认示例:
|
||||
|
||||
```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',
|
||||
}));
|
||||
};
|
||||
```
|
||||
430
FEATURES.zh-CN.md
Normal file
430
FEATURES.zh-CN.md
Normal file
@@ -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 `<img src="url">`、直接图片 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 前端,不能在同一文件内混用模块系统。
|
||||
289
README.md
289
README.md
@@ -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)
|
||||
289
README.zh-CN.md
289
README.zh-CN.md
@@ -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)
|
||||
@@ -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(颜色、间距、阴影、圆角)
|
||||
- [ ] 清理未使用类名和重复媒体查询
|
||||
@@ -1,558 +0,0 @@
|
||||
# 通用功能模块自评审流程
|
||||
|
||||
> 一键触发:`ulw 启动自评审流程,目标:<包路径 | 功能模块描述>`
|
||||
> 交互触发:`ulw 我要自评审`(逐步问答,无需记参数)
|
||||
> 示例:`ulw 启动自评审流程,目标:core/helper/search`
|
||||
> 示例:`ulw 启动自评审流程,目标:订单结算流程`
|
||||
> 示例:`ulw 启动自评审流程,目标:库存同步与发货`
|
||||
|
||||
## 触发方式
|
||||
|
||||
支持两种触发模式:
|
||||
|
||||
### 模式 1:直接触发(参数完整)
|
||||
|
||||
适合熟悉格式、一次性写全的场景。
|
||||
|
||||
Sisyphus 将根据输入自动判断目标类型:
|
||||
|
||||
- **Go 包路径**(包含 `/` 或 `.`,如 `core/helper/search`)→ 走包发现模式
|
||||
- **功能模块描述**(自然语言,如 `订单结算流程`)→ 走功能模块发现模式
|
||||
- **手动指定文件清单**:在上述参数后追加 `|` 分隔的文件路径,跳过自动发现
|
||||
|
||||
```
|
||||
ulw 启动自评审流程,目标:<TARGET> [| 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 ./<PACKAGE>...` + `go test ./<PACKAGE>...`
|
||||
- 若跨多个包 → 对每个涉及的独立包分别运行 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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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="""
|
||||
<review_type>CODE CORRECTNESS + QUALITY REVIEW</review_type>
|
||||
<module>{MODULE_NAME}</module>
|
||||
<files>{NEWLINE_SEPARATED_FILE_LIST_WITH_FULL_CONTENT}</files>
|
||||
<context>{MODULE_SPECIFIC_CONTEXT_FROM_USER}</context>
|
||||
|
||||
通用要求:
|
||||
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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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: <verdict>PASS or FAIL</verdict> <findings>each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation</findings>
|
||||
""")
|
||||
```
|
||||
|
||||
### Agent 2: 安全 + 边界条件
|
||||
|
||||
```
|
||||
task(subagent_type="oracle", load_skills=[], run_in_background=true,
|
||||
description="Review security of MODULE_NAME",
|
||||
prompt="""
|
||||
<review_type>SECURITY + EDGE CASE REVIEW</review_type>
|
||||
<module>{MODULE_NAME}</module>
|
||||
<files>{FILE_LIST}</files>
|
||||
|
||||
通用要求:
|
||||
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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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: <verdict>PASS or FAIL</verdict> <findings>each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, and concrete explanation</findings>
|
||||
""")
|
||||
```
|
||||
|
||||
### Agent 3: 架构 + 模式
|
||||
|
||||
```
|
||||
task(subagent_type="oracle", load_skills=[], run_in_background=true,
|
||||
description="Review architecture of MODULE_NAME",
|
||||
prompt="""
|
||||
<review_type>ARCHITECTURE + PATTERN REVIEW</review_type>
|
||||
<module>{MODULE_NAME}</module>
|
||||
<files>{FILE_LIST}</files>
|
||||
|
||||
通用要求:
|
||||
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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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: <verdict>PASS or FAIL</verdict> <findings>each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation</findings>
|
||||
""")
|
||||
```
|
||||
|
||||
### Agent 4: 攻击者测试
|
||||
|
||||
这是新增角色,专门从"破坏系统"角度审查。它不检查"代码好不好看",只检查"有什么方式能让系统坏掉"。
|
||||
|
||||
```
|
||||
task(subagent_type="oracle", load_skills=[], run_in_background=true,
|
||||
description="Adversarial review of MODULE_NAME",
|
||||
prompt="""
|
||||
<review_type>ADVERSARIAL + DESTRUCTIVE TESTING</review_type>
|
||||
<module>{MODULE_NAME}</module>
|
||||
<files>{FILE_LIST}</files>
|
||||
|
||||
角色: 你是恶意攻击者/系统破坏者。你的目标是找到所有方式让这个模块出错、崩溃、数据损坏、或行为异常。
|
||||
你不关心代码风格或架构优雅性。只关心:**我怎么搞坏它?**
|
||||
|
||||
通用要求:
|
||||
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:<appID>` vs `game:batch:<hash>`)是否可能碰撞?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: <verdict>PASS or FAIL</verdict> <findings>each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, concrete exploit scenario, and expected impact</findings>
|
||||
""")
|
||||
```
|
||||
|
||||
## 交叉验证
|
||||
|
||||
所有 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
|
||||
```
|
||||
|
||||
交叉验证的输出格式:
|
||||
```
|
||||
<cross_validation>
|
||||
<agent4_supplements>
|
||||
<finding ref="agent3_finding_5">严重度从 MINOR 修正为 MAJOR: ...</finding>
|
||||
<finding ref="new">Agent 1-3 未发现的路径: ...</finding>
|
||||
</agent4_supplements>
|
||||
<agent1_3_review>
|
||||
<finding ref="agent4_finding_3">与 agent1_finding_7 重复,合并</finding>
|
||||
<finding ref="agent4_finding_8">确认遗漏,补充到主清单</finding>
|
||||
</agent1_3_review>
|
||||
<misjudgment_check>
|
||||
<finding ref="agent1_finding_2" verdict="MISJUDGMENT">
|
||||
原判定: "sync.Map.Delete 幂等安全,无实际 bug"
|
||||
纠正: 虽然 Delete 不 panic,但此时 map 中存的是其他 goroutine 的 token,删除它导致该 goroutine 状态泄露
|
||||
</finding>
|
||||
</misjudgment_check>
|
||||
</cross_validation>
|
||||
```
|
||||
|
||||
## 修复 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}
|
||||
```
|
||||
@@ -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 诊断无新增错误
|
||||
- [ ] 改动的代码通过了手动验证(见验证计划各条)
|
||||
|
||||
**以上门禁全部通过才算任务完成。**
|
||||
68
client.js
68
client.js
@@ -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');
|
||||
@@ -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",
|
||||
accountName: 'your_steam_login_name',
|
||||
password: 'your_steam_password',
|
||||
logonID: Math.floor(Math.random() * 0x7fffffff),
|
||||
steamID: '',
|
||||
identitySecret: '',
|
||||
chat: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
wsPath: '/ws',
|
||||
auth: {
|
||||
username: 'admin',
|
||||
password: 'change-me',
|
||||
username: '',
|
||||
password: '',
|
||||
realm: 'Steam Chat',
|
||||
trustProxy: false,
|
||||
},
|
||||
},
|
||||
trustProxy: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
111
logger.js
111
logger.js
@@ -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<void>}
|
||||
*/
|
||||
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,
|
||||
}
|
||||
797
package-lock.json
generated
797
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
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"
|
||||
}
|
||||
}
|
||||
|
||||
401
public/app.js
401
public/app.js
@@ -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);
|
||||
})();
|
||||
@@ -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
|
||||
103
public/app/bootstrap.js
vendored
103
public/app/bootstrap.js
vendored
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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') : [],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(/<img\b[^>]*?\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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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\])|(<img\b[^>]*?\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], '<img>', 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(/<img\b[^>]*?\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),
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Steam Chat</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="dropOverlay" class="drop-overlay">
|
||||
<div class="drop-overlay-card">松开即可发送图片</div>
|
||||
</div>
|
||||
<div id="sidebarBackdrop" class="sidebar-backdrop" aria-hidden="true"></div>
|
||||
<div id="imageLightbox" class="image-lightbox" aria-hidden="true">
|
||||
<div class="image-lightbox-dialog" role="dialog" aria-modal="true" aria-label="图片预览" tabindex="-1">
|
||||
<button id="closeImageLightbox" class="secondary image-lightbox-close" type="button" aria-label="关闭">×</button>
|
||||
<div id="imageLightboxViewport" class="image-lightbox-viewport">
|
||||
<img id="imageLightboxImage" alt="放大预览" />
|
||||
</div>
|
||||
<div class="image-lightbox-toolbar">
|
||||
<button id="imageZoomOut" class="secondary" type="button" aria-label="缩小">-</button>
|
||||
<button id="imageZoomReset" class="secondary" type="button" aria-label="重置缩放">100%</button>
|
||||
<button id="imageZoomIn" class="secondary" type="button" aria-label="放大">+</button>
|
||||
</div>
|
||||
<div id="imageLightboxCaption" class="image-lightbox-caption"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-mobile-header">
|
||||
<strong>会话与设置</strong>
|
||||
<button id="closeSidebar" class="secondary" type="button">关闭</button>
|
||||
</div>
|
||||
<div class="card sidebar-config-card">
|
||||
<div class="toolbar">
|
||||
<label>
|
||||
<span class="field-label">打开会话</span>
|
||||
<input id="targetId" placeholder="输入 SteamID64" />
|
||||
</label>
|
||||
<button id="openConversation" type="button">打开</button>
|
||||
</div>
|
||||
<div class="toolbar toolbar-spaced">
|
||||
<label class="field-compact">
|
||||
<span class="field-label">历史条数</span>
|
||||
<input id="historyLimit" type="number" min="1" max="500" value="100" />
|
||||
</label>
|
||||
<button id="reloadHistory" class="secondary" type="button">刷新历史</button>
|
||||
</div>
|
||||
<div id="feedbackStatus" class="feedback-status" aria-live="polite">准备就绪</div>
|
||||
</div>
|
||||
<div class="card conversation-card card-fill">
|
||||
<div class="sidebar-tabs" role="tablist" aria-label="侧边栏列表">
|
||||
<button type="button" id="sidebarTabButtonConversations" class="sidebar-tab active" data-tab="conversations" role="tab" aria-selected="true" aria-controls="sidebarTabConversations">最近会话</button>
|
||||
<button type="button" id="sidebarTabButtonFriends" class="sidebar-tab" data-tab="friends" role="tab" aria-selected="false" aria-controls="sidebarTabFriends" tabindex="-1">好友</button>
|
||||
<button type="button" id="sidebarTabButtonGroups" class="sidebar-tab" data-tab="groups" role="tab" aria-selected="false" aria-controls="sidebarTabGroups" tabindex="-1">群组</button>
|
||||
</div>
|
||||
<div id="sidebarTabConversations" class="sidebar-tab-panel" role="tabpanel" aria-labelledby="sidebarTabButtonConversations">
|
||||
<div class="chat-header sidebar-section-header">
|
||||
<strong>最近会话</strong>
|
||||
<button id="reloadConversations" class="secondary" type="button">刷新列表</button>
|
||||
</div>
|
||||
<div id="conversationList" class="sidebar-list"></div>
|
||||
</div>
|
||||
<div id="sidebarTabFriends" class="sidebar-tab-panel" role="tabpanel" aria-labelledby="sidebarTabButtonFriends" hidden>
|
||||
<div class="chat-header sidebar-section-header">
|
||||
<strong>好友列表</strong>
|
||||
<button id="reloadFriends" class="secondary" type="button">刷新列表</button>
|
||||
</div>
|
||||
<div id="friendsList" class="sidebar-list"></div>
|
||||
</div>
|
||||
<div id="sidebarTabGroups" class="sidebar-tab-panel" role="tabpanel" aria-labelledby="sidebarTabButtonGroups" hidden>
|
||||
<div class="chat-header sidebar-section-header">
|
||||
<strong>群组列表</strong>
|
||||
<button id="reloadGroups" class="secondary" type="button">刷新列表</button>
|
||||
</div>
|
||||
<div id="groupsList" class="sidebar-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="chat-panel">
|
||||
<div class="card chat-topbar">
|
||||
<div class="chat-header">
|
||||
<div class="chat-heading">
|
||||
<div id="chatTitle" class="chat-title">未选择会话</div>
|
||||
<div id="chatSubtitle" class="chat-subtitle">请选择左侧会话,或手动输入 SteamID64</div>
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<button id="mobileSidebarToggle" class="secondary mobile-nav-button" type="button" aria-expanded="false">会话列表</button>
|
||||
<div id="connectionStatus" class="connection-chip is-connecting" role="status" aria-live="polite">
|
||||
<span class="connection-chip-dot" aria-hidden="true"></span>
|
||||
<span id="connectionStatusLabel">连接中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messages" class="card">
|
||||
<div class="empty-state">请选择一个会话开始聊天</div>
|
||||
</div>
|
||||
|
||||
<div class="card composer">
|
||||
<div id="attachmentPreview" class="attachment-preview">
|
||||
<img id="attachmentPreviewImage" alt="attachment preview" hidden />
|
||||
<div class="attachment-preview-body">
|
||||
<div id="attachmentPreviewTitle" class="attachment-preview-title"></div>
|
||||
<div id="attachmentPreviewSubtitle" class="attachment-preview-subtitle"></div>
|
||||
</div>
|
||||
<button id="clearAttachment" class="secondary" type="button">移除</button>
|
||||
</div>
|
||||
<div id="uploadQueue" class="upload-queue">
|
||||
<div class="upload-queue-title">发送队列</div>
|
||||
<div id="uploadQueueList"></div>
|
||||
</div>
|
||||
<div id="urlPanel" class="url-panel">
|
||||
<label>
|
||||
<span class="field-label">图片 URL</span>
|
||||
<input id="imageUrl" placeholder="https://example.com/a.png" />
|
||||
</label>
|
||||
<button id="confirmImageUrl" type="button">添加</button>
|
||||
<button id="cancelImageUrl" class="secondary" type="button">取消</button>
|
||||
</div>
|
||||
<div class="composer-row">
|
||||
<div class="composer-actions">
|
||||
<button id="attachmentButton" class="secondary icon-button" type="button" title="选择图片或图片 URL">+</button>
|
||||
<div id="attachmentMenu" class="attachment-menu">
|
||||
<button id="chooseImageButton" type="button">选择图片</button>
|
||||
<button id="chooseUrlButton" type="button">输入图片 URL</button>
|
||||
<div class="attachment-hint">支持直接粘贴剪切板图片到输入框</div>
|
||||
</div>
|
||||
<button id="pickerButton" class="secondary icon-button" type="button" title="表情和贴纸">☺</button>
|
||||
<div id="pickerPanel" class="picker-panel">
|
||||
<div class="picker-tabs">
|
||||
<button type="button" class="picker-tab active" data-tab="emoticons">表情</button>
|
||||
<button type="button" class="picker-tab" data-tab="stickers">贴纸</button>
|
||||
</div>
|
||||
<div class="picker-search">
|
||||
<input id="pickerSearch" type="text" placeholder="搜索..." />
|
||||
</div>
|
||||
<div id="pickerGrid" class="picker-grid"></div>
|
||||
<div id="pickerEmpty" class="picker-empty">加载中…</div>
|
||||
</div>
|
||||
<input id="imageFile" type="file" accept="image/*" hidden />
|
||||
</div>
|
||||
<label class="composer-field">
|
||||
<span class="field-label">文本消息</span>
|
||||
<textarea
|
||||
id="messageInput"
|
||||
aria-label="输入消息"
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行;也可直接粘贴图片"
|
||||
data-desktop-placeholder="输入消息,Enter 发送,Shift+Enter 换行;也可直接粘贴图片"
|
||||
data-mobile-placeholder="输入消息"
|
||||
></textarea>
|
||||
<div id="emoticonSuggestions" class="emoticon-suggestions">
|
||||
<div id="emoticonPreview" class="emoticon-preview">
|
||||
<img id="emoticonPreviewImage" alt="emoticon preview" />
|
||||
<div id="emoticonPreviewLabel" class="emoticon-preview-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<button id="sendMessage" type="button">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
28
src/config/load.ts
Normal file
28
src/config/load.ts
Normal file
@@ -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
|
||||
};
|
||||
184
src/index.ts
Normal file
184
src/index.ts
Normal file
@@ -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<string, Persona>;
|
||||
};
|
||||
|
||||
type SteamUserMain = {
|
||||
users?: Record<string, Persona>;
|
||||
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<string, Persona> = {};
|
||||
|
||||
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<Persona> {
|
||||
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<PersonaResponse>((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
|
||||
};
|
||||
22
src/paths.ts
Normal file
22
src/paths.ts
Normal file
@@ -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
|
||||
};
|
||||
93
src/server/auth.ts
Normal file
93
src/server/auth.ts
Normal file
@@ -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<AuthConfig, 'realm'> = {}): 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
|
||||
};
|
||||
781
src/server/chat-service.ts
Normal file
781
src/server/chat-service.ts
Normal file
@@ -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<unknown> | (() => Promise<unknown> | 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<string, Persona>;
|
||||
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> | unknown;
|
||||
getUserInfo?: (value: unknown) => Promise<Persona>;
|
||||
getSelfName?: () => Promise<string>;
|
||||
getEmoticons?: (options: GetEmoticonsOptions) => Promise<EmoticonPayload>;
|
||||
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<unknown> {
|
||||
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<string, string> = {}) {
|
||||
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<string, string> = {}) {
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage, maxBytes = MAX_BODY_BYTES): Promise<string> {
|
||||
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<UnknownRecord> {
|
||||
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<string, string> = {
|
||||
'.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<unknown> {
|
||||
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<Persona> = 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<WsConnection>();
|
||||
const recentSentText = new Map<string, number>();
|
||||
const recentSentImages = new Map<string, number>();
|
||||
|
||||
const server: Server = options.server || http.createServer(handleHttpRequest);
|
||||
const wss: WsServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
function remember(map: Map<string, number>, key: string, ttl = 30 * 1000) {
|
||||
map.set(key, Date.now());
|
||||
setTimeout(() => map.delete(key), ttl).unref?.();
|
||||
}
|
||||
|
||||
function isRecent(map: Map<string, number>, 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<T>(operation: () => Promise<T> | T, needsWebSession = false): Promise<T> {
|
||||
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<HistoryItem> {
|
||||
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<HistoryItem> {
|
||||
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<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
sendTextMessage,
|
||||
sendImageMessage,
|
||||
broadcast,
|
||||
handleHttpRequest,
|
||||
handleWsMessage
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultGetEmoticons({ steamUser, waitForLogin, waitForWebSession }: GetEmoticonsOptions): Promise<EmoticonPayload> {
|
||||
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<FriendSummary[]> {
|
||||
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<GroupSummary[]> {
|
||||
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
|
||||
};
|
||||
32
src/server/network.ts
Normal file
32
src/server/network.ts
Normal file
@@ -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
|
||||
};
|
||||
319
src/steam/lifecycle.ts
Normal file
319
src/steam/lifecycle.ts
Normal file
@@ -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<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
type RefreshTokenFileSystem = Pick<typeof import('node:fs'), 'readFileSync' | 'mkdirSync' | 'writeFileSync'>;
|
||||
|
||||
type TimerHandle = ReturnType<typeof setTimeout> | 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<T = unknown>(): Deferred<T> {
|
||||
let resolve: (value: T | PromiseLike<T>) => void;
|
||||
let reject: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<boolean>();
|
||||
let webDeferred = createDeferred<WebSession>();
|
||||
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<WebSession> {
|
||||
if (stopped) return Promise.reject(new Error('Steam lifecycle is stopped'));
|
||||
webDeferred = createDeferred<WebSession>();
|
||||
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
|
||||
};
|
||||
131
src/steam/message-logger.ts
Normal file
131
src/steam/message-logger.ts
Normal file
@@ -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<Persona>;
|
||||
getSelfName?: () => Promise<string>;
|
||||
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<Persona> = options.getUserInfo || (async () => ({ player_name: 'Unknown' }));
|
||||
if (!steamUser || typeof steamUser.on !== 'function') {
|
||||
throw new Error('steamUser EventEmitter is required');
|
||||
}
|
||||
|
||||
const echoKeys = new Map<string, boolean>();
|
||||
const importAttempts = new Set<string>();
|
||||
|
||||
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<SteamHistoryMessage[]>((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
|
||||
};
|
||||
227
src/storage/chat-log.ts
Normal file
227
src/storage/chat-log.ts
Normal file
@@ -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<HistoryItem, 'date' | 'sentAt' | 'ordinal'>): 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<HistoryItem> {
|
||||
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<HistoryItem[]> {
|
||||
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<HistoryItem[]> {
|
||||
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(/<img\b[^>]*>/gi, '[图片]')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function previewForMessage(item: Pick<HistoryItem, 'type' | 'message' | 'imageUrl'>): 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<ConversationSummary[]> {
|
||||
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<string, ConversationSummary & { updatedAtMs: number }>();
|
||||
|
||||
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
|
||||
};
|
||||
167
src/storage/media-cache.ts
Normal file
167
src/storage/media-cache.ts
Normal file
@@ -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<string, Promise<CachedImage>>();
|
||||
const stickerDownloads = new Map<string, Promise<CachedImage>>();
|
||||
|
||||
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<CachedImage> {
|
||||
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<CachedImage> {
|
||||
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<CachedImage> {
|
||||
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
|
||||
};
|
||||
106
src/types.ts
Normal file
106
src/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
100
test/auth-network.test.ts
Normal file
100
test/auth-network.test.ts
Normal file
@@ -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<string, string | string[] | undefined>, 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<string, string> = {};
|
||||
let body = '';
|
||||
auth.challenge({
|
||||
writeHead(code: number, nextHeaders: Record<string, string>) {
|
||||
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' });
|
||||
});
|
||||
107
test/chat-service-helpers.test.ts
Normal file
107
test/chat-service-helpers.test.ts
Normal file
@@ -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;
|
||||
});
|
||||
});
|
||||
1072
test/chat.test.js
1072
test/chat.test.js
File diff suppressed because it is too large
Load Diff
210
test/chat.test.ts
Normal file
210
test/chat.test.ts
Normal file
@@ -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<string, unknown> & {
|
||||
items?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type WsInbox = {
|
||||
next: () => Promise<WsMessage>;
|
||||
};
|
||||
|
||||
type TestSteamUser = EventEmitterType & {
|
||||
chat: {
|
||||
sendFriendMessage: (id: unknown, msg: unknown, callback: (error: Error | null, result?: unknown) => void) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ChatServiceRuntime = {
|
||||
server: Server;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type WsConstructor = new (url: string) => WsConnection;
|
||||
const WsClient = WebSocket as WsConstructor;
|
||||
|
||||
function listen(server: Server): Promise<number> {
|
||||
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');
|
||||
});
|
||||
68
test/logger.test.ts
Normal file
68
test/logger.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
135
test/media-cache.test.ts
Normal file
135
test/media-cache.test.ts
Normal file
@@ -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<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fetchImpl = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls += 1;
|
||||
userAgent = String((init?.headers as Record<string, string>)?.['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]);
|
||||
});
|
||||
105
test/message-logger.test.ts
Normal file
105
test/message-logger.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
110
test/steam-lifecycle.test.ts
Normal file
110
test/steam-lifecycle.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
1174
web/app.ts
Normal file
1174
web/app.ts
Normal file
File diff suppressed because it is too large
Load Diff
100
web/index.html
Normal file
100
web/index.html
Normal file
@@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Steam Chat</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<header class="side-head">
|
||||
<div>
|
||||
<h1>Steam Chat</h1>
|
||||
<p id="connectionText">连接中</p>
|
||||
</div>
|
||||
<button id="closeSidebar" class="icon-btn" type="button" title="关闭侧栏">×</button>
|
||||
</header>
|
||||
|
||||
<form id="openForm" class="open-form">
|
||||
<input id="targetInput" inputmode="numeric" autocomplete="off" placeholder="SteamID64">
|
||||
<button type="submit">打开</button>
|
||||
</form>
|
||||
|
||||
<div class="side-tools">
|
||||
<label>历史 <input id="historyLimit" type="number" min="1" max="500" step="10"></label>
|
||||
<button id="refreshAll" type="button">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="is-active" type="button" data-tab="conversations">最近</button>
|
||||
<button type="button" data-tab="friends">好友</button>
|
||||
<button type="button" data-tab="groups">群组</button>
|
||||
</div>
|
||||
|
||||
<section class="panel is-active" data-panel="conversations">
|
||||
<div id="conversationList" class="list"></div>
|
||||
</section>
|
||||
<section class="panel" data-panel="friends">
|
||||
<div id="friendList" class="list"></div>
|
||||
</section>
|
||||
<section class="panel" data-panel="groups">
|
||||
<div id="groupList" class="list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div id="backdrop" class="backdrop"></div>
|
||||
|
||||
<main class="chat">
|
||||
<header class="chat-head">
|
||||
<button id="openSidebar" class="icon-btn menu-btn" type="button" title="打开侧栏">☰</button>
|
||||
<div class="chat-title">
|
||||
<h2 id="chatTitle">未选择会话</h2>
|
||||
<p id="chatSubtitle">选择好友、群组或输入 SteamID64</p>
|
||||
</div>
|
||||
<div id="feedback" class="feedback">就绪</div>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="messages" aria-live="polite"></section>
|
||||
|
||||
<footer class="composer">
|
||||
<div id="uploadTray" class="upload-tray"></div>
|
||||
<div id="picker" class="picker" hidden>
|
||||
<div class="picker-head">
|
||||
<button class="is-active" type="button" data-picker="emoticons">表情</button>
|
||||
<button type="button" data-picker="stickers">贴纸</button>
|
||||
<input id="pickerSearch" type="search" placeholder="搜索">
|
||||
</div>
|
||||
<div id="pickerGrid" class="picker-grid"></div>
|
||||
</div>
|
||||
<div id="autocomplete" class="autocomplete" hidden></div>
|
||||
<div class="compose-row">
|
||||
<button id="pickerToggle" class="icon-btn" type="button" title="表情和贴纸">☺</button>
|
||||
<button id="fileButton" class="icon-btn" type="button" title="选择图片">+</button>
|
||||
<input id="fileInput" type="file" accept="image/*" multiple hidden>
|
||||
<textarea id="messageInput" rows="1" placeholder="发送消息"></textarea>
|
||||
<button id="sendButton" type="button">发送</button>
|
||||
</div>
|
||||
<form id="imageUrlForm" class="url-row">
|
||||
<input id="imageUrlInput" type="url" placeholder="图片 URL">
|
||||
<button type="submit">发送图片</button>
|
||||
</form>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="dropOverlay" class="drop-overlay">释放发送图片</div>
|
||||
<div id="lightbox" class="lightbox" hidden>
|
||||
<button id="lightboxClose" class="icon-btn lightbox-close" type="button" title="关闭">×</button>
|
||||
<div class="lightbox-tools">
|
||||
<button id="zoomOut" type="button">-</button>
|
||||
<button id="zoomReset" type="button">100%</button>
|
||||
<button id="zoomIn" type="button">+</button>
|
||||
</div>
|
||||
<img id="lightboxImage" alt="">
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
691
web/style.css
Normal file
691
web/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user