poi-plugin-mcp 0.2.12 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -93
- package/index.js +2 -0
- package/lib/bridge-controller.js +2 -0
- package/lib/poi-action-events.js +290 -0
- package/lib/poi-http-bridge.js +206 -9
- package/lib/poi-input-lease.js +226 -0
- package/lib/poi-input.js +97 -0
- package/lib/poi-telemetry.js +99 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,144 +1,201 @@
|
|
|
1
1
|
# poi-plugin-mcp
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
authenticated input.
|
|
3
|
+
`poi-plugin-mcp` 是运行在 Poi 内的本地数据桥。它把当前舰队、舰娘、装备、
|
|
4
|
+
资源、任务、陆航和相关主数据通过本机 HTTP 与 MCP 提供给本机工具。
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
默认监听:
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
```none
|
|
9
|
+
http://127.0.0.1:17777
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
服务只绑定本机回环地址 `127.0.0.1`,不对局域网或公网监听。本文介绍用于
|
|
13
|
+
读取 Poi 状态的 MCP 工具、MCP 资源和 HTTP 数据端点。
|
|
14
|
+
|
|
15
|
+
## 安装与重启
|
|
16
|
+
|
|
17
|
+
在 Poi 插件管理器中安装 npm 包:
|
|
10
18
|
|
|
11
|
-
```
|
|
19
|
+
```none
|
|
12
20
|
poi-plugin-mcp
|
|
13
21
|
```
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
也可以完全退出 Poi 后,在 Poi 插件目录中手动安装:
|
|
16
24
|
|
|
17
25
|
```powershell
|
|
18
26
|
cd "$env:APPDATA\poi\plugins"
|
|
19
27
|
npm install poi-plugin-mcp
|
|
20
28
|
```
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
安装或更新后完整退出并重新启动 Poi。在插件设置中确认“`MCP 数据桥`”已启用,
|
|
31
|
+
然后进入游戏;需要读取账号数据的端点要等 Poi store 初始化完成。
|
|
32
|
+
|
|
33
|
+
如果 MCP 客户端要使用 stdio 方式,可另外全局安装同一个 npm 包,使
|
|
34
|
+
`poi-mcp` 命令进入 `PATH`:
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
npm install -g poi-plugin-mcp
|
|
38
|
+
Get-Command poi-mcp
|
|
39
|
+
```
|
|
23
40
|
|
|
24
|
-
##
|
|
41
|
+
## 健康检查与端口发现
|
|
25
42
|
|
|
26
|
-
|
|
43
|
+
默认端口的检查命令:
|
|
27
44
|
|
|
28
45
|
```powershell
|
|
29
|
-
|
|
30
|
-
npm run install:poi
|
|
46
|
+
Invoke-RestMethod http://127.0.0.1:17777/health
|
|
31
47
|
```
|
|
32
48
|
|
|
33
|
-
|
|
49
|
+
正常响应:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{"status":"ok"}
|
|
53
|
+
```
|
|
34
54
|
|
|
35
|
-
|
|
55
|
+
插件启动后会把实际端口写入:
|
|
36
56
|
|
|
37
|
-
```
|
|
38
|
-
%
|
|
57
|
+
```none
|
|
58
|
+
%USERPROFILE%\.poi-mcp\port
|
|
39
59
|
```
|
|
40
60
|
|
|
41
|
-
|
|
61
|
+
修改过端口时,应以该文件为准:
|
|
42
62
|
|
|
43
|
-
|
|
63
|
+
```powershell
|
|
64
|
+
$port = (Get-Content "$HOME\.poi-mcp\port" -Raw).Trim()
|
|
65
|
+
Invoke-RestMethod "http://127.0.0.1:$port/health"
|
|
66
|
+
```
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
- manually starting and stopping the local bridge
|
|
47
|
-
- enabling WebView input, which defaults to off
|
|
68
|
+
根路径 `/` 返回 404 不代表服务故障,请使用 `/health`。
|
|
48
69
|
|
|
49
|
-
|
|
50
|
-
`~/.poi-mcp/input-token` with restrictive best-effort file permissions. The
|
|
51
|
-
token is never returned by an HTTP endpoint.
|
|
70
|
+
## MCP 接入
|
|
52
71
|
|
|
53
|
-
|
|
72
|
+
插件提供两种 MCP 连接方式:
|
|
54
73
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
| `/basic` | Admiral profile |
|
|
59
|
-
| `/fleets` | Raw Poi fleet data |
|
|
60
|
-
| `/ships` | Raw owned ship instance data |
|
|
61
|
-
| `/equipment` | Raw owned equipment instance data |
|
|
62
|
-
| `/resources` | Resource array |
|
|
63
|
-
| `/quests` | Active quests and quest records |
|
|
64
|
-
| `/quest-list` | Latest complete quest-page telemetry |
|
|
65
|
-
| `/quest-action` | Latest successful quest start or stop request |
|
|
66
|
-
| `/equipment-action` | Latest successful equipment mutation request |
|
|
67
|
-
| `/airbase` | Land base air squadron data |
|
|
68
|
-
| `/names` | Ship, equipment, and mission name maps |
|
|
69
|
-
| `/master` | Master ship, equipment, ship type, equipment type, mission, and bounded equipment compatibility data |
|
|
70
|
-
| `/event` | Event ship tag definitions plus owned ships' current sally area |
|
|
71
|
-
| `/planner` | Ship Info deck planner areas and ship assignments |
|
|
72
|
-
| `/battle` | Observed battle packets, official settlement, and compact Prophet state |
|
|
73
|
-
| `/screenshot` | In-memory PNG capture of the game WebView |
|
|
74
|
-
| `/input/status` | Whether authenticated WebView input is enabled |
|
|
75
|
-
| `/input` | Authenticated, serialized WebView input |
|
|
76
|
-
| `/all` | Combined basic runtime data |
|
|
77
|
-
|
|
78
|
-
`/screenshot` accepts `GET` only. It uses Poi's existing
|
|
79
|
-
`screenshot::get` WebContents capture path and returns PNG base64 in JSON. It
|
|
80
|
-
does not save a file, write the clipboard, capture the desktop, or appear in
|
|
81
|
-
MCP resources and tools. Its response disables CORS and uses
|
|
82
|
-
`Cache-Control: no-store`.
|
|
83
|
-
|
|
84
|
-
### WebView Input
|
|
85
|
-
|
|
86
|
-
`GET /input/status` returns only `{"enabled":true|false}`. `POST /input`
|
|
87
|
-
requires `Authorization: Bearer <token>`, accepts at most 64 KiB of JSON, and
|
|
88
|
-
works only while WebView input is enabled in the plugin settings. Both routes
|
|
89
|
-
disable CORS and return `Cache-Control: no-store`.
|
|
90
|
-
|
|
91
|
-
Each POST accepts exactly one operation:
|
|
74
|
+
- **HTTP MCP**:客户端直接连接 Poi 内的 `POST /mcp`。
|
|
75
|
+
- **stdio MCP**:客户端启动 npm 安装的 `poi-mcp` 命令;该命令读取端口文件,
|
|
76
|
+
再访问 Poi 的本地数据 API。
|
|
92
77
|
|
|
93
|
-
|
|
94
|
-
|
|
78
|
+
两种方式都要求 Poi 正在运行且插件已加载。
|
|
79
|
+
|
|
80
|
+
### HTTP MCP
|
|
81
|
+
|
|
82
|
+
MCP URL:
|
|
83
|
+
|
|
84
|
+
```none
|
|
85
|
+
http://127.0.0.1:17777/mcp
|
|
95
86
|
```
|
|
96
87
|
|
|
97
|
-
|
|
98
|
-
|
|
88
|
+
这是 JSON-RPC over HTTP 端点,请由 MCP 客户端向它发送 `POST` 请求。Codex
|
|
89
|
+
配置示例:
|
|
99
90
|
|
|
100
|
-
```
|
|
101
|
-
|
|
91
|
+
```toml
|
|
92
|
+
[mcp_servers.poi]
|
|
93
|
+
url = "http://127.0.0.1:17777/mcp"
|
|
102
94
|
```
|
|
103
95
|
|
|
104
|
-
|
|
105
|
-
`End`, `Enter`, `Escape`, `Home`, `PageDown`, `PageUp`, `Space`, `Tab`, and the
|
|
106
|
-
four arrow keys.
|
|
96
|
+
使用 JSON 配置的 MCP 客户端可采用:
|
|
107
97
|
|
|
108
98
|
```json
|
|
109
|
-
{
|
|
99
|
+
{
|
|
100
|
+
"mcpServers": {
|
|
101
|
+
"poi": {
|
|
102
|
+
"url": "http://127.0.0.1:17777/mcp"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
110
106
|
```
|
|
111
107
|
|
|
112
|
-
|
|
113
|
-
return a bounded response such as
|
|
114
|
-
`{"ok":true,"operation":"text","sequence":1}`. Complete operations execute in
|
|
115
|
-
sequence, so concurrent requests cannot interleave their WebView events.
|
|
108
|
+
若修改过端口,请把 URL 中的 `17777` 替换为端口文件中的值。
|
|
116
109
|
|
|
117
|
-
|
|
110
|
+
### stdio CLI
|
|
118
111
|
|
|
119
|
-
|
|
112
|
+
`poi-mcp` 是 MCP stdio 服务命令,不是交互式终端程序。MCP 客户端应负责启动
|
|
113
|
+
并通过标准输入输出与它通信。
|
|
120
114
|
|
|
121
|
-
|
|
122
|
-
|
|
115
|
+
Codex 配置示例:
|
|
116
|
+
|
|
117
|
+
```toml
|
|
118
|
+
[mcp_servers.poi]
|
|
119
|
+
command = "poi-mcp"
|
|
120
|
+
args = []
|
|
123
121
|
```
|
|
124
122
|
|
|
125
|
-
|
|
126
|
-
`tools/list`, and `tools/call`. Resource URIs mirror the HTTP endpoints,
|
|
127
|
-
including `poi://ships`, `poi://equipment`, `poi://resources`, `poi://master`,
|
|
128
|
-
`poi://event`, and `poi://planner`.
|
|
123
|
+
通用 JSON 配置示例:
|
|
129
124
|
|
|
130
|
-
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"mcpServers": {
|
|
128
|
+
"poi": {
|
|
129
|
+
"command": "poi-mcp",
|
|
130
|
+
"args": []
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
131
135
|
|
|
132
|
-
-
|
|
133
|
-
|
|
134
|
-
- `search_equipment`
|
|
135
|
-
- `get_resources`
|
|
136
|
-
- `get_all`
|
|
136
|
+
如果桌面客户端找不到 npm 的全局命令,请先运行 `Get-Command poi-mcp`,再把
|
|
137
|
+
配置中的 `command` 改为返回的完整可执行文件路径。
|
|
137
138
|
|
|
138
|
-
##
|
|
139
|
+
## MCP 工具
|
|
139
140
|
|
|
140
|
-
|
|
141
|
+
| 工具 | 用途 |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `get_fleet_status` | 按 `fleetId`(1-4)读取一支舰队及舰娘、装备实例 |
|
|
144
|
+
| `search_ships` | 按可选的 `minLevel`、`maxLevel`、`minMorale` 筛选持有舰娘 |
|
|
145
|
+
| `search_equipment` | 按可选的 `minLevel` 筛选持有装备 |
|
|
146
|
+
| `get_resources` | 读取当前资源数组 |
|
|
147
|
+
| `get_all` | 读取基础汇总;`include` 可选 `master`、`event`、`planner` |
|
|
148
|
+
|
|
149
|
+
## MCP 资源
|
|
150
|
+
|
|
151
|
+
可通过 `resources/list` 和 `resources/read` 读取:
|
|
152
|
+
|
|
153
|
+
```none
|
|
154
|
+
poi://basic
|
|
155
|
+
poi://fleets
|
|
156
|
+
poi://ships
|
|
157
|
+
poi://equipment
|
|
158
|
+
poi://resources
|
|
159
|
+
poi://quests
|
|
160
|
+
poi://airbase
|
|
161
|
+
poi://names
|
|
162
|
+
poi://master
|
|
163
|
+
poi://event
|
|
164
|
+
poi://planner
|
|
165
|
+
poi://all
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## 数据端点
|
|
169
|
+
|
|
170
|
+
主要的本地 HTTP 数据查询端点:
|
|
171
|
+
|
|
172
|
+
| 端点 | 内容 |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `GET /health` | bridge 健康状态 |
|
|
175
|
+
| `GET /basic` | 提督基础信息 |
|
|
176
|
+
| `GET /fleets` | 舰队数据 |
|
|
177
|
+
| `GET /ships` | 持有舰娘实例 |
|
|
178
|
+
| `GET /equipment` | 持有装备实例 |
|
|
179
|
+
| `GET /resources` | 当前资源 |
|
|
180
|
+
| `GET /quests` | 当前任务与任务记录 |
|
|
181
|
+
| `GET /airbase` | 基地航空队 |
|
|
182
|
+
| `GET /names` | 舰娘、装备和远征名称映射 |
|
|
183
|
+
| `GET /master` | 舰娘、装备、类型、远征等主数据 |
|
|
184
|
+
| `GET /event` | 活动标签定义与持有舰娘出击标签 |
|
|
185
|
+
| `GET /planner` | Ship Info 配装规划数据 |
|
|
186
|
+
| `GET /battle` | 已观测战斗数据、结算与简化战斗状态 |
|
|
187
|
+
| `GET /all` | 基础运行数据汇总 |
|
|
188
|
+
| `POST /mcp` | HTTP MCP JSON-RPC 入口 |
|
|
189
|
+
|
|
190
|
+
## 开发验证
|
|
191
|
+
|
|
192
|
+
在源码目录执行:
|
|
141
193
|
|
|
142
194
|
```powershell
|
|
143
|
-
npm
|
|
195
|
+
npm install
|
|
196
|
+
npm test
|
|
197
|
+
npm pack --dry-run --ignore-scripts
|
|
144
198
|
```
|
|
199
|
+
|
|
200
|
+
`npm test` 应运行 `node --test test/*.test.js`;打包预览应包含
|
|
201
|
+
`index.js`、`lib`、`mcp-server.js` 和本 README。
|
package/index.js
CHANGED
|
@@ -7,7 +7,9 @@ const controller = createBridgeController({
|
|
|
7
7
|
getQuestList: telemetry.getQuestList,
|
|
8
8
|
getQuestAction: telemetry.getQuestAction,
|
|
9
9
|
getEquipmentAction: telemetry.getEquipmentAction,
|
|
10
|
+
getUnsetSlot: telemetry.getUnsetSlot,
|
|
10
11
|
getFleetAction: telemetry.getFleetAction,
|
|
12
|
+
getActionEvents: telemetry.getActionEvents,
|
|
11
13
|
getBattleTelemetry: telemetry.getBattleTelemetry,
|
|
12
14
|
})
|
|
13
15
|
const settingsClass = createSettingsClass(controller)
|
package/lib/bridge-controller.js
CHANGED
|
@@ -33,7 +33,9 @@ function createBridgeController(options = {}) {
|
|
|
33
33
|
getQuestList: options.getQuestList,
|
|
34
34
|
getQuestAction: options.getQuestAction,
|
|
35
35
|
getEquipmentAction: options.getEquipmentAction,
|
|
36
|
+
getUnsetSlot: options.getUnsetSlot,
|
|
36
37
|
getFleetAction: options.getFleetAction,
|
|
38
|
+
getActionEvents: options.getActionEvents,
|
|
37
39
|
getBattleTelemetry: options.getBattleTelemetry,
|
|
38
40
|
inputEnabled: settings.inputEnabled,
|
|
39
41
|
inputToken,
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CAPACITY = 256
|
|
4
|
+
const DEFAULT_LIMIT = 64
|
|
5
|
+
const MAX_CAPACITY = 256
|
|
6
|
+
const MAX_OBJECT_KEYS = 32
|
|
7
|
+
const MAX_ARRAY_ITEMS = 16
|
|
8
|
+
const MAX_STRING_LENGTH = 256
|
|
9
|
+
const MAX_RESPONSE_DEPTH = 4
|
|
10
|
+
|
|
11
|
+
const EXACT_PATHS = new Set([
|
|
12
|
+
'/kcsapi/api_start2/getData',
|
|
13
|
+
'/kcsapi/api_port/port',
|
|
14
|
+
'/kcsapi/api_get_member/base_air_corps',
|
|
15
|
+
'/kcsapi/api_get_member/deck',
|
|
16
|
+
'/kcsapi/api_get_member/kdock',
|
|
17
|
+
'/kcsapi/api_get_member/mapinfo',
|
|
18
|
+
'/kcsapi/api_get_member/material',
|
|
19
|
+
'/kcsapi/api_get_member/ndock',
|
|
20
|
+
'/kcsapi/api_get_member/practice',
|
|
21
|
+
'/kcsapi/api_get_member/questlist',
|
|
22
|
+
'/kcsapi/api_get_member/ship2',
|
|
23
|
+
'/kcsapi/api_get_member/ship3',
|
|
24
|
+
'/kcsapi/api_get_member/slot_item',
|
|
25
|
+
'/kcsapi/api_get_member/unsetslot',
|
|
26
|
+
'/kcsapi/api_get_member/useitem',
|
|
27
|
+
'/kcsapi/api_req_air_corps/set_action',
|
|
28
|
+
'/kcsapi/api_req_air_corps/set_plane',
|
|
29
|
+
'/kcsapi/api_req_air_corps/supply',
|
|
30
|
+
'/kcsapi/api_req_battle_midnight/battle',
|
|
31
|
+
'/kcsapi/api_req_battle_midnight/sp_midnight',
|
|
32
|
+
'/kcsapi/api_req_hensei/change',
|
|
33
|
+
'/kcsapi/api_req_hensei/combined',
|
|
34
|
+
'/kcsapi/api_req_hensei/preset_select',
|
|
35
|
+
'/kcsapi/api_req_hokyu/charge',
|
|
36
|
+
'/kcsapi/api_req_kaisou/powerup',
|
|
37
|
+
'/kcsapi/api_req_kaisou/slot_deprive',
|
|
38
|
+
'/kcsapi/api_req_kaisou/slotset',
|
|
39
|
+
'/kcsapi/api_req_kaisou/slotset_ex',
|
|
40
|
+
'/kcsapi/api_req_kaisou/unsetslot_all',
|
|
41
|
+
'/kcsapi/api_req_kousyou/createitem',
|
|
42
|
+
'/kcsapi/api_req_kousyou/createship',
|
|
43
|
+
'/kcsapi/api_req_kousyou/createship_speedchange',
|
|
44
|
+
'/kcsapi/api_req_kousyou/destroyitem2',
|
|
45
|
+
'/kcsapi/api_req_kousyou/destroyship',
|
|
46
|
+
'/kcsapi/api_req_kousyou/getship',
|
|
47
|
+
'/kcsapi/api_req_kousyou/remodel_slot',
|
|
48
|
+
'/kcsapi/api_req_map/next',
|
|
49
|
+
'/kcsapi/api_req_map/select_eventmap_rank',
|
|
50
|
+
'/kcsapi/api_req_map/start',
|
|
51
|
+
'/kcsapi/api_req_member/get_practice_enemyinfo',
|
|
52
|
+
'/kcsapi/api_req_mission/result',
|
|
53
|
+
'/kcsapi/api_req_mission/start',
|
|
54
|
+
'/kcsapi/api_req_nyukyo/speedchange',
|
|
55
|
+
'/kcsapi/api_req_nyukyo/start',
|
|
56
|
+
'/kcsapi/api_req_practice/battle',
|
|
57
|
+
'/kcsapi/api_req_practice/battle_result',
|
|
58
|
+
'/kcsapi/api_req_practice/midnight_battle',
|
|
59
|
+
'/kcsapi/api_req_quest/clearitemget',
|
|
60
|
+
'/kcsapi/api_req_quest/start',
|
|
61
|
+
'/kcsapi/api_req_quest/stop',
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
const ALLOWED_PREFIXES = [
|
|
65
|
+
'/kcsapi/api_req_sortie/',
|
|
66
|
+
'/kcsapi/api_req_combined_battle/',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const SHARED_TELEMETRY_TIMESTAMP_PATHS = new Set([
|
|
70
|
+
'/kcsapi/api_get_member/questlist',
|
|
71
|
+
'/kcsapi/api_get_member/unsetslot',
|
|
72
|
+
'/kcsapi/api_req_quest/start',
|
|
73
|
+
'/kcsapi/api_req_quest/stop',
|
|
74
|
+
'/kcsapi/api_req_kaisou/slotset',
|
|
75
|
+
'/kcsapi/api_req_kaisou/slotset_ex',
|
|
76
|
+
'/kcsapi/api_req_kaisou/slot_deprive',
|
|
77
|
+
'/kcsapi/api_req_kaisou/unsetslot_all',
|
|
78
|
+
'/kcsapi/api_req_hensei/change',
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
function createPoiActionEvents(options = {}) {
|
|
82
|
+
const now = options.now || (() => new Date())
|
|
83
|
+
const inferredNow = options.inferredNow || (() => new Date())
|
|
84
|
+
const sessionId = boundedId(options.sessionId || crypto.randomUUID(), 'sessionId')
|
|
85
|
+
const capacity = boundedInteger(
|
|
86
|
+
options.capacity == null ? DEFAULT_CAPACITY : options.capacity,
|
|
87
|
+
1,
|
|
88
|
+
MAX_CAPACITY,
|
|
89
|
+
'capacity',
|
|
90
|
+
)
|
|
91
|
+
const events = []
|
|
92
|
+
let latestGeneration = 0
|
|
93
|
+
|
|
94
|
+
function capture(detail) {
|
|
95
|
+
if (!isCapturableDetail(detail)) return null
|
|
96
|
+
const explicitApiResult = readApiResult(detail)
|
|
97
|
+
const apiResult = explicitApiResult == null && hasUnwrappedResponseBody(detail)
|
|
98
|
+
? 1
|
|
99
|
+
: explicitApiResult
|
|
100
|
+
if (apiResult !== 1) return null
|
|
101
|
+
|
|
102
|
+
latestGeneration += 1
|
|
103
|
+
const eventNow = explicitApiResult != null ||
|
|
104
|
+
SHARED_TELEMETRY_TIMESTAMP_PATHS.has(detail.path)
|
|
105
|
+
? now
|
|
106
|
+
: inferredNow
|
|
107
|
+
const event = Object.freeze({
|
|
108
|
+
generation: latestGeneration,
|
|
109
|
+
capturedAt: timestamp(eventNow()),
|
|
110
|
+
path: detail.path,
|
|
111
|
+
apiResult,
|
|
112
|
+
postBody: sanitizePostBody(detail.postBody),
|
|
113
|
+
responseSummary: sanitizeResponse(detail.body),
|
|
114
|
+
})
|
|
115
|
+
events.push(event)
|
|
116
|
+
if (events.length > capacity) events.splice(0, events.length - capacity)
|
|
117
|
+
return event
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function read(options = {}) {
|
|
121
|
+
const after = boundedInteger(
|
|
122
|
+
options.after == null ? 0 : options.after,
|
|
123
|
+
0,
|
|
124
|
+
Number.MAX_SAFE_INTEGER,
|
|
125
|
+
'after',
|
|
126
|
+
)
|
|
127
|
+
const limit = boundedInteger(
|
|
128
|
+
options.limit == null ? DEFAULT_LIMIT : options.limit,
|
|
129
|
+
1,
|
|
130
|
+
MAX_CAPACITY,
|
|
131
|
+
'limit',
|
|
132
|
+
)
|
|
133
|
+
return {
|
|
134
|
+
available: true,
|
|
135
|
+
sessionId,
|
|
136
|
+
latestGeneration,
|
|
137
|
+
events: events
|
|
138
|
+
.filter((event) => event.generation > after)
|
|
139
|
+
.slice(0, limit),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Object.freeze({ capture, read })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isCapturableDetail(detail) {
|
|
147
|
+
return Boolean(
|
|
148
|
+
detail &&
|
|
149
|
+
typeof detail === 'object' &&
|
|
150
|
+
!Array.isArray(detail) &&
|
|
151
|
+
typeof detail.path === 'string' &&
|
|
152
|
+
(
|
|
153
|
+
EXACT_PATHS.has(detail.path) ||
|
|
154
|
+
ALLOWED_PREFIXES.some((prefix) => detail.path.startsWith(prefix))
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readApiResult(detail) {
|
|
160
|
+
const candidates = [
|
|
161
|
+
detail.apiResult,
|
|
162
|
+
detail.api_result,
|
|
163
|
+
detail.result,
|
|
164
|
+
detail.body && detail.body.api_result,
|
|
165
|
+
]
|
|
166
|
+
for (const candidate of candidates) {
|
|
167
|
+
if (candidate == null || candidate === '') continue
|
|
168
|
+
const parsed = Number(candidate)
|
|
169
|
+
return Number.isInteger(parsed) ? parsed : null
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function hasUnwrappedResponseBody(detail) {
|
|
175
|
+
if (!Object.prototype.hasOwnProperty.call(detail, 'body')) return false
|
|
176
|
+
const body = detail.body
|
|
177
|
+
if (!isPlainObject(body) && !Array.isArray(body)) return false
|
|
178
|
+
return !(
|
|
179
|
+
isPlainObject(body) &&
|
|
180
|
+
(
|
|
181
|
+
Object.prototype.hasOwnProperty.call(body, 'api_result') ||
|
|
182
|
+
Object.prototype.hasOwnProperty.call(body, 'api_data')
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sanitizePostBody(value) {
|
|
188
|
+
if (!isPlainObject(value)) return {}
|
|
189
|
+
const output = {}
|
|
190
|
+
for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
|
|
191
|
+
if (!safeKey(key)) continue
|
|
192
|
+
const scalar = sanitizeScalar(item)
|
|
193
|
+
if (scalar !== undefined) {
|
|
194
|
+
output[key] = scalar
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
Array.isArray(item) &&
|
|
199
|
+
item.length <= MAX_ARRAY_ITEMS
|
|
200
|
+
) {
|
|
201
|
+
const array = item.map(sanitizeScalar)
|
|
202
|
+
if (array.every((entry) => entry !== undefined)) output[key] = array
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return output
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sanitizeResponse(value) {
|
|
209
|
+
const sanitized = sanitizeNested(value, 0)
|
|
210
|
+
return isPlainObject(sanitized) ? sanitized : {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function sanitizeNested(value, depth) {
|
|
214
|
+
const scalar = sanitizeScalar(value)
|
|
215
|
+
if (scalar !== undefined) return scalar
|
|
216
|
+
if (depth >= MAX_RESPONSE_DEPTH) return undefined
|
|
217
|
+
if (Array.isArray(value)) {
|
|
218
|
+
return value
|
|
219
|
+
.slice(0, MAX_ARRAY_ITEMS)
|
|
220
|
+
.map((item) => sanitizeNested(item, depth + 1))
|
|
221
|
+
.filter((item) => item !== undefined)
|
|
222
|
+
}
|
|
223
|
+
if (!isPlainObject(value)) return undefined
|
|
224
|
+
const output = {}
|
|
225
|
+
for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
|
|
226
|
+
if (!safeKey(key)) continue
|
|
227
|
+
const sanitized = sanitizeNested(item, depth + 1)
|
|
228
|
+
if (sanitized !== undefined) output[key] = sanitized
|
|
229
|
+
}
|
|
230
|
+
return output
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sanitizeScalar(value) {
|
|
234
|
+
if (
|
|
235
|
+
value === null ||
|
|
236
|
+
typeof value === 'boolean' ||
|
|
237
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
238
|
+
) {
|
|
239
|
+
return value
|
|
240
|
+
}
|
|
241
|
+
if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH)
|
|
242
|
+
return undefined
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function safeKey(key) {
|
|
246
|
+
return (
|
|
247
|
+
typeof key === 'string' &&
|
|
248
|
+
key.length > 0 &&
|
|
249
|
+
key.length <= 64 &&
|
|
250
|
+
!/(authorization|cookie|header|password|secret|token)/i.test(key)
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function timestamp(value) {
|
|
255
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
256
|
+
if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
|
|
257
|
+
return date.toISOString()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function boundedId(value, name) {
|
|
261
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
|
|
262
|
+
throw new Error(`${name} must be a non-empty bounded string`)
|
|
263
|
+
}
|
|
264
|
+
return value
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function boundedInteger(value, minimum, maximum, name) {
|
|
268
|
+
if (
|
|
269
|
+
!Number.isSafeInteger(value) ||
|
|
270
|
+
value < minimum ||
|
|
271
|
+
value > maximum
|
|
272
|
+
) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`${name} must be an integer from ${minimum} through ${maximum}`,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
return value
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isPlainObject(value) {
|
|
281
|
+
return Boolean(
|
|
282
|
+
value &&
|
|
283
|
+
typeof value === 'object' &&
|
|
284
|
+
!Array.isArray(value),
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
createPoiActionEvents,
|
|
290
|
+
}
|