@wenbin_wb/dsh-bridge 2.1.0 → 2.1.2
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 +2 -1
- package/README.zh-CN.md +3 -2
- package/lib/index.js +3 -0
- package/lib/qq/gateway.js +59 -4
- package/lib/qq/node.js +129 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public
|
|
|
18
18
|
- **Cloudflare Tunnel**: One-click public internet exposure, connect from anywhere without a public server of your own — keep working even when you're away from home
|
|
19
19
|
- **Custom Tunnel**: Connect to your own tunnel server with a fixed domain ([Setup Guide](docs/custom-tunnel.md))
|
|
20
20
|
- **WeChat Bot (ClawBot / iLink)**: Scan a QR code to log in a WeChat personal account, then chat with, control, and approve your DeepSeek Harness agents right inside WeChat. **Multi-workspace selection, restart-persistent sessions, grouped session listing with titles, media (image/file/voice) transfer, and permission approvals** — over Tencent's official iLink Bot API, no public server or tunnel required ([Usage Guide](docs/wechat-usage.md))
|
|
21
|
+
- **QQ Bot (OpenAPI v2)**: Connect your QQ Bot to receive private/group messages, send Markdown, button keyboards, and rich media. **Full event coverage (C2C / GROUP_AT_MESSAGE_CREATE), auto token refresh, reconnection with backoff, message deduplication** — over Tencent's official QQ Bot OpenAPI v2 ([Usage Guide](docs/qq-usage.md))
|
|
21
22
|
- **IM Integration (More Planned)**: WeChat / QQ / Feishu / OpenClaw direct chat integration
|
|
22
23
|
- **Security Alerts**: URLs and QR codes with access warnings to prevent accidental sharing
|
|
23
24
|
- **Auto Version Check**: Automatic update detection when entering the panel
|
|
@@ -35,7 +36,7 @@ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public
|
|
|
35
36
|
|--------|-------------|--------|
|
|
36
37
|
| **Platform Abstraction** | Platform-agnostic core (sessions / approvals / commands / digest) shared across IM channels | ✅ **Completed** (v2.0.0) |
|
|
37
38
|
| **WeChat** | Chat with your Agent directly in WeChat | ✅ Supported (workspaces / persisted sessions / media / approvals) |
|
|
38
|
-
| **QQ Bot** | QQ bot integration for group/private chat |
|
|
39
|
+
| **QQ Bot** | QQ bot integration for group/private chat | ✅ **Completed** (v2.1.0) — Markdown / buttons / rich media |
|
|
39
40
|
| **Feishu** | Feishu message/bot integration for workplace scenarios | Planned |
|
|
40
41
|
| **OpenClaw** | Integration with OpenClaw ecosystem | Planned |
|
|
41
42
|
| **Telegram** | Self-hosted IM channel | Planned |
|
package/README.zh-CN.md
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
- **Cloudflare 隧道**:一键暴露公网地址,随时随地连接,出差在外、不在家也能接着干,无需自建公网服务器
|
|
19
19
|
- **自建隧道**:连接自己的隧道服务器,获得固定域名([搭建教程](docs/custom-tunnel.md))
|
|
20
20
|
- **微信 Bot(ClawBot / iLink)**:扫码登录微信个人号后,直接在微信里对话、控制 DeepSeek Harness 的 agent。**支持多工作区选择、会话跨重启持久化、按工作区分组查看、媒体(图片/文件/语音)收发、权限审批**——走腾讯官方 iLink Bot API,无需公网([使用说明](docs/wechat-usage.md))
|
|
21
|
-
- **
|
|
21
|
+
- **QQ Bot(OpenAPI v2)**:接入 QQ 机器人,私聊/群聊接收消息,发送 Markdown、按钮键盘和富媒体。**完整事件覆盖(C2C / GROUP_AT_MESSAGE_CREATE)、Token 自动刷新、断线重连、消息去重**——走腾讯官方 QQ Bot OpenAPI v2([使用说明](docs/qq-usage.md))
|
|
22
|
+
- **IM 集成(更多平台规划中)**:飞书 / OpenClaw,直接在聊天软件里呼唤你的 Agent
|
|
22
23
|
- **安全提示**:URL 和二维码带访问警告,防止误分享
|
|
23
24
|
- **版本检查**:进入面板自动检测是否有新版本
|
|
24
25
|
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
|------|------|------|
|
|
36
37
|
| **平台抽象层** | 平台无关的核心(会话/审批/命令/digest)跨 IM 渠道复用 | ✅ **已完成**(v2.0.0) |
|
|
37
38
|
| **微信** | 在微信里直接与你的 Agent 对话 | ✅ 已支持(多工作区 / 会话持久化 / 媒体 / 审批) |
|
|
38
|
-
| **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent |
|
|
39
|
+
| **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent | ✅ **已完成**(v2.1.0)— Markdown / 按钮 / 富媒体 |
|
|
39
40
|
| **飞书** | 飞书消息/机器人集成,办公场景直接调用 | 规划中 |
|
|
40
41
|
| **OpenClaw** | 与 OpenClaw 生态打通 | 规划中 |
|
|
41
42
|
| **Telegram** | 适合自托管的 IM 渠道 | 规划中 |
|
package/lib/index.js
CHANGED
|
@@ -545,6 +545,9 @@ function apply(ctx, config = {}) {
|
|
|
545
545
|
accountId: cfg.accountId,
|
|
546
546
|
});
|
|
547
547
|
logger.info('dsh-bridge: loaded saved qq bot config, starting gateway');
|
|
548
|
+
await qq.start().catch((err) => {
|
|
549
|
+
logger.error('dsh-bridge: qq auto-start failed: %s', err?.message ?? err);
|
|
550
|
+
});
|
|
548
551
|
}
|
|
549
552
|
}
|
|
550
553
|
}).catch(() => {});
|
package/lib/qq/gateway.js
CHANGED
|
@@ -14,6 +14,7 @@ const RECONNECT_DELAY_MS = 3000
|
|
|
14
14
|
|
|
15
15
|
export const QQ_INTENTS = {
|
|
16
16
|
C2C_MESSAGE_CREATE: 1 << 25,
|
|
17
|
+
INTERACTION_CREATE: 1 << 26,
|
|
17
18
|
GROUP_AT_MESSAGE_CREATE: 1 << 30,
|
|
18
19
|
PUBLIC_GUILD_MESSAGES: 1 << 9,
|
|
19
20
|
DIRECT_MESSAGE: 1 << 12,
|
|
@@ -60,6 +61,7 @@ function normalizeEvent(payload) {
|
|
|
60
61
|
peerId: data.author?.user_openid || data.user_openid,
|
|
61
62
|
text: stringValue(data.content).trim(),
|
|
62
63
|
message: data,
|
|
64
|
+
messageReference: data.message_reference,
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
if (event === 'GROUP_AT_MESSAGE_CREATE') {
|
|
@@ -71,6 +73,7 @@ function normalizeEvent(payload) {
|
|
|
71
73
|
groupId: data.group_openid || data.group_id,
|
|
72
74
|
text: stringValue(data.content).trim(),
|
|
73
75
|
message: data,
|
|
76
|
+
messageReference: data.message_reference,
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
if (event === 'AT_MESSAGE_CREATE') {
|
|
@@ -82,6 +85,19 @@ function normalizeEvent(payload) {
|
|
|
82
85
|
guildId: data.guild_id,
|
|
83
86
|
text: stringValue(data.content).trim(),
|
|
84
87
|
message: data,
|
|
88
|
+
messageReference: data.message_reference,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (event === 'INTERACTION_CREATE') {
|
|
92
|
+
return {
|
|
93
|
+
type: 'interaction', scope: data.group_openid ? 'group' : 'c2c', event,
|
|
94
|
+
id: data.id,
|
|
95
|
+
interactionId: data.id,
|
|
96
|
+
senderId: data.group_member_openid || data.user_openid,
|
|
97
|
+
peerId: data.group_openid || data.user_openid,
|
|
98
|
+
groupId: data.group_openid,
|
|
99
|
+
data: data.data,
|
|
100
|
+
message: data,
|
|
85
101
|
}
|
|
86
102
|
}
|
|
87
103
|
return { type: 'event', event, message: data }
|
|
@@ -98,7 +114,7 @@ export class QqGateway extends Service {
|
|
|
98
114
|
accessToken: config.accessToken || '',
|
|
99
115
|
accessTokenExpiresAt: Number(config.accessTokenExpiresAt || 0),
|
|
100
116
|
gatewayUrl: config.gatewayUrl || '',
|
|
101
|
-
intents: Number(config.intents ?? (QQ_INTENTS.C2C_MESSAGE_CREATE | QQ_INTENTS.GROUP_AT_MESSAGE_CREATE)),
|
|
117
|
+
intents: Number(config.intents ?? (QQ_INTENTS.C2C_MESSAGE_CREATE | QQ_INTENTS.GROUP_AT_MESSAGE_CREATE | QQ_INTENTS.INTERACTION_CREATE)),
|
|
102
118
|
reconnectDelayMs: Number(config.reconnectDelayMs ?? RECONNECT_DELAY_MS),
|
|
103
119
|
apiTimeoutMs: Number(config.apiTimeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
104
120
|
}
|
|
@@ -253,6 +269,9 @@ export class QqGateway extends Service {
|
|
|
253
269
|
if (event.type === 'message' && event.id && !this.seen(event.id)) {
|
|
254
270
|
this.ctx.emit?.('qq/message', event)
|
|
255
271
|
}
|
|
272
|
+
if (event.type === 'interaction' && event.interactionId) {
|
|
273
|
+
this.ctx.emit?.('qq/interaction', event)
|
|
274
|
+
}
|
|
256
275
|
return
|
|
257
276
|
}
|
|
258
277
|
if (op === 7) { try { ws.close() } catch {}; return }
|
|
@@ -311,11 +330,11 @@ export class QqGateway extends Service {
|
|
|
311
330
|
})
|
|
312
331
|
}
|
|
313
332
|
|
|
314
|
-
async sendKeyboard(peerId, keyboard, opts = {}) {
|
|
333
|
+
async sendKeyboard(peerId, content, keyboard, opts = {}) {
|
|
315
334
|
return this.api(this.endpoint(peerId, opts.scope), {
|
|
316
335
|
method: 'POST',
|
|
317
336
|
body: {
|
|
318
|
-
content:
|
|
337
|
+
content: stringValue(content),
|
|
319
338
|
keyboard,
|
|
320
339
|
msg_type: 2,
|
|
321
340
|
msg_id: opts.msgId,
|
|
@@ -335,7 +354,43 @@ export class QqGateway extends Service {
|
|
|
335
354
|
})
|
|
336
355
|
}
|
|
337
356
|
|
|
338
|
-
async
|
|
357
|
+
async sendStream(peerId, content, opts = {}) {
|
|
358
|
+
const endpoint = this.endpoint(peerId, opts.scope, 'stream-messages')
|
|
359
|
+
return this.api(endpoint, {
|
|
360
|
+
method: 'POST',
|
|
361
|
+
body: {
|
|
362
|
+
content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
|
|
363
|
+
msg_type: 0,
|
|
364
|
+
msg_id: opts.msgId,
|
|
365
|
+
event_id: opts.eventId,
|
|
366
|
+
input_state: opts.inputState, // 0=结束输入, 1=输入中
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async respondInteraction(interactionId, response) {
|
|
372
|
+
return this.api(`/interactions/${encodeURIComponent(interactionId)}`, {
|
|
373
|
+
method: 'PUT',
|
|
374
|
+
body: response,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async sendTyping(peerId, opts = {}) {
|
|
379
|
+
// QQ Bot API v2 通过流式消息的 input_state 参数控制输入状态
|
|
380
|
+
// input_state: 0=结束输入, 1=输入中
|
|
381
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_stream_messages.post.html
|
|
382
|
+
const endpoint = this.endpoint(peerId, opts.scope, 'stream-messages')
|
|
383
|
+
return this.api(endpoint, {
|
|
384
|
+
method: 'POST',
|
|
385
|
+
body: {
|
|
386
|
+
content: '', // 仅设置输入状态,不发送内容
|
|
387
|
+
msg_type: 0,
|
|
388
|
+
input_state: 1, // 1=输入中
|
|
389
|
+
msg_id: opts.msgId,
|
|
390
|
+
event_id: opts.eventId,
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
}
|
|
339
394
|
|
|
340
395
|
setCredentials(values = {}) {
|
|
341
396
|
for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
|
package/lib/qq/node.js
CHANGED
|
@@ -19,7 +19,8 @@ function makePlatform(gateway) {
|
|
|
19
19
|
get accountId() { return gateway.accountId ?? '' },
|
|
20
20
|
get capabilities() { return gateway.capabilities },
|
|
21
21
|
sendText: (peer, text) => gateway.sendText(peer, text, {}),
|
|
22
|
-
sendTyping: (peer,
|
|
22
|
+
sendTyping: (peer, opts) => gateway.sendTyping(peer, opts),
|
|
23
|
+
sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -42,11 +43,94 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
42
43
|
onActiveSessionChange,
|
|
43
44
|
})
|
|
44
45
|
this.gateway = ctx.qq
|
|
46
|
+
this.lastMessageId = null // 存储最后发送的消息 ID,用于消息引用
|
|
45
47
|
|
|
46
48
|
// 订阅网关入站事件
|
|
47
49
|
this.ctx.on('qq/message', (event) => {
|
|
48
50
|
void this._handleInbound(event)
|
|
49
51
|
})
|
|
52
|
+
this.ctx.on('qq/interaction', (event) => {
|
|
53
|
+
void this._handleInteraction(event)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- 出站:覆盖 sendText,在提示消息中添加按钮 ----
|
|
58
|
+
|
|
59
|
+
async sendText(text) {
|
|
60
|
+
const peer = this.peerId
|
|
61
|
+
if (!peer) return
|
|
62
|
+
|
|
63
|
+
// 检测是否是提示用户开始新会话的消息
|
|
64
|
+
const isPromptMessage = text.includes('没有活动会话') || text.includes('恢复会话失败')
|
|
65
|
+
|
|
66
|
+
if (isPromptMessage) {
|
|
67
|
+
// 发送带按钮的消息,方便用户快速操作
|
|
68
|
+
const keyboard = {
|
|
69
|
+
rows: [
|
|
70
|
+
{
|
|
71
|
+
buttons: [
|
|
72
|
+
{ id: 'new_conversation', render_data: { label: '🆕 新建会话', visited_label: '新建会话' }, action: { type: 2, permission: { type: 2 } } },
|
|
73
|
+
{ id: 'list_sessions', render_data: { label: '📋 会话列表', visited_label: '会话列表' }, action: { type: 2, permission: { type: 2 } } },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
buttons: [
|
|
78
|
+
{ id: 'help', render_data: { label: '❓ 帮助', visited_label: '帮助' }, action: { type: 2, permission: { type: 2 } } },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
const result = await this.platform.sendKeyboard(peer, text, keyboard)
|
|
84
|
+
// 保存消息 ID
|
|
85
|
+
if (result?.data?.message_id) {
|
|
86
|
+
this.lastMessageId = result.data.message_id
|
|
87
|
+
}
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 其他消息使用流式发送
|
|
92
|
+
const content = String(text || '').trim()
|
|
93
|
+
if (content.length === 0) return { success: true }
|
|
94
|
+
|
|
95
|
+
const STREAM_CHUNK_SIZE = 200
|
|
96
|
+
|
|
97
|
+
// 如果消息较短,直接发送普通消息
|
|
98
|
+
if (content.length <= STREAM_CHUNK_SIZE) {
|
|
99
|
+
const result = await this.gateway.sendText(peer, content, {})
|
|
100
|
+
// 保存消息 ID
|
|
101
|
+
if (result?.data?.message_id) {
|
|
102
|
+
this.lastMessageId = result.data.message_id
|
|
103
|
+
}
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 流式发送:把内容切分成多段
|
|
108
|
+
const chunks = []
|
|
109
|
+
for (let i = 0; i < content.length; i += STREAM_CHUNK_SIZE) {
|
|
110
|
+
chunks.push(content.slice(i, i + STREAM_CHUNK_SIZE))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 逐段发送流式消息
|
|
114
|
+
let firstMsgId = null
|
|
115
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
116
|
+
const chunk = chunks[i]
|
|
117
|
+
const isLast = i === chunks.length - 1
|
|
118
|
+
const result = await this.gateway.sendStream(peer, chunk, {
|
|
119
|
+
msgId: firstMsgId, // 后续段关联到第一段
|
|
120
|
+
inputState: isLast ? 0 : 1, // 最后一段结束输入状态,其他段显示输入中
|
|
121
|
+
})
|
|
122
|
+
if (!firstMsgId && result?.data?.message_id) {
|
|
123
|
+
firstMsgId = result.data.message_id
|
|
124
|
+
this.lastMessageId = firstMsgId // 保存第一段消息 ID
|
|
125
|
+
}
|
|
126
|
+
if (result?.error) {
|
|
127
|
+
return { success: false, error: result.error }
|
|
128
|
+
}
|
|
129
|
+
// 流式发送间隔稍短,避免刷屏
|
|
130
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { success: true }
|
|
50
134
|
}
|
|
51
135
|
|
|
52
136
|
// ---- 入站 ----
|
|
@@ -70,9 +154,53 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
70
154
|
// 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
|
|
71
155
|
const isGroup = event.scope === 'group' || event.scope === 'guild'
|
|
72
156
|
|
|
157
|
+
// 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
|
|
158
|
+
// 则自动创建新会话并发送消息
|
|
159
|
+
const messageReference = event.messageReference
|
|
160
|
+
if (messageReference && !this.activeSessionId && !text.startsWith('/')) {
|
|
161
|
+
this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
|
|
162
|
+
// 先创建新会话,再处理消息
|
|
163
|
+
await this.handleInbound({ senderId: sender, text: '/new', isGroup })
|
|
164
|
+
// 等待一小段时间确保会话创建完成
|
|
165
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
166
|
+
}
|
|
167
|
+
|
|
73
168
|
// 交给平台无关核心:白名单/群消息/命令路由/agent 分发
|
|
74
169
|
await this.handleInbound({ senderId: sender, text, isGroup })
|
|
75
170
|
}
|
|
171
|
+
|
|
172
|
+
// ---- 互动事件 ----
|
|
173
|
+
|
|
174
|
+
async _handleInteraction(event) {
|
|
175
|
+
const sender = String(event.senderId ?? '').trim()
|
|
176
|
+
if (!sender) return
|
|
177
|
+
|
|
178
|
+
// 快速预检查:白名单非空时,未授权发件人直接忽略
|
|
179
|
+
if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
|
|
180
|
+
this.logger?.info?.(`[dsh-bridge qq] ignore interaction from non-allowlisted sender ${sender}`)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const data = event.data || {}
|
|
185
|
+
const resolved = data.resolved || {}
|
|
186
|
+
const buttonId = resolved.button_id || ''
|
|
187
|
+
|
|
188
|
+
// 响应互动
|
|
189
|
+
await this.gateway.respondInteraction(event.interactionId, {
|
|
190
|
+
code: 0,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// 根据按钮 ID 执行对应操作
|
|
194
|
+
if (buttonId === 'new_conversation') {
|
|
195
|
+
await this.handleInbound({ senderId: sender, text: '/new', isGroup: event.scope === 'group' })
|
|
196
|
+
} else if (buttonId === 'list_sessions') {
|
|
197
|
+
await this.handleInbound({ senderId: sender, text: '/list', isGroup: event.scope === 'group' })
|
|
198
|
+
} else if (buttonId === 'help') {
|
|
199
|
+
await this.handleInbound({ senderId: sender, text: '/help', isGroup: event.scope === 'group' })
|
|
200
|
+
} else {
|
|
201
|
+
this.logger?.info?.(`[dsh-bridge qq] unknown button interaction: ${buttonId}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
76
204
|
}
|
|
77
205
|
|
|
78
206
|
// 导出,便于测试与复用
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"docs/custom-tunnel.md",
|
|
23
23
|
"docs/wechat-usage.md"
|
|
24
24
|
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build:client": "node client/build.mjs",
|
|
27
|
+
"test": "node --test test/*.test.mjs"
|
|
28
|
+
},
|
|
25
29
|
"dependencies": {
|
|
26
30
|
"qrcode": "^1.5.3",
|
|
27
31
|
"ws": "^8.18.0",
|
|
@@ -81,9 +85,5 @@
|
|
|
81
85
|
"publishConfig": {
|
|
82
86
|
"access": "public",
|
|
83
87
|
"registry": "https://registry.npmjs.org/"
|
|
84
|
-
},
|
|
85
|
-
"scripts": {
|
|
86
|
-
"build:client": "node client/build.mjs",
|
|
87
|
-
"test": "node --test test/*.test.mjs"
|
|
88
88
|
}
|
|
89
|
-
}
|
|
89
|
+
}
|