@yeaft/webchat-agent 0.0.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/claude.js +405 -0
- package/cli.js +151 -0
- package/connection.js +391 -0
- package/context.js +26 -0
- package/conversation.js +452 -0
- package/encryption.js +105 -0
- package/history.js +283 -0
- package/index.js +159 -0
- package/package.json +75 -0
- package/proxy.js +169 -0
- package/sdk/index.js +9 -0
- package/sdk/query.js +396 -0
- package/sdk/stream.js +112 -0
- package/sdk/types.js +13 -0
- package/sdk/utils.js +194 -0
- package/service.js +587 -0
- package/terminal.js +176 -0
- package/workbench.js +907 -0
package/conversation.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import ctx from './context.js';
|
|
2
|
+
import { loadSessionHistory } from './history.js';
|
|
3
|
+
import { startClaudeQuery } from './claude.js';
|
|
4
|
+
|
|
5
|
+
// 不支持的斜杠命令(真正需要交互式 CLI 的命令)
|
|
6
|
+
const UNSUPPORTED_SLASH_COMMANDS = ['/help', '/bug', '/login', '/logout', '/terminal-setup', '/vim', '/config'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 解析斜杠命令
|
|
10
|
+
* @param {string} message - 用户消息
|
|
11
|
+
* @returns {{type: string|null, command?: string, message: string, passthrough?: boolean}}
|
|
12
|
+
*/
|
|
13
|
+
export function parseSlashCommand(message) {
|
|
14
|
+
const trimmed = message.trim();
|
|
15
|
+
|
|
16
|
+
// 检查是否是不支持的斜杠命令
|
|
17
|
+
for (const cmd of UNSUPPORTED_SLASH_COMMANDS) {
|
|
18
|
+
if (trimmed === cmd || trimmed.startsWith(cmd + ' ')) {
|
|
19
|
+
return { type: 'unsupported', command: cmd, message: trimmed };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 其他所有 / 开头的命令都传递给 Claude 处理
|
|
24
|
+
// 包括 /compact, /init, /doctor, /memory, /model, /review, /mcp, /cost, /context, /skills
|
|
25
|
+
// 以及用户定义的自定义 skills 如 /commit, /pr 等
|
|
26
|
+
if (trimmed.startsWith('/') && trimmed.length > 1) {
|
|
27
|
+
const match = trimmed.match(/^(\/[a-zA-Z0-9_-]+)/);
|
|
28
|
+
if (match) {
|
|
29
|
+
return { type: 'skill', command: match[1], message: trimmed };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { type: null, message };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 发送 conversation 列表
|
|
37
|
+
export function sendConversationList() {
|
|
38
|
+
const list = [];
|
|
39
|
+
for (const [id, state] of ctx.conversations) {
|
|
40
|
+
list.push({
|
|
41
|
+
id,
|
|
42
|
+
workDir: state.workDir,
|
|
43
|
+
claudeSessionId: state.claudeSessionId,
|
|
44
|
+
createdAt: state.createdAt,
|
|
45
|
+
processing: !!state.turnActive,
|
|
46
|
+
userId: state.userId,
|
|
47
|
+
username: state.username
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ctx.sendToServer({
|
|
52
|
+
type: 'conversation_list',
|
|
53
|
+
conversations: list
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sendOutput(conversationId, data) {
|
|
58
|
+
ctx.sendToServer({
|
|
59
|
+
type: 'claude_output',
|
|
60
|
+
conversationId,
|
|
61
|
+
data
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sendError(conversationId, message) {
|
|
66
|
+
ctx.sendToServer({
|
|
67
|
+
type: 'error',
|
|
68
|
+
conversationId,
|
|
69
|
+
message
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 创建新的 conversation (延迟启动 Claude,等待用户发送第一条消息)
|
|
74
|
+
export async function createConversation(msg) {
|
|
75
|
+
const { conversationId, workDir, userId, username, disallowedTools } = msg;
|
|
76
|
+
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
77
|
+
|
|
78
|
+
console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
|
|
79
|
+
if (username) console.log(` User: ${username} (${userId})`);
|
|
80
|
+
|
|
81
|
+
// 只创建 conversation 状态,不启动 Claude 进程
|
|
82
|
+
// Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
|
|
83
|
+
ctx.conversations.set(conversationId, {
|
|
84
|
+
query: null,
|
|
85
|
+
inputStream: null,
|
|
86
|
+
workDir: effectiveWorkDir,
|
|
87
|
+
claudeSessionId: null,
|
|
88
|
+
createdAt: Date.now(),
|
|
89
|
+
abortController: null,
|
|
90
|
+
tools: [],
|
|
91
|
+
slashCommands: [],
|
|
92
|
+
model: null,
|
|
93
|
+
userId,
|
|
94
|
+
username,
|
|
95
|
+
disallowedTools: disallowedTools || null, // null = 使用全局默认
|
|
96
|
+
usage: {
|
|
97
|
+
inputTokens: 0,
|
|
98
|
+
outputTokens: 0,
|
|
99
|
+
cacheRead: 0,
|
|
100
|
+
cacheCreation: 0,
|
|
101
|
+
totalCostUsd: 0
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ctx.sendToServer({
|
|
106
|
+
type: 'conversation_created',
|
|
107
|
+
conversationId,
|
|
108
|
+
workDir: effectiveWorkDir,
|
|
109
|
+
userId,
|
|
110
|
+
username,
|
|
111
|
+
disallowedTools: disallowedTools || null
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
sendConversationList();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Resume 历史 conversation (延迟启动 Claude,等待用户发送第一条消息)
|
|
118
|
+
export async function resumeConversation(msg) {
|
|
119
|
+
const { conversationId, claudeSessionId, workDir, userId, username, disallowedTools } = msg;
|
|
120
|
+
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
121
|
+
|
|
122
|
+
console.log(`[Resume] conversationId: ${conversationId}`);
|
|
123
|
+
console.log(`[Resume] claudeSessionId: ${claudeSessionId}`);
|
|
124
|
+
console.log(`[Resume] workDir: ${effectiveWorkDir} (lazy start)`);
|
|
125
|
+
|
|
126
|
+
// 清理旧条目:同 conversationId 或同 claudeSessionId 的条目(避免重复恢复同一个 session 累积)
|
|
127
|
+
for (const [id, conv] of ctx.conversations) {
|
|
128
|
+
if (id === conversationId || (claudeSessionId && conv.claudeSessionId === claudeSessionId)) {
|
|
129
|
+
console.log(`[Resume] Cleaning up old conversation: ${id} (claudeSessionId: ${conv.claudeSessionId})`);
|
|
130
|
+
if (conv.abortController) {
|
|
131
|
+
conv.abortController.abort();
|
|
132
|
+
}
|
|
133
|
+
if (conv.inputStream) {
|
|
134
|
+
try { conv.inputStream.done(); } catch {}
|
|
135
|
+
}
|
|
136
|
+
ctx.conversations.delete(id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const historyMessages = loadSessionHistory(effectiveWorkDir, claudeSessionId);
|
|
141
|
+
if (username) console.log(`[Resume] User: ${username} (${userId})`);
|
|
142
|
+
console.log(`Loaded ${historyMessages.length} history messages`);
|
|
143
|
+
|
|
144
|
+
// 只创建 conversation 状态并保存 claudeSessionId,不启动 Claude 进程
|
|
145
|
+
// Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
|
|
146
|
+
ctx.conversations.set(conversationId, {
|
|
147
|
+
query: null,
|
|
148
|
+
inputStream: null,
|
|
149
|
+
workDir: effectiveWorkDir,
|
|
150
|
+
claudeSessionId: claudeSessionId, // 保存要恢复的 session ID
|
|
151
|
+
createdAt: Date.now(),
|
|
152
|
+
abortController: null,
|
|
153
|
+
tools: [],
|
|
154
|
+
slashCommands: [],
|
|
155
|
+
model: null,
|
|
156
|
+
userId,
|
|
157
|
+
username,
|
|
158
|
+
disallowedTools: disallowedTools || null, // null = 使用全局默认
|
|
159
|
+
usage: {
|
|
160
|
+
inputTokens: 0,
|
|
161
|
+
outputTokens: 0,
|
|
162
|
+
cacheRead: 0,
|
|
163
|
+
cacheCreation: 0,
|
|
164
|
+
totalCostUsd: 0
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
ctx.sendToServer({
|
|
169
|
+
type: 'conversation_resumed',
|
|
170
|
+
conversationId,
|
|
171
|
+
claudeSessionId,
|
|
172
|
+
workDir: effectiveWorkDir,
|
|
173
|
+
historyMessages,
|
|
174
|
+
userId,
|
|
175
|
+
username
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
sendConversationList();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 删除 conversation
|
|
182
|
+
export function deleteConversation(msg) {
|
|
183
|
+
const { conversationId } = msg;
|
|
184
|
+
|
|
185
|
+
console.log(`Deleting conversation: ${conversationId}`);
|
|
186
|
+
|
|
187
|
+
// 清理关联的所有终端(一个 conversation 可能有多个分屏终端)
|
|
188
|
+
for (const [terminalId, term] of ctx.terminals.entries()) {
|
|
189
|
+
if (term.conversationId === conversationId || terminalId === conversationId) {
|
|
190
|
+
if (term.pty) {
|
|
191
|
+
try { term.pty.kill(); } catch {}
|
|
192
|
+
}
|
|
193
|
+
if (term.timer) clearTimeout(term.timer);
|
|
194
|
+
ctx.terminals.delete(terminalId);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const conv = ctx.conversations.get(conversationId);
|
|
199
|
+
if (conv) {
|
|
200
|
+
if (conv.abortController) {
|
|
201
|
+
conv.abortController.abort();
|
|
202
|
+
}
|
|
203
|
+
if (conv.inputStream) {
|
|
204
|
+
conv.inputStream.done();
|
|
205
|
+
}
|
|
206
|
+
ctx.conversations.delete(conversationId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ctx.sendToServer({
|
|
210
|
+
type: 'conversation_deleted',
|
|
211
|
+
conversationId
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
sendConversationList();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 刷新会话状态 - 发送当前会话的处理状态
|
|
218
|
+
export async function handleRefreshConversation(msg) {
|
|
219
|
+
const { conversationId } = msg;
|
|
220
|
+
const conv = ctx.conversations.get(conversationId);
|
|
221
|
+
|
|
222
|
+
if (!conv) {
|
|
223
|
+
ctx.sendToServer({
|
|
224
|
+
type: 'conversation_refresh',
|
|
225
|
+
conversationId,
|
|
226
|
+
error: 'Conversation not found'
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 检查是否有 turn 正在处理(不是 query 是否存在,因为持久模式下 query 一直存在)
|
|
232
|
+
const isRunning = !!conv.turnActive;
|
|
233
|
+
|
|
234
|
+
ctx.sendToServer({
|
|
235
|
+
type: 'conversation_refresh',
|
|
236
|
+
conversationId,
|
|
237
|
+
isProcessing: isRunning,
|
|
238
|
+
workDir: conv.workDir,
|
|
239
|
+
claudeSessionId: conv.claudeSessionId
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 取消当前执行
|
|
244
|
+
export async function handleCancelExecution(msg) {
|
|
245
|
+
const { conversationId } = msg;
|
|
246
|
+
|
|
247
|
+
console.log(`[${conversationId}] Cancelling execution`);
|
|
248
|
+
|
|
249
|
+
const state = ctx.conversations.get(conversationId);
|
|
250
|
+
if (!state) {
|
|
251
|
+
console.log(`[${conversationId}] No active conversation found`);
|
|
252
|
+
ctx.sendToServer({
|
|
253
|
+
type: 'execution_cancelled',
|
|
254
|
+
conversationId
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 保存当前会话 ID,以便后续可以恢复
|
|
260
|
+
const claudeSessionId = state.claudeSessionId;
|
|
261
|
+
const workDir = state.workDir;
|
|
262
|
+
|
|
263
|
+
// 标记为取消状态,防止 processClaudeOutput 的 finally 发送 conversation_closed
|
|
264
|
+
state.cancelled = true;
|
|
265
|
+
|
|
266
|
+
// 中止当前查询
|
|
267
|
+
if (state.abortController) {
|
|
268
|
+
state.abortController.abort();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 关闭输入流
|
|
272
|
+
if (state.inputStream) {
|
|
273
|
+
state.inputStream.done();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 清理当前查询状态,但保留会话信息
|
|
277
|
+
state.query = null;
|
|
278
|
+
state.inputStream = null;
|
|
279
|
+
state.abortController = null;
|
|
280
|
+
state.turnActive = false;
|
|
281
|
+
|
|
282
|
+
console.log(`[${conversationId}] Execution cancelled, session: ${claudeSessionId}`);
|
|
283
|
+
|
|
284
|
+
// 通知客户端取消完成
|
|
285
|
+
ctx.sendToServer({
|
|
286
|
+
type: 'execution_cancelled',
|
|
287
|
+
conversationId,
|
|
288
|
+
claudeSessionId
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
sendConversationList();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 清空排队消息 — 已移至 server 端管理 (Phase 3.6)
|
|
295
|
+
// handleClearQueue 和 handleCancelQueuedMessage 不再需要
|
|
296
|
+
|
|
297
|
+
// 处理用户输入
|
|
298
|
+
export async function handleUserInput(msg) {
|
|
299
|
+
const { conversationId, prompt, workDir, claudeSessionId } = msg;
|
|
300
|
+
|
|
301
|
+
// 解析斜杠命令
|
|
302
|
+
const slashCommand = parseSlashCommand(prompt);
|
|
303
|
+
|
|
304
|
+
// 处理不支持的斜杠命令
|
|
305
|
+
if (slashCommand.type === 'unsupported') {
|
|
306
|
+
console.log(`[${conversationId}] Unsupported slash command: ${slashCommand.command}`);
|
|
307
|
+
|
|
308
|
+
sendOutput(conversationId, {
|
|
309
|
+
type: 'assistant',
|
|
310
|
+
message: {
|
|
311
|
+
role: 'assistant',
|
|
312
|
+
content: [{
|
|
313
|
+
type: 'text',
|
|
314
|
+
text: `命令 \`${slashCommand.command}\` 在远程模式下不可用(需要交互式终端)。\n\n` +
|
|
315
|
+
`**支持的命令:**\n` +
|
|
316
|
+
`- \`/clear\` - 清除当前会话上下文\n` +
|
|
317
|
+
`- \`/compact\` - 压缩会话上下文\n` +
|
|
318
|
+
`- \`/context\` - 显示上下文使用情况\n` +
|
|
319
|
+
`- \`/cost\` - 显示花费信息\n` +
|
|
320
|
+
`- \`/init\` - 初始化项目 CLAUDE.md\n` +
|
|
321
|
+
`- \`/doctor\` - 运行诊断检查\n` +
|
|
322
|
+
`- \`/memory\` - 管理记忆\n` +
|
|
323
|
+
`- \`/model\` - 查看/切换模型\n` +
|
|
324
|
+
`- \`/review\` - 代码审查\n` +
|
|
325
|
+
`- \`/mcp\` - MCP 服务器管理\n` +
|
|
326
|
+
`- \`/<skill-name>\` - 自定义技能(如 /commit, /pr 等)`
|
|
327
|
+
}]
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
// 通知前端清除 processing 状态(因为不会启动 Claude 查询,没有 result 消息)
|
|
331
|
+
const existingState = ctx.conversations.get(conversationId);
|
|
332
|
+
ctx.sendToServer({
|
|
333
|
+
type: 'turn_completed',
|
|
334
|
+
conversationId,
|
|
335
|
+
claudeSessionId: existingState?.claudeSessionId,
|
|
336
|
+
workDir: existingState?.workDir || ctx.CONFIG.workDir
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let state = ctx.conversations.get(conversationId);
|
|
342
|
+
|
|
343
|
+
// ★ Phase 3.6: 排队逻辑已移至 server 端,agent 不再本地排队
|
|
344
|
+
// Server 保证 conversation busy 时不会发新的 execute 过来
|
|
345
|
+
|
|
346
|
+
// 如果没有活跃的查询,启动新的
|
|
347
|
+
if (!state || !state.query || !state.inputStream) {
|
|
348
|
+
const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
|
|
349
|
+
const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
|
|
350
|
+
|
|
351
|
+
console.log(`[SDK] Starting Claude for ${conversationId}, resume: ${resumeSessionId || 'none'}`);
|
|
352
|
+
state = await startClaudeQuery(conversationId, effectiveWorkDir, resumeSessionId);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 发送用户消息到输入流
|
|
356
|
+
const userMessage = {
|
|
357
|
+
type: 'user',
|
|
358
|
+
message: { role: 'user', content: prompt }
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
console.log(`[${conversationId}] Sending: ${prompt.substring(0, 100)}...`);
|
|
362
|
+
state.turnActive = true;
|
|
363
|
+
sendConversationList(); // 在 turnActive=true 后通知 server,确保 processing 状态正确
|
|
364
|
+
state.inputStream.enqueue(userMessage);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 更新会话设置(如 disallowedTools)
|
|
368
|
+
export function handleUpdateConversationSettings(msg) {
|
|
369
|
+
const { conversationId } = msg;
|
|
370
|
+
const conv = ctx.conversations.get(conversationId);
|
|
371
|
+
if (!conv) {
|
|
372
|
+
console.log(`[Settings] Conversation not found: ${conversationId}`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (msg.disallowedTools !== undefined) {
|
|
377
|
+
conv.disallowedTools = msg.disallowedTools;
|
|
378
|
+
console.log(`[Settings] ${conversationId} disallowedTools updated:`, msg.disallowedTools);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
ctx.sendToServer({
|
|
382
|
+
type: 'conversation_settings_updated',
|
|
383
|
+
conversationId,
|
|
384
|
+
disallowedTools: conv.disallowedTools,
|
|
385
|
+
needRestart: !!conv.query // Claude 进程已启动则需要重启才能生效
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// AskUserQuestion 交互式问答
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 处理 AskUserQuestion 工具调用 — 转发到 Web UI 等待用户回答
|
|
393
|
+
*/
|
|
394
|
+
export function handleAskUserQuestion(conversationId, input, toolCtx) {
|
|
395
|
+
return new Promise((resolve, reject) => {
|
|
396
|
+
const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
397
|
+
|
|
398
|
+
console.log(`[AskUser] ${conversationId} requesting user input, requestId: ${requestId}`);
|
|
399
|
+
|
|
400
|
+
// 发送到 Web UI
|
|
401
|
+
ctx.sendToServer({
|
|
402
|
+
type: 'ask_user_question',
|
|
403
|
+
conversationId,
|
|
404
|
+
requestId,
|
|
405
|
+
questions: input.questions || []
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// 设置超时(5 分钟)
|
|
409
|
+
const timeout = setTimeout(() => {
|
|
410
|
+
console.log(`[AskUser] ${requestId} timed out, auto-resolving with empty answers`);
|
|
411
|
+
ctx.pendingUserQuestions.delete(requestId);
|
|
412
|
+
resolve({ behavior: 'allow', updatedInput: { questions: input.questions, answers: {} } });
|
|
413
|
+
}, 5 * 60 * 1000);
|
|
414
|
+
|
|
415
|
+
ctx.pendingUserQuestions.set(requestId, {
|
|
416
|
+
resolve,
|
|
417
|
+
timeout,
|
|
418
|
+
conversationId,
|
|
419
|
+
input
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// 监听 abort signal
|
|
423
|
+
if (toolCtx?.signal) {
|
|
424
|
+
toolCtx.signal.addEventListener('abort', () => {
|
|
425
|
+
clearTimeout(timeout);
|
|
426
|
+
ctx.pendingUserQuestions.delete(requestId);
|
|
427
|
+
reject(new Error('aborted'));
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 处理 Web UI 的 AskUserQuestion 回答
|
|
435
|
+
*/
|
|
436
|
+
export function handleAskUserAnswer(msg) {
|
|
437
|
+
const pending = ctx.pendingUserQuestions.get(msg.requestId);
|
|
438
|
+
if (pending) {
|
|
439
|
+
console.log(`[AskUser] Received answer for ${msg.requestId}`);
|
|
440
|
+
clearTimeout(pending.timeout);
|
|
441
|
+
ctx.pendingUserQuestions.delete(msg.requestId);
|
|
442
|
+
pending.resolve({
|
|
443
|
+
behavior: 'allow',
|
|
444
|
+
updatedInput: {
|
|
445
|
+
questions: pending.input.questions,
|
|
446
|
+
answers: msg.answers || {}
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
} else {
|
|
450
|
+
console.log(`[AskUser] No pending question for requestId: ${msg.requestId}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
package/encryption.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import tweetnacl from 'tweetnacl';
|
|
2
|
+
import tweetnaclUtil from 'tweetnacl-util';
|
|
3
|
+
import zlib from 'zlib';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
|
|
6
|
+
const gzip = promisify(zlib.gzip);
|
|
7
|
+
const gunzip = promisify(zlib.gunzip);
|
|
8
|
+
|
|
9
|
+
const { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } = tweetnaclUtil;
|
|
10
|
+
|
|
11
|
+
// Compression threshold: only compress if data > 512 bytes
|
|
12
|
+
const COMPRESS_THRESHOLD = 512;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a new 32-byte session key
|
|
16
|
+
* @returns {Uint8Array} 32-byte random key
|
|
17
|
+
*/
|
|
18
|
+
export function generateSessionKey() {
|
|
19
|
+
return tweetnacl.randomBytes(32);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Encrypt data using TweetNaCl secretbox (XSalsa20-Poly1305)
|
|
24
|
+
* Compresses data with gzip before encryption if > threshold
|
|
25
|
+
* @param {any} data - Data to encrypt (will be JSON stringified)
|
|
26
|
+
* @param {Uint8Array} key - 32-byte encryption key
|
|
27
|
+
* @returns {Promise<{n: string, c: string, z?: boolean}>} Object with base64 nonce, ciphertext, and optional compressed flag
|
|
28
|
+
*/
|
|
29
|
+
export async function encrypt(data, key) {
|
|
30
|
+
const nonce = tweetnacl.randomBytes(24);
|
|
31
|
+
const jsonStr = JSON.stringify(data);
|
|
32
|
+
|
|
33
|
+
let message;
|
|
34
|
+
let compressed = false;
|
|
35
|
+
|
|
36
|
+
if (jsonStr.length > COMPRESS_THRESHOLD) {
|
|
37
|
+
// Compress before encryption
|
|
38
|
+
const compressedBuf = await gzip(Buffer.from(jsonStr, 'utf8'));
|
|
39
|
+
message = new Uint8Array(compressedBuf);
|
|
40
|
+
compressed = true;
|
|
41
|
+
} else {
|
|
42
|
+
message = decodeUTF8(jsonStr);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const encrypted = tweetnacl.secretbox(message, nonce, key);
|
|
46
|
+
const result = {
|
|
47
|
+
n: encodeBase64(nonce),
|
|
48
|
+
c: encodeBase64(encrypted)
|
|
49
|
+
};
|
|
50
|
+
if (compressed) result.z = true;
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decrypt data using TweetNaCl secretbox
|
|
56
|
+
* Decompresses data with gunzip after decryption if compressed
|
|
57
|
+
* @param {{n: string, c: string, z?: boolean}} encrypted - Object with base64 nonce, ciphertext, and optional compressed flag
|
|
58
|
+
* @param {Uint8Array} key - 32-byte encryption key
|
|
59
|
+
* @returns {Promise<any|null>} Decrypted and parsed data, or null if decryption fails
|
|
60
|
+
*/
|
|
61
|
+
export async function decrypt(encrypted, key) {
|
|
62
|
+
try {
|
|
63
|
+
const nonce = decodeBase64(encrypted.n);
|
|
64
|
+
const ciphertext = decodeBase64(encrypted.c);
|
|
65
|
+
const decrypted = tweetnacl.secretbox.open(ciphertext, nonce, key);
|
|
66
|
+
if (!decrypted) return null;
|
|
67
|
+
|
|
68
|
+
if (encrypted.z) {
|
|
69
|
+
// Decompress after decryption
|
|
70
|
+
const decompressed = await gunzip(Buffer.from(decrypted));
|
|
71
|
+
return JSON.parse(decompressed.toString('utf8'));
|
|
72
|
+
} else {
|
|
73
|
+
return JSON.parse(encodeUTF8(decrypted));
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a message is encrypted (has n and c properties)
|
|
82
|
+
* @param {any} msg - Message to check
|
|
83
|
+
* @returns {boolean} True if message appears to be encrypted
|
|
84
|
+
*/
|
|
85
|
+
export function isEncrypted(msg) {
|
|
86
|
+
return msg && typeof msg === 'object' && typeof msg.n === 'string' && typeof msg.c === 'string';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Encode session key to base64 for transmission
|
|
91
|
+
* @param {Uint8Array} key - Session key
|
|
92
|
+
* @returns {string} Base64 encoded key
|
|
93
|
+
*/
|
|
94
|
+
export function encodeKey(key) {
|
|
95
|
+
return encodeBase64(key);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decode session key from base64
|
|
100
|
+
* @param {string} encodedKey - Base64 encoded key
|
|
101
|
+
* @returns {Uint8Array} Session key
|
|
102
|
+
*/
|
|
103
|
+
export function decodeKey(encodedKey) {
|
|
104
|
+
return decodeBase64(encodedKey);
|
|
105
|
+
}
|