修复会话标题显示自己名字而非对方名字的问题

encodeSteamMessage 中 echo 消息始终使用 friendId 获取对方信息,
避免会话名称被自己的名字覆盖。同时包含 Web 聊天界面及相关重构。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 18:52:00 +08:00
parent b6b8c07757
commit f370f28da6
11 changed files with 8027 additions and 758 deletions

641
API.md Normal file
View File

@@ -0,0 +1,641 @@
# 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
}
}
```
#### 图片发送广播
当通过服务成功发送图片后,会广播:
```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',
}));
};
```

1899
chat.js

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ const SteamUser = require('steam-user');
const SteamCommunity = require('steamcommunity');
const fs = require('fs');
const winston = require("winston");
const config = require("./config.js");
const logger = winston.createLogger({
level: 'info',
@@ -23,10 +24,8 @@ steamUser.on("refreshToken", (refreshToken) => {
fs.writeFileSync('refresh.token', refreshToken);
});
const config = require("./config.js");
try {
refreshToken = fs.readFileSync('refresh.token', 'utf8');
const refreshToken = fs.readFileSync('refresh.token', 'utf8');
if (refreshToken && refreshToken.length > 0) {
const LogOnOptionsAUTO = {
@@ -54,20 +53,27 @@ try {
});
}
steamLoginPromise = new Promise((resolve, reject) => {
const steamLoginPromise = new Promise((resolve, reject) => {
steamUser.on('loggedOn', async () => {
logger.info(`login to Steam as ${steamUser.steamID}`);
try {
steamUser.webLogOn();
} catch (err) {
logger.warn(`failed to start web login: ${err.message}`);
}
resolve();
});
});
steamWebLoginPromise = new Promise((resolve, reject) => {
const steamWebLoginPromise = new Promise((resolve, reject) => {
steamUser.on('webSession', async (sessionID, cookies) => {
logger.info(`web session received: ${sessionID}`);
steamCommunity.setCookies(cookies);
steamCommunity.startConfirmationChecker(10000, config.identitySecret);
if (config.identitySecret) {
steamCommunity.startConfirmationChecker(10000, config.identitySecret);
}
resolve()
});
@@ -109,4 +115,3 @@ module.exports = {
steamLoginPromise: steamLoginPromise,
steamWebLoginPromise: steamWebLoginPromise,
}

View File

@@ -9,5 +9,16 @@ module.exports = {
password: 'password',
logonID: getRandomInt(1000000, 999999999),
steamID: "xxxxxxxxxx",
chat: false,
chat: {
enabled: false,
host: '0.0.0.0',
port: 3000,
wsPath: '/ws',
auth: {
username: 'admin',
password: 'change-me',
realm: 'Steam Chat',
trustProxy: false,
},
},
};

View File

@@ -101,7 +101,7 @@ function importChatHistory(steamID) {
});
}
if (config.chat == true) {
if (config.chat) {
require("./chat.js")
}

1144
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"type": "commonjs",
"main": "logger.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "STEAM_CHAT_DISABLE_AUTOSTART=1 node --test"
},
"keywords": [],
"author": "",

2523
public/app.js Normal file

File diff suppressed because it is too large Load Diff

144
public/index.html Normal file
View File

@@ -0,0 +1,144 @@
<!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">
<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" style="margin-top: 12px;">
<label style="max-width: 160px;">
<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="status" style="margin-top: 12px;">正在连接 WebSocket…</div>
</div>
<div class="card conversation-card" style="flex: 1; min-height: 0; display: flex; flex-direction: column;">
<div class="chat-header" style="margin-bottom: 12px;">
<strong>最近会话</strong>
<button id="reloadConversations" class="secondary" type="button">刷新列表</button>
</div>
<div id="conversationList"></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>
<span class="chip">WebSocket</span>
</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"
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 src="/app.js"></script>
</body>
</html>

1335
public/style.css Normal file

File diff suppressed because it is too large Load Diff

1063
test/chat.test.js Normal file

File diff suppressed because it is too large Load Diff