@taptapai/taptapai-openclaw 0.0.1
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 +141 -0
- package/index.ts +4 -0
- package/openclaw.plugin.json +80 -0
- package/package.json +27 -0
- package/src/backendWs.ts +252 -0
- package/src/bridgeLock.ts +89 -0
- package/src/constants.ts +14 -0
- package/src/emitArtifacts.ts +46 -0
- package/src/gatewayHttp.ts +65 -0
- package/src/gatewayWs.ts +380 -0
- package/src/loggerFilter.ts +35 -0
- package/src/logging.ts +38 -0
- package/src/openclawConfig.ts +129 -0
- package/src/paths.ts +39 -0
- package/src/plugin.ts +360 -0
- package/src/qr.ts +121 -0
- package/src/relayFallback.ts +103 -0
- package/src/requestHandler.ts +366 -0
- package/src/sanitize.ts +57 -0
- package/src/secrets.ts +93 -0
- package/src/sessions.ts +117 -0
- package/src/setup.ts +134 -0
- package/src/types.ts +36 -0
- package/src/urls.ts +41 -0
- package/src/websocketFactory.ts +22 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import type { LoggerLike } from "./types";
|
|
5
|
+
import type { RpcRequest } from "./types";
|
|
6
|
+
import { sanitizePathComponent, ensurePathWithin } from "./sanitize";
|
|
7
|
+
import { sendBackendResponse, type BackendWsState } from "./backendWs";
|
|
8
|
+
import { callGatewayHttp } from "./gatewayHttp";
|
|
9
|
+
import { dispatchViaGateway, type GatewayWsState } from "./gatewayWs";
|
|
10
|
+
import {
|
|
11
|
+
listTapTapAiSessions,
|
|
12
|
+
purgeSession,
|
|
13
|
+
readSessionStore,
|
|
14
|
+
readTranscript,
|
|
15
|
+
resolveSessionFromStore,
|
|
16
|
+
isTapTapAiSessionKey,
|
|
17
|
+
} from "./sessions";
|
|
18
|
+
import { readOpenClawConfig } from "./openclawConfig";
|
|
19
|
+
import { getTranscriptPath } from "./paths";
|
|
20
|
+
|
|
21
|
+
export type RequestHandlerDeps = {
|
|
22
|
+
backendState: BackendWsState;
|
|
23
|
+
gatewayState: GatewayWsState;
|
|
24
|
+
runtime: any;
|
|
25
|
+
pluginConfig: any;
|
|
26
|
+
logger: LoggerLike;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Allowed RPC methods. Requests with unknown methods are rejected. */
|
|
30
|
+
const ALLOWED_METHODS = new Set([
|
|
31
|
+
"agent.send",
|
|
32
|
+
"agent.cancel",
|
|
33
|
+
"conversations.list",
|
|
34
|
+
"conversations.get",
|
|
35
|
+
"conversations.delete",
|
|
36
|
+
"agents.list",
|
|
37
|
+
"agents.get",
|
|
38
|
+
"identity.link",
|
|
39
|
+
"identity.unlink",
|
|
40
|
+
"identity.list",
|
|
41
|
+
"sessions.list",
|
|
42
|
+
"taptapai.sessions.list",
|
|
43
|
+
"taptapai.sessions.delete",
|
|
44
|
+
"health",
|
|
45
|
+
"memory.search",
|
|
46
|
+
"memory.get",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export async function handleBackendRequest(deps: RequestHandlerDeps, req: RpcRequest): Promise<void> {
|
|
50
|
+
const { backendState, gatewayState, runtime, pluginConfig, logger } = deps;
|
|
51
|
+
const { id, method, params } = req;
|
|
52
|
+
|
|
53
|
+
// Validate request structure
|
|
54
|
+
if (!id || typeof id !== "string") {
|
|
55
|
+
logger.warn?.("[taptapai] Rejected request: missing or invalid id");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!method || typeof method !== "string") {
|
|
59
|
+
sendBackendResponse(backendState, id, "error", undefined, { message: "Invalid method", code: 400 });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!ALLOWED_METHODS.has(method)) {
|
|
63
|
+
sendBackendResponse(backendState, id, "error", undefined, { message: `Unknown method: ${method}`, code: 404 });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
logger.info?.(`[taptapai] handleRequest: method=${method} id=${id}`);
|
|
68
|
+
|
|
69
|
+
const send = (type: string, data?: any, extra?: Record<string, any>) =>
|
|
70
|
+
sendBackendResponse(backendState, id, type, data, extra);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
switch (method) {
|
|
74
|
+
// ================================================================
|
|
75
|
+
// Agent
|
|
76
|
+
// ================================================================
|
|
77
|
+
case "agent.send": {
|
|
78
|
+
const { session, text } = params as any;
|
|
79
|
+
const t0 = Date.now();
|
|
80
|
+
logger.info?.(`[taptapai] agent.send: session=${session} text="${String(text).substring(0, 80)}"`);
|
|
81
|
+
send("ack");
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (gatewayState.connected) {
|
|
85
|
+
logger.info?.(`[taptapai] Dispatching via gateway WS (chat.send) [ack sent in ${Date.now() - t0}ms]`);
|
|
86
|
+
const responseText = await dispatchViaGateway({ state: gatewayState, logger, text: String(text), session: String(session) });
|
|
87
|
+
const t1 = Date.now();
|
|
88
|
+
logger.info?.(`[taptapai] ⏱️ agent.send DONE: total=${t1 - t0}ms | Gateway WS response: "${responseText.substring(0, 80)}..."`);
|
|
89
|
+
send("stream", { text: responseText }, { event: "done" });
|
|
90
|
+
} else {
|
|
91
|
+
logger.info?.("[taptapai] Gateway WS not connected — falling back to HTTP");
|
|
92
|
+
const responseText = await callGatewayHttp({
|
|
93
|
+
text: String(text),
|
|
94
|
+
session: String(session),
|
|
95
|
+
runtime,
|
|
96
|
+
pluginConfig,
|
|
97
|
+
readOpenClawConfig: () => readOpenClawConfig(logger),
|
|
98
|
+
logger,
|
|
99
|
+
});
|
|
100
|
+
const t1 = Date.now();
|
|
101
|
+
logger.info?.(`[taptapai] ⏱️ agent.send HTTP fallback DONE: total=${t1 - t0}ms`);
|
|
102
|
+
send("stream", { text: responseText }, { event: "done" });
|
|
103
|
+
}
|
|
104
|
+
} catch (e: any) {
|
|
105
|
+
logger.error?.(`[taptapai] agent.send error after ${Date.now() - t0}ms: ${e?.message || e}`);
|
|
106
|
+
send("stream", { text: `Error: ${e?.message || e}` }, { event: "done" });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case "agent.cancel": {
|
|
113
|
+
send("result", { ok: true });
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ================================================================
|
|
118
|
+
// Conversations
|
|
119
|
+
// ================================================================
|
|
120
|
+
case "conversations.list": {
|
|
121
|
+
const agentId = sanitizePathComponent(String((params as any).agent || "main"), "agent");
|
|
122
|
+
const limit = Number((params as any).limit || 50);
|
|
123
|
+
const offset = Number((params as any).offset || 0);
|
|
124
|
+
|
|
125
|
+
const store = readSessionStore(agentId);
|
|
126
|
+
const entries = Object.entries(store)
|
|
127
|
+
.map(([key, val]: [string, any]) => ({
|
|
128
|
+
id: val.sessionId || key,
|
|
129
|
+
key,
|
|
130
|
+
channel: val.channel || key.split(":")[2] || "unknown",
|
|
131
|
+
updatedAt: val.updatedAt || val.lastMessageAt,
|
|
132
|
+
title: val.title || key,
|
|
133
|
+
}))
|
|
134
|
+
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime())
|
|
135
|
+
.slice(offset, offset + limit);
|
|
136
|
+
|
|
137
|
+
send("result", entries);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case "conversations.get": {
|
|
142
|
+
const sessionId = sanitizePathComponent(String((params as any).id || ""), "sessionId");
|
|
143
|
+
const agentId = sanitizePathComponent(String((params as any).agent || "main"), "agent");
|
|
144
|
+
|
|
145
|
+
const store = readSessionStore(agentId);
|
|
146
|
+
let resolvedSessionId = sessionId;
|
|
147
|
+
if (store[sessionId]) resolvedSessionId = store[sessionId].sessionId || sessionId;
|
|
148
|
+
|
|
149
|
+
const transcript = readTranscript(agentId, resolvedSessionId);
|
|
150
|
+
const metadata = store[sessionId] || {};
|
|
151
|
+
|
|
152
|
+
send("result", { id: resolvedSessionId, metadata, messages: transcript });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "conversations.delete": {
|
|
157
|
+
const sessionId = sanitizePathComponent(String((params as any).id || ""), "sessionId");
|
|
158
|
+
const agentId = sanitizePathComponent(String((params as any).agent || "main"), "agent");
|
|
159
|
+
try {
|
|
160
|
+
const transcriptPath = getTranscriptPath(agentId, sessionId);
|
|
161
|
+
if (fs.existsSync(transcriptPath)) fs.unlinkSync(transcriptPath);
|
|
162
|
+
send("result", { ok: true });
|
|
163
|
+
} catch (e: any) {
|
|
164
|
+
send("error", undefined, { message: `Failed to delete: ${e?.message || e}` , code: 500 });
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ================================================================
|
|
170
|
+
// Agents
|
|
171
|
+
// ================================================================
|
|
172
|
+
case "agents.list": {
|
|
173
|
+
const config = readOpenClawConfig(logger);
|
|
174
|
+
const agents = config?.agents?.list || [];
|
|
175
|
+
const defaults = config?.agents?.defaults || {};
|
|
176
|
+
|
|
177
|
+
const result = agents.map((a: any) => ({
|
|
178
|
+
id: a.id,
|
|
179
|
+
name: a.identity?.name || a.id,
|
|
180
|
+
model: a.model?.primary || defaults.model?.primary || "unknown",
|
|
181
|
+
workspace: a.workspace || defaults.workspace,
|
|
182
|
+
isDefault: !!a.default,
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
send("result", result);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case "agents.get": {
|
|
190
|
+
const agentId = sanitizePathComponent(String((params as any).id || ""), "agentId");
|
|
191
|
+
const config = readOpenClawConfig(logger);
|
|
192
|
+
const agents = config?.agents?.list || [];
|
|
193
|
+
const agent = agents.find((a: any) => a.id === agentId);
|
|
194
|
+
|
|
195
|
+
if (agent) send("result", agent);
|
|
196
|
+
else send("error", undefined, { message: `Agent '${agentId}' not found`, code: 404 });
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ================================================================
|
|
201
|
+
// Identity links (deprecated/no-op)
|
|
202
|
+
// ================================================================
|
|
203
|
+
case "identity.link":
|
|
204
|
+
case "identity.unlink": {
|
|
205
|
+
send("result", { ok: true, message: "identity links deprecated" });
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case "identity.list": {
|
|
210
|
+
send("result", { links: {} });
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ================================================================
|
|
215
|
+
// Sessions
|
|
216
|
+
// ================================================================
|
|
217
|
+
case "sessions.list": {
|
|
218
|
+
const agentId = sanitizePathComponent(String((params as any).agent || "main"), "agent");
|
|
219
|
+
const store = readSessionStore(agentId);
|
|
220
|
+
const entries = Object.entries(store).map(([key, val]: [string, any]) => ({
|
|
221
|
+
key,
|
|
222
|
+
sessionId: val.sessionId || key,
|
|
223
|
+
channel: val.channel || "unknown",
|
|
224
|
+
updatedAt: val.updatedAt,
|
|
225
|
+
}));
|
|
226
|
+
send("result", entries);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ================================================================
|
|
231
|
+
// TapTapAI session maintenance
|
|
232
|
+
// ================================================================
|
|
233
|
+
case "taptapai.sessions.list": {
|
|
234
|
+
const agentId = sanitizePathComponent(String((params as any).agent || "main"), "agent");
|
|
235
|
+
const limit = Number((params as any).limit || 100);
|
|
236
|
+
const offset = Number((params as any).offset || 0);
|
|
237
|
+
|
|
238
|
+
const all = listTapTapAiSessions(agentId);
|
|
239
|
+
send("result", { agent: agentId, total: all.length, offset, limit, sessions: all.slice(offset, offset + limit) });
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case "taptapai.sessions.delete": {
|
|
244
|
+
const agentId = sanitizePathComponent(String((params as any).agent || "main"), "agent");
|
|
245
|
+
const idParam = String((params as any)?.id || "").trim();
|
|
246
|
+
const all = Boolean((params as any)?.all);
|
|
247
|
+
const confirm = Boolean((params as any)?.confirm);
|
|
248
|
+
const dryRun = Boolean((params as any)?.dryRun);
|
|
249
|
+
|
|
250
|
+
if (all && !confirm && !dryRun) {
|
|
251
|
+
send("error", undefined, { message: "Refusing to delete all sessions without confirm=true (or dryRun=true)", code: 400 });
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const targets = all
|
|
256
|
+
? listTapTapAiSessions(agentId).map((s) => ({ key: s.key, sessionId: s.sessionId }))
|
|
257
|
+
: (() => {
|
|
258
|
+
if (!idParam) return [];
|
|
259
|
+
const resolved = resolveSessionFromStore(agentId, idParam);
|
|
260
|
+
if (!resolved) return [];
|
|
261
|
+
if (!isTapTapAiSessionKey(resolved.key)) return [];
|
|
262
|
+
return [resolved];
|
|
263
|
+
})();
|
|
264
|
+
|
|
265
|
+
if (!targets.length) {
|
|
266
|
+
send("result", {
|
|
267
|
+
ok: true,
|
|
268
|
+
agent: agentId,
|
|
269
|
+
dryRun,
|
|
270
|
+
deleted: [],
|
|
271
|
+
deletedCount: 0,
|
|
272
|
+
message: all ? "No TapTapAI sessions found" : "Session not found (or not a TapTapAI session)",
|
|
273
|
+
});
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const deleted = targets.map((t) => purgeSession(agentId, t.key, t.sessionId, { dryRun }));
|
|
278
|
+
send("result", { ok: true, agent: agentId, dryRun, deletedCount: deleted.length, deleted });
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ================================================================
|
|
283
|
+
// System
|
|
284
|
+
// ================================================================
|
|
285
|
+
case "health": {
|
|
286
|
+
const config = readOpenClawConfig(logger);
|
|
287
|
+
const agents = config?.agents?.list || [];
|
|
288
|
+
const channels = Object.keys(config?.channels || {}).filter((k) => config.channels[k]?.dmPolicy && config.channels[k].dmPolicy !== "disabled");
|
|
289
|
+
|
|
290
|
+
send("result", {
|
|
291
|
+
status: "healthy",
|
|
292
|
+
version: runtime?.version ?? "unknown",
|
|
293
|
+
agents: agents.length,
|
|
294
|
+
channels: channels.length,
|
|
295
|
+
channelNames: channels,
|
|
296
|
+
wsConnected: true,
|
|
297
|
+
gatewayWsConnected: gatewayState.connected,
|
|
298
|
+
});
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case "memory.search": {
|
|
303
|
+
const query = String((params as any).query || "");
|
|
304
|
+
const limit = Number((params as any).limit || 5);
|
|
305
|
+
|
|
306
|
+
const workspacePath = runtime?.config?.loadConfig?.()?.agents?.defaults?.workspace || path.join(os.homedir(), ".openclaw", "workspace");
|
|
307
|
+
const memoryDir = path.join(workspacePath, "memory");
|
|
308
|
+
|
|
309
|
+
const results: any[] = [];
|
|
310
|
+
try {
|
|
311
|
+
if (fs.existsSync(memoryDir)) {
|
|
312
|
+
const files = fs.readdirSync(memoryDir).filter((f: string) => f.endsWith(".md"));
|
|
313
|
+
const queryLower = query.toLowerCase();
|
|
314
|
+
for (const file of files) {
|
|
315
|
+
const safePath = ensurePathWithin(memoryDir, file, "memory file");
|
|
316
|
+
const content = fs.readFileSync(safePath, "utf-8");
|
|
317
|
+
if (content.toLowerCase().includes(queryLower)) {
|
|
318
|
+
results.push({ source: `memory/${file}`, text: content.substring(0, 500), score: 1.0 });
|
|
319
|
+
if (results.length >= limit) break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch (e: any) {
|
|
324
|
+
logger.error?.(`[taptapai] Memory search error: ${e?.message || e}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
send("result", results);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
case "memory.get": {
|
|
332
|
+
const memPath = (params as any).path as string | undefined;
|
|
333
|
+
|
|
334
|
+
const workspacePath = runtime?.config?.loadConfig?.()?.agents?.defaults?.workspace || path.join(os.homedir(), ".openclaw", "workspace");
|
|
335
|
+
let filePath: string;
|
|
336
|
+
try {
|
|
337
|
+
filePath = memPath
|
|
338
|
+
? ensurePathWithin(workspacePath, memPath, "memory path")
|
|
339
|
+
: path.join(workspacePath, "MEMORY.md");
|
|
340
|
+
} catch (e: any) {
|
|
341
|
+
send("error", undefined, { message: "Invalid path", code: 400 });
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
if (fs.existsSync(filePath)) {
|
|
347
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
348
|
+
send("result", { path: filePath, content });
|
|
349
|
+
} else {
|
|
350
|
+
send("error", undefined, { message: "File not found", code: 404 });
|
|
351
|
+
}
|
|
352
|
+
} catch (e: any) {
|
|
353
|
+
send("error", undefined, { message: "Read error", code: 500 });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
default:
|
|
360
|
+
send("error", undefined, { message: `Unknown method: ${method}`, code: 404 });
|
|
361
|
+
}
|
|
362
|
+
} catch (e: any) {
|
|
363
|
+
logger.error?.(`[taptapai] Handler error for ${method}: ${e?.stack || e?.message || e}`);
|
|
364
|
+
send("error", undefined, { message: "Internal handler error", code: 500 });
|
|
365
|
+
}
|
|
366
|
+
}
|
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates that a path component (agentId, sessionId, filename) is safe
|
|
5
|
+
* to use in filesystem operations. Rejects traversal attempts, slashes,
|
|
6
|
+
* null bytes, and other dangerous characters.
|
|
7
|
+
*
|
|
8
|
+
* @throws Error if the component is invalid
|
|
9
|
+
*/
|
|
10
|
+
export function sanitizePathComponent(value: string, label: string = "value"): string {
|
|
11
|
+
const trimmed = String(value ?? "").trim();
|
|
12
|
+
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
throw new Error(`${label} must not be empty`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Reject null bytes
|
|
18
|
+
if (trimmed.includes("\0")) {
|
|
19
|
+
throw new Error(`${label} contains null bytes`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Reject path separators
|
|
23
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
24
|
+
throw new Error(`${label} must not contain path separators`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Reject traversal patterns
|
|
28
|
+
if (trimmed === "." || trimmed === ".." || trimmed.includes("..")) {
|
|
29
|
+
throw new Error(`${label} must not contain path traversal sequences`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Reject control characters (U+0000 to U+001F, U+007F)
|
|
33
|
+
if (/[\x00-\x1f\x7f]/.test(trimmed)) {
|
|
34
|
+
throw new Error(`${label} must not contain control characters`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return trimmed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ensures that a resolved file path stays within a given base directory.
|
|
42
|
+
* Prevents path traversal via symlinks or encoded components.
|
|
43
|
+
*
|
|
44
|
+
* @returns The resolved absolute path
|
|
45
|
+
* @throws Error if the path escapes the base directory
|
|
46
|
+
*/
|
|
47
|
+
export function ensurePathWithin(basePath: string, untrustedPath: string, label: string = "path"): string {
|
|
48
|
+
const resolvedBase = path.resolve(basePath);
|
|
49
|
+
const resolvedFull = path.resolve(basePath, untrustedPath);
|
|
50
|
+
|
|
51
|
+
// Ensure the resolved path starts with the base path + separator (or is exactly the base)
|
|
52
|
+
if (resolvedFull !== resolvedBase && !resolvedFull.startsWith(resolvedBase + path.sep)) {
|
|
53
|
+
throw new Error(`${label} escapes the allowed directory`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return resolvedFull;
|
|
57
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import process from "process";
|
|
4
|
+
import type { StoredSecret } from "./types";
|
|
5
|
+
import { getSecretCachePath, getUuidCachePath, getPluginDataDir } from "./paths";
|
|
6
|
+
|
|
7
|
+
/** Write data atomically: write to temp file, then rename. */
|
|
8
|
+
function atomicWriteFileSync(filePath: string, data: string, mode: number = 0o600): void {
|
|
9
|
+
const tmpPath = filePath + ".tmp." + process.pid;
|
|
10
|
+
fs.writeFileSync(tmpPath, data, { encoding: "utf8", mode });
|
|
11
|
+
try { fs.chmodSync(tmpPath, mode); } catch {}
|
|
12
|
+
fs.renameSync(tmpPath, filePath);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sha256Hex(value: string): string {
|
|
16
|
+
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated Kept only for backward compatibility with existing tokens.
|
|
21
|
+
* New tokens should use computeScryptHash() instead.
|
|
22
|
+
*/
|
|
23
|
+
export function computeDoubleSha256Hex(password: string): string {
|
|
24
|
+
return sha256Hex(sha256Hex(password));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Derives a password hash using scrypt with a random salt.
|
|
29
|
+
* Returns { hash, salt } where both are hex strings.
|
|
30
|
+
*/
|
|
31
|
+
export function computeScryptHash(password: string, existingSalt?: string): { hash: string; salt: string } {
|
|
32
|
+
const salt = existingSalt || crypto.randomBytes(32).toString("hex");
|
|
33
|
+
const derived = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 });
|
|
34
|
+
return { hash: derived.toString("hex"), salt };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sha256HexPrefix(value: string, length: number): string {
|
|
38
|
+
return sha256Hex(value).slice(0, length);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function encodeSecretToken(versionByte: number, uuidHex: string, passwordHashHex: string, n: number): string {
|
|
42
|
+
const uuidBuf = Buffer.from(uuidHex, "hex");
|
|
43
|
+
const passBuf = Buffer.from(passwordHashHex, "hex");
|
|
44
|
+
const verBuf = Buffer.from([versionByte & 0xff]);
|
|
45
|
+
const nBuf = Buffer.allocUnsafe(4);
|
|
46
|
+
nBuf.writeUInt32BE(n >>> 0, 0);
|
|
47
|
+
const raw = Buffer.concat([verBuf, uuidBuf, passBuf, nBuf]);
|
|
48
|
+
return `tta1_${raw.toString("base64url")}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ensurePluginDataDir(): void {
|
|
52
|
+
fs.mkdirSync(getPluginDataDir(), { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadOrCreateUuidHex(): string {
|
|
56
|
+
ensurePluginDataDir();
|
|
57
|
+
const p = getUuidCachePath();
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
60
|
+
const uuidHex = String(parsed?.uuidHex || "").trim();
|
|
61
|
+
if (/^[0-9a-f]{64}$/i.test(uuidHex)) return uuidHex.toLowerCase();
|
|
62
|
+
} catch (e: any) {
|
|
63
|
+
// First run or corrupted file — will regenerate below
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const uuidHex = crypto.randomBytes(32).toString("hex");
|
|
67
|
+
atomicWriteFileSync(p, JSON.stringify({ uuidHex, createdAt: new Date().toISOString() }, null, 2));
|
|
68
|
+
return uuidHex;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadStoredSecret(): StoredSecret | null {
|
|
72
|
+
try {
|
|
73
|
+
const p = getSecretCachePath();
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
75
|
+
if (!parsed?.token || !parsed?.uuidHex) return null;
|
|
76
|
+
return {
|
|
77
|
+
version: Number(parsed.version ?? 0),
|
|
78
|
+
uuidHex: String(parsed.uuidHex),
|
|
79
|
+
n: Number(parsed.n ?? 1),
|
|
80
|
+
token: String(parsed.token),
|
|
81
|
+
salt: parsed.salt ? String(parsed.salt) : undefined,
|
|
82
|
+
generatedAt: String(parsed.generatedAt ?? new Date().toISOString()),
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function saveStoredSecret(secret: StoredSecret): void {
|
|
90
|
+
ensurePluginDataDir();
|
|
91
|
+
const p = getSecretCachePath();
|
|
92
|
+
atomicWriteFileSync(p, JSON.stringify(secret, null, 2));
|
|
93
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import process from "process";
|
|
4
|
+
import { getSessionStorePath, getTranscriptPath } from "./paths";
|
|
5
|
+
|
|
6
|
+
export function readSessionStore(agentId: string = "main"): Record<string, any> {
|
|
7
|
+
try {
|
|
8
|
+
const raw = fs.readFileSync(getSessionStorePath(agentId), "utf-8");
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writeSessionStore(agentId: string, store: Record<string, any>): void {
|
|
16
|
+
const p = getSessionStorePath(agentId);
|
|
17
|
+
try {
|
|
18
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
19
|
+
} catch {}
|
|
20
|
+
// Atomic write: write to temp then rename to prevent partial writes
|
|
21
|
+
const tmpPath = p + ".tmp." + process.pid;
|
|
22
|
+
fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), "utf-8");
|
|
23
|
+
fs.renameSync(tmpPath, p);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isTapTapAiSessionKey(key: string): boolean {
|
|
27
|
+
return String(key || "").trim().startsWith("taptapai:");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveSessionFromStore(agentId: string, keyOrId: string): { key: string; sessionId: string } | null {
|
|
31
|
+
const id = String(keyOrId || "").trim();
|
|
32
|
+
if (!id) return null;
|
|
33
|
+
|
|
34
|
+
const store = readSessionStore(agentId);
|
|
35
|
+
|
|
36
|
+
if (store[id]) {
|
|
37
|
+
const sessionId = String(store[id]?.sessionId || id);
|
|
38
|
+
return { key: id, sessionId };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const [key, val] of Object.entries(store)) {
|
|
42
|
+
const sid = String((val as any)?.sessionId || "");
|
|
43
|
+
if (sid && sid === id) return { key, sessionId: sid };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function listTapTapAiSessions(agentId: string): Array<{ key: string; sessionId: string; updatedAt: any; title: any }> {
|
|
50
|
+
const store = readSessionStore(agentId);
|
|
51
|
+
return Object.entries(store)
|
|
52
|
+
.filter(([key]) => isTapTapAiSessionKey(key))
|
|
53
|
+
.map(([key, val]: [string, any]) => ({
|
|
54
|
+
key,
|
|
55
|
+
sessionId: String(val?.sessionId || key),
|
|
56
|
+
updatedAt: val?.updatedAt || val?.lastMessageAt,
|
|
57
|
+
title: val?.title || key,
|
|
58
|
+
}))
|
|
59
|
+
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function purgeSession(
|
|
63
|
+
agentId: string,
|
|
64
|
+
key: string,
|
|
65
|
+
sessionId: string,
|
|
66
|
+
opts?: { deleteTranscript?: boolean; deleteStoreEntry?: boolean; dryRun?: boolean },
|
|
67
|
+
): {
|
|
68
|
+
key: string;
|
|
69
|
+
sessionId: string;
|
|
70
|
+
storeEntryDeleted: boolean;
|
|
71
|
+
transcriptDeleted: boolean;
|
|
72
|
+
} {
|
|
73
|
+
const dryRun = !!opts?.dryRun;
|
|
74
|
+
const deleteTranscript = opts?.deleteTranscript !== false;
|
|
75
|
+
const deleteStoreEntry = opts?.deleteStoreEntry !== false;
|
|
76
|
+
|
|
77
|
+
let storeEntryDeleted = false;
|
|
78
|
+
let transcriptDeleted = false;
|
|
79
|
+
|
|
80
|
+
if (deleteStoreEntry) {
|
|
81
|
+
const store = readSessionStore(agentId);
|
|
82
|
+
if (store[key] !== undefined) {
|
|
83
|
+
if (!dryRun) {
|
|
84
|
+
delete store[key];
|
|
85
|
+
writeSessionStore(agentId, store);
|
|
86
|
+
}
|
|
87
|
+
storeEntryDeleted = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (deleteTranscript) {
|
|
92
|
+
const transcriptPath = getTranscriptPath(agentId, sessionId);
|
|
93
|
+
if (fs.existsSync(transcriptPath)) {
|
|
94
|
+
if (!dryRun) {
|
|
95
|
+
try { fs.unlinkSync(transcriptPath); } catch {}
|
|
96
|
+
}
|
|
97
|
+
transcriptDeleted = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { key, sessionId, storeEntryDeleted, transcriptDeleted };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function readTranscript(agentId: string, sessionId: string): any[] {
|
|
105
|
+
try {
|
|
106
|
+
const raw = fs.readFileSync(getTranscriptPath(agentId, sessionId), "utf-8");
|
|
107
|
+
return raw
|
|
108
|
+
.split("\n")
|
|
109
|
+
.filter((line: string) => line.trim())
|
|
110
|
+
.map((line: string) => {
|
|
111
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
112
|
+
})
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|