happy-coder 0.1.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.
Files changed (50) hide show
  1. package/README.md +38 -0
  2. package/bin/happy +2 -0
  3. package/bin/happy.cmd +2 -0
  4. package/dist/auth/auth.d.ts +38 -0
  5. package/dist/auth/auth.js +76 -0
  6. package/dist/auth/auth.test.d.ts +7 -0
  7. package/dist/auth/auth.test.js +96 -0
  8. package/dist/auth/crypto.d.ts +25 -0
  9. package/dist/auth/crypto.js +36 -0
  10. package/dist/claude/claude.d.ts +54 -0
  11. package/dist/claude/claude.js +170 -0
  12. package/dist/claude/claude.test.d.ts +7 -0
  13. package/dist/claude/claude.test.js +130 -0
  14. package/dist/claude/types.d.ts +37 -0
  15. package/dist/claude/types.js +7 -0
  16. package/dist/commands/start.d.ts +38 -0
  17. package/dist/commands/start.js +161 -0
  18. package/dist/commands/start.test.d.ts +7 -0
  19. package/dist/commands/start.test.js +307 -0
  20. package/dist/handlers/message-handler.d.ts +65 -0
  21. package/dist/handlers/message-handler.js +187 -0
  22. package/dist/index.cjs +603 -0
  23. package/dist/index.d.cts +1 -0
  24. package/dist/index.d.mts +1 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/index.mjs +583 -0
  28. package/dist/session/service.d.ts +27 -0
  29. package/dist/session/service.js +93 -0
  30. package/dist/session/service.test.d.ts +7 -0
  31. package/dist/session/service.test.js +71 -0
  32. package/dist/session/types.d.ts +44 -0
  33. package/dist/session/types.js +4 -0
  34. package/dist/socket/client.d.ts +50 -0
  35. package/dist/socket/client.js +136 -0
  36. package/dist/socket/client.test.d.ts +7 -0
  37. package/dist/socket/client.test.js +74 -0
  38. package/dist/socket/types.d.ts +80 -0
  39. package/dist/socket/types.js +12 -0
  40. package/dist/utils/config.d.ts +22 -0
  41. package/dist/utils/config.js +23 -0
  42. package/dist/utils/logger.d.ts +26 -0
  43. package/dist/utils/logger.js +60 -0
  44. package/dist/utils/paths.d.ts +18 -0
  45. package/dist/utils/paths.js +24 -0
  46. package/dist/utils/qrcode.d.ts +19 -0
  47. package/dist/utils/qrcode.js +37 -0
  48. package/dist/utils/qrcode.test.d.ts +7 -0
  49. package/dist/utils/qrcode.test.js +14 -0
  50. package/package.json +60 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,583 @@
1
+ import axios from 'axios';
2
+ import * as fs from 'node:fs';
3
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { chmod } from 'node:fs/promises';
5
+ import { randomBytes, randomUUID } from 'node:crypto';
6
+ import tweetnacl from 'tweetnacl';
7
+ import { homedir } from 'node:os';
8
+ import { join, basename } from 'node:path';
9
+ import chalk from 'chalk';
10
+ import { EventEmitter } from 'node:events';
11
+ import { io } from 'socket.io-client';
12
+ import { z } from 'zod';
13
+ import qrcode from 'qrcode-terminal';
14
+ import { spawn } from 'node:child_process';
15
+
16
+ function encodeBase64(buffer) {
17
+ return Buffer.from(buffer).toString("base64");
18
+ }
19
+ function encodeBase64Url(buffer) {
20
+ return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
21
+ }
22
+ function decodeBase64(base64) {
23
+ return new Uint8Array(Buffer.from(base64, "base64"));
24
+ }
25
+ function getRandomBytes(size) {
26
+ return new Uint8Array(randomBytes(size));
27
+ }
28
+ function encrypt(data, secret) {
29
+ const nonce = getRandomBytes(tweetnacl.secretbox.nonceLength);
30
+ const encrypted = tweetnacl.secretbox(new TextEncoder().encode(JSON.stringify(data)), nonce, secret);
31
+ const result = new Uint8Array(nonce.length + encrypted.length);
32
+ result.set(nonce);
33
+ result.set(encrypted, nonce.length);
34
+ return result;
35
+ }
36
+ function decrypt(data, secret) {
37
+ const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
38
+ const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
39
+ const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
40
+ if (!decrypted) {
41
+ return null;
42
+ }
43
+ return JSON.parse(new TextDecoder().decode(decrypted));
44
+ }
45
+ function authChallenge(secret) {
46
+ const keypair = tweetnacl.sign.keyPair.fromSeed(secret);
47
+ const challenge = getRandomBytes(32);
48
+ const signature = tweetnacl.sign.detached(challenge, keypair.secretKey);
49
+ return {
50
+ challenge,
51
+ publicKey: keypair.publicKey,
52
+ signature
53
+ };
54
+ }
55
+
56
+ async function getOrCreateSecretKey() {
57
+ const keyPath = join(homedir(), ".handy", "access.key");
58
+ if (existsSync(keyPath)) {
59
+ const keyBase642 = readFileSync(keyPath, "utf8").trim();
60
+ return new Uint8Array(Buffer.from(keyBase642, "base64"));
61
+ }
62
+ const secret = getRandomBytes(32);
63
+ const keyBase64 = encodeBase64(secret);
64
+ mkdirSync(join(homedir(), ".handy"), { recursive: true });
65
+ writeFileSync(keyPath, keyBase64);
66
+ await chmod(keyPath, 384);
67
+ return secret;
68
+ }
69
+ async function authGetToken(secret) {
70
+ const { challenge, publicKey, signature } = authChallenge(secret);
71
+ const response = await axios.post(`https://handy-api.korshakov.org/v1/auth`, {
72
+ challenge: encodeBase64(challenge),
73
+ publicKey: encodeBase64(publicKey),
74
+ signature: encodeBase64(signature)
75
+ });
76
+ if (!response.data.success || !response.data.token) {
77
+ throw new Error("Authentication failed");
78
+ }
79
+ return response.data.token;
80
+ }
81
+ function generateAppUrl(secret) {
82
+ const secretBase64Url = encodeBase64Url(secret);
83
+ return `handy://${secretBase64Url}`;
84
+ }
85
+
86
+ class Logger {
87
+ debug(message, ...args) {
88
+ if (process.env.DEBUG) {
89
+ this.log("DEBUG" /* DEBUG */, message, ...args);
90
+ }
91
+ }
92
+ error(message, ...args) {
93
+ this.log("ERROR" /* ERROR */, message, ...args);
94
+ }
95
+ info(message, ...args) {
96
+ this.log("INFO" /* INFO */, message, ...args);
97
+ }
98
+ warn(message, ...args) {
99
+ this.log("WARN" /* WARN */, message, ...args);
100
+ }
101
+ getTimestamp() {
102
+ return (/* @__PURE__ */ new Date()).toISOString();
103
+ }
104
+ log(level, message, ...args) {
105
+ const timestamp = this.getTimestamp();
106
+ const prefix = `[${timestamp}] [${level}]`;
107
+ switch (level) {
108
+ case "DEBUG" /* DEBUG */: {
109
+ console.log(chalk.gray(prefix), message, ...args);
110
+ break;
111
+ }
112
+ case "ERROR" /* ERROR */: {
113
+ console.error(chalk.red(prefix), message, ...args);
114
+ break;
115
+ }
116
+ case "INFO" /* INFO */: {
117
+ console.log(chalk.blue(prefix), message, ...args);
118
+ break;
119
+ }
120
+ case "WARN" /* WARN */: {
121
+ console.log(chalk.yellow(prefix), message, ...args);
122
+ break;
123
+ }
124
+ }
125
+ }
126
+ }
127
+ const logger = new Logger();
128
+
129
+ const SessionMessageContentSchema = z.object({
130
+ c: z.string(),
131
+ // Base64 encoded encrypted content
132
+ t: z.literal("encrypted")
133
+ });
134
+ const UpdateBodySchema = z.object({
135
+ message: z.object({
136
+ id: z.string(),
137
+ seq: z.number(),
138
+ content: SessionMessageContentSchema
139
+ }),
140
+ sid: z.string(),
141
+ // Session ID
142
+ t: z.literal("new-message")
143
+ });
144
+ z.object({
145
+ id: z.string(),
146
+ seq: z.number(),
147
+ body: UpdateBodySchema,
148
+ createdAt: z.number()
149
+ });
150
+ z.object({
151
+ createdAt: z.number(),
152
+ id: z.string(),
153
+ seq: z.number(),
154
+ updatedAt: z.number()
155
+ });
156
+ z.object({
157
+ content: SessionMessageContentSchema,
158
+ createdAt: z.number(),
159
+ id: z.string(),
160
+ seq: z.number(),
161
+ updatedAt: z.number()
162
+ });
163
+ z.object({
164
+ session: z.object({
165
+ id: z.string(),
166
+ tag: z.string(),
167
+ seq: z.number(),
168
+ createdAt: z.number(),
169
+ updatedAt: z.number()
170
+ })
171
+ });
172
+ const UserMessageSchema = z.object({
173
+ role: z.literal("user"),
174
+ content: z.object({
175
+ type: z.literal("text"),
176
+ text: z.string()
177
+ })
178
+ });
179
+ const AgentMessageSchema = z.object({
180
+ role: z.literal("agent"),
181
+ content: z.any()
182
+ });
183
+ z.union([UserMessageSchema, AgentMessageSchema]);
184
+
185
+ class ApiSessionClient extends EventEmitter {
186
+ token;
187
+ secret;
188
+ sessionId;
189
+ socket;
190
+ receivedMessages = /* @__PURE__ */ new Set();
191
+ pendingMessages = [];
192
+ pendingMessageCallback = null;
193
+ constructor(token, secret, sessionId) {
194
+ super();
195
+ this.token = token;
196
+ this.secret = secret;
197
+ this.sessionId = sessionId;
198
+ this.socket = io("https://handy-api.korshakov.org", {
199
+ auth: {
200
+ token: this.token
201
+ },
202
+ path: "/v1/updates",
203
+ reconnection: true,
204
+ reconnectionAttempts: Infinity,
205
+ reconnectionDelay: 1e3,
206
+ reconnectionDelayMax: 5e3,
207
+ transports: ["websocket"],
208
+ withCredentials: true,
209
+ autoConnect: false
210
+ });
211
+ this.socket.on("connect", () => {
212
+ logger.info("Socket connected successfully");
213
+ });
214
+ this.socket.on("disconnect", (reason) => {
215
+ logger.warn("Socket disconnected:", reason);
216
+ });
217
+ this.socket.on("connect_error", (error) => {
218
+ logger.error("Socket connection error:", error.message);
219
+ });
220
+ this.socket.on("update", (data) => {
221
+ if (data.body.t === "new-message" && data.body.message.content.t === "encrypted") {
222
+ const body = decrypt(decodeBase64(data.body.message.content.c), this.secret);
223
+ const result = UserMessageSchema.safeParse(body);
224
+ if (result.success) {
225
+ if (!this.receivedMessages.has(data.body.message.id)) {
226
+ this.receivedMessages.add(data.body.message.id);
227
+ if (this.pendingMessageCallback) {
228
+ this.pendingMessageCallback(result.data);
229
+ } else {
230
+ this.pendingMessages.push(result.data);
231
+ }
232
+ }
233
+ }
234
+ }
235
+ });
236
+ this.socket.connect();
237
+ }
238
+ onUserMessage(callback) {
239
+ this.pendingMessageCallback = callback;
240
+ while (this.pendingMessages.length > 0) {
241
+ callback(this.pendingMessages.shift());
242
+ }
243
+ }
244
+ /**
245
+ * Send message to session
246
+ * @param body - Message body
247
+ */
248
+ sendMessage(body) {
249
+ let content = {
250
+ role: "agent",
251
+ content: body
252
+ };
253
+ const encrypted = encodeBase64(encrypt(content, this.secret));
254
+ this.socket.emit("message", {
255
+ sid: this.sessionId,
256
+ message: encrypted
257
+ });
258
+ }
259
+ async close() {
260
+ this.socket.close();
261
+ }
262
+ }
263
+
264
+ class ApiClient {
265
+ token;
266
+ secret;
267
+ constructor(token, secret) {
268
+ this.token = token;
269
+ this.secret = secret;
270
+ }
271
+ /**
272
+ * Create a new session or load existing one with the given tag
273
+ */
274
+ async getOrCreateSession(tag) {
275
+ try {
276
+ const response = await axios.post(
277
+ `https://handy-api.korshakov.org/v1/sessions`,
278
+ { tag },
279
+ {
280
+ headers: {
281
+ "Authorization": `Bearer ${this.token}`,
282
+ "Content-Type": "application/json"
283
+ }
284
+ }
285
+ );
286
+ logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${tag})`);
287
+ return response.data;
288
+ } catch (error) {
289
+ logger.error("Failed to get or create session:", error);
290
+ throw new Error(`Failed to get or create session: ${error instanceof Error ? error.message : "Unknown error"}`);
291
+ }
292
+ }
293
+ /**
294
+ * Start realtime session client
295
+ * @param id - Session ID
296
+ * @returns Session client
297
+ */
298
+ session(id) {
299
+ return new ApiSessionClient(this.token, this.secret, id);
300
+ }
301
+ }
302
+
303
+ function displayQRCode(url) {
304
+ try {
305
+ logger.info("=".repeat(50));
306
+ logger.info("\u{1F4F1} Scan this QR code with your mobile device:");
307
+ logger.info("=".repeat(50));
308
+ qrcode.generate(url, { small: true }, (qr) => {
309
+ for (let l of qr.split("\n")) {
310
+ logger.info(" " + l);
311
+ }
312
+ });
313
+ logger.info("=".repeat(50));
314
+ } catch (error) {
315
+ logger.error("Failed to generate QR code:", error);
316
+ logger.info(`\u{1F4CB} Use this URL to connect: ${url}`);
317
+ }
318
+ }
319
+
320
+ function claudePath() {
321
+ if (fs.existsSync(process.env.HOME + "/.claude/local/claude")) {
322
+ return process.env.HOME + "/.claude/local/claude";
323
+ } else {
324
+ return "claude";
325
+ }
326
+ }
327
+
328
+ async function* claude(options) {
329
+ const args = buildArgs(options);
330
+ const path = claudePath();
331
+ logger.info("Spawning Claude CLI with args:", args);
332
+ const process = spawn(path, args, {
333
+ cwd: options.workingDirectory,
334
+ stdio: ["pipe", "pipe", "pipe"],
335
+ shell: false
336
+ });
337
+ process.stdin?.end();
338
+ let outputBuffer = "";
339
+ let stderrBuffer = "";
340
+ let processExited = false;
341
+ const outputQueue = [];
342
+ let outputResolve = null;
343
+ process.stdout?.on("data", (data) => {
344
+ outputBuffer += data.toString();
345
+ const lines = outputBuffer.split("\n");
346
+ outputBuffer = lines.pop() || "";
347
+ for (const line of lines) {
348
+ if (line.trim()) {
349
+ try {
350
+ const json = JSON.parse(line);
351
+ outputQueue.push({ type: "json", data: json });
352
+ } catch {
353
+ outputQueue.push({ type: "text", data: line });
354
+ }
355
+ if (outputResolve) {
356
+ outputResolve();
357
+ outputResolve = null;
358
+ }
359
+ }
360
+ }
361
+ });
362
+ process.stderr?.on("data", (data) => {
363
+ stderrBuffer += data.toString();
364
+ const lines = stderrBuffer.split("\n");
365
+ stderrBuffer = lines.pop() || "";
366
+ for (const line of lines) {
367
+ if (line.trim()) {
368
+ outputQueue.push({ type: "error", error: line });
369
+ if (outputResolve) {
370
+ outputResolve();
371
+ outputResolve = null;
372
+ }
373
+ }
374
+ }
375
+ });
376
+ process.on("exit", (code, signal) => {
377
+ processExited = true;
378
+ outputQueue.push({ type: "exit", code, signal });
379
+ if (outputResolve) {
380
+ outputResolve();
381
+ outputResolve = null;
382
+ }
383
+ });
384
+ process.on("error", (error) => {
385
+ outputQueue.push({ type: "error", error: error.message });
386
+ processExited = true;
387
+ if (outputResolve) {
388
+ outputResolve();
389
+ outputResolve = null;
390
+ }
391
+ });
392
+ while (!processExited || outputQueue.length > 0) {
393
+ if (outputQueue.length === 0) {
394
+ await new Promise((resolve) => {
395
+ outputResolve = resolve;
396
+ if (outputQueue.length > 0 || processExited) {
397
+ resolve();
398
+ outputResolve = null;
399
+ }
400
+ });
401
+ }
402
+ while (outputQueue.length > 0) {
403
+ const output = outputQueue.shift();
404
+ yield output;
405
+ }
406
+ }
407
+ if (outputBuffer.trim()) {
408
+ try {
409
+ const json = JSON.parse(outputBuffer);
410
+ yield { type: "json", data: json };
411
+ } catch {
412
+ yield { type: "text", data: outputBuffer };
413
+ }
414
+ }
415
+ if (stderrBuffer.trim()) {
416
+ yield { type: "error", error: stderrBuffer };
417
+ }
418
+ }
419
+ function buildArgs(options) {
420
+ const args = [
421
+ "--print",
422
+ options.command,
423
+ "--output-format",
424
+ "stream-json",
425
+ "--verbose"
426
+ ];
427
+ if (options.model) {
428
+ args.push("--model", options.model);
429
+ }
430
+ if (options.permissionMode) {
431
+ const modeMap = {
432
+ "auto": "acceptEdits",
433
+ "default": "default",
434
+ "plan": "bypassPermissions"
435
+ };
436
+ args.push("--permission-mode", modeMap[options.permissionMode]);
437
+ }
438
+ if (options.skipPermissions) {
439
+ args.push("--dangerously-skip-permissions");
440
+ }
441
+ if (options.sessionId) {
442
+ args.push("--resume", options.sessionId);
443
+ }
444
+ return args;
445
+ }
446
+
447
+ function startClaudeLoop(opts, session) {
448
+ let exiting = false;
449
+ const messageQueue = [];
450
+ let messageResolve = null;
451
+ let promise = (async () => {
452
+ session.onUserMessage((message) => {
453
+ messageQueue.push(message);
454
+ if (messageResolve) {
455
+ messageResolve();
456
+ messageResolve = null;
457
+ }
458
+ });
459
+ while (!exiting) {
460
+ if (messageQueue.length > 0) {
461
+ const message = messageQueue.shift();
462
+ if (message) {
463
+ for await (const output of claude({
464
+ command: message.content.text,
465
+ workingDirectory: opts.path,
466
+ model: opts.model,
467
+ permissionMode: opts.permissionMode
468
+ })) {
469
+ if (output.type === "exit") {
470
+ if (output.code !== 0 || output.code === void 0) {
471
+ session.sendMessage({
472
+ content: {
473
+ type: "error",
474
+ error: output.error,
475
+ code: output.code
476
+ },
477
+ role: "assistant"
478
+ });
479
+ }
480
+ break;
481
+ }
482
+ if (output.type === "json") {
483
+ session.sendMessage({
484
+ data: output.data,
485
+ type: "output"
486
+ });
487
+ }
488
+ }
489
+ }
490
+ }
491
+ await new Promise((resolve) => {
492
+ messageResolve = resolve;
493
+ });
494
+ }
495
+ })();
496
+ return async () => {
497
+ exiting = true;
498
+ if (messageResolve) {
499
+ messageResolve();
500
+ }
501
+ await promise;
502
+ };
503
+ }
504
+
505
+ async function start(options = {}) {
506
+ const workingDirectory = process.cwd();
507
+ const projectName = basename(workingDirectory);
508
+ const sessionTag = randomUUID();
509
+ logger.info(`Starting happy session for project: ${projectName}`);
510
+ const secret = await getOrCreateSecretKey();
511
+ logger.info("Secret key loaded");
512
+ const token = await authGetToken(secret);
513
+ logger.info("Authenticated with handy server");
514
+ const api = new ApiClient(token, secret);
515
+ const response = await api.getOrCreateSession(sessionTag);
516
+ logger.info(`Session created: ${response.session.id}`);
517
+ const handyUrl = generateAppUrl(secret);
518
+ displayQRCode(handyUrl);
519
+ const session = api.session(response.session.id);
520
+ const loopDestroy = startClaudeLoop({ path: workingDirectory }, session);
521
+ const shutdown = async () => {
522
+ logger.info("Shutting down...");
523
+ await loopDestroy();
524
+ await session.close();
525
+ process.exit(0);
526
+ };
527
+ process.on("SIGINT", shutdown);
528
+ process.on("SIGTERM", shutdown);
529
+ logger.info("Happy CLI is running. Press Ctrl+C to stop.");
530
+ await new Promise(() => {
531
+ });
532
+ }
533
+
534
+ const args = process.argv.slice(2);
535
+ const options = {};
536
+ let showHelp = false;
537
+ let showVersion = false;
538
+ for (let i = 0; i < args.length; i++) {
539
+ const arg = args[i];
540
+ if (arg === "-h" || arg === "--help") {
541
+ showHelp = true;
542
+ } else if (arg === "-v" || arg === "--version") {
543
+ showVersion = true;
544
+ } else if (arg === "-m" || arg === "--model") {
545
+ options.model = args[++i];
546
+ } else if (arg === "-p" || arg === "--permission-mode") {
547
+ options.permissionMode = args[++i];
548
+ } else {
549
+ console.error(chalk.red(`Unknown argument: ${arg}`));
550
+ process.exit(1);
551
+ }
552
+ }
553
+ if (showHelp) {
554
+ console.log(`
555
+ ${chalk.bold("happy")} - Claude Code session sharing
556
+
557
+ ${chalk.bold("Usage:")}
558
+ happy [options]
559
+
560
+ ${chalk.bold("Options:")}
561
+ -h, --help Show this help message
562
+ -v, --version Show version
563
+ -m, --model <model> Claude model to use (default: sonnet)
564
+ -p, --permission-mode Permission mode: auto, default, or plan
565
+
566
+ ${chalk.bold("Examples:")}
567
+ happy Start a session with default settings
568
+ happy -m opus Use Claude Opus model
569
+ happy -p plan Use plan permission mode
570
+ `);
571
+ process.exit(0);
572
+ }
573
+ if (showVersion) {
574
+ console.log("0.1.0");
575
+ process.exit(0);
576
+ }
577
+ start(options).catch((error) => {
578
+ console.error(chalk.red("Error:"), error.message);
579
+ if (process.env.DEBUG) {
580
+ console.error(error);
581
+ }
582
+ process.exit(1);
583
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Session service for managing handy-server sessions
3
+ */
4
+ import type { CreateSessionResponse, GetMessagesResponse, SendMessageResponse } from '#session/types';
5
+ export declare class SessionService {
6
+ private readonly serverUrl;
7
+ private readonly authToken;
8
+ constructor(serverUrl: string, authToken: string);
9
+ /**
10
+ * Create a new session or load existing one with the given tag
11
+ */
12
+ createSession(tag: string): Promise<CreateSessionResponse>;
13
+ /**
14
+ * Decrypt a message content
15
+ * Note: In real implementation, this would use proper decryption
16
+ */
17
+ decryptContent(encryptedContent: string): unknown;
18
+ /**
19
+ * Get messages from a session
20
+ */
21
+ getMessages(sessionId: string): Promise<GetMessagesResponse>;
22
+ /**
23
+ * Send a message to a session
24
+ * Note: In real implementation, we'd encrypt the content before sending
25
+ */
26
+ sendMessage(sessionId: string, content: unknown): Promise<SendMessageResponse>;
27
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Session service for managing handy-server sessions
3
+ */
4
+ import { logger } from '#utils/logger';
5
+ import axios from 'axios';
6
+ export class SessionService {
7
+ serverUrl;
8
+ authToken;
9
+ constructor(serverUrl, authToken) {
10
+ this.serverUrl = serverUrl;
11
+ this.authToken = authToken;
12
+ }
13
+ /**
14
+ * Create a new session or load existing one with the given tag
15
+ */
16
+ async createSession(tag) {
17
+ try {
18
+ const response = await axios.post(`${this.serverUrl}/v1/sessions`, { tag }, {
19
+ headers: {
20
+ 'Authorization': `Bearer ${this.authToken}`,
21
+ 'Content-Type': 'application/json'
22
+ }
23
+ });
24
+ logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${tag})`);
25
+ return response.data;
26
+ }
27
+ catch (error) {
28
+ logger.error('Failed to create session:', error);
29
+ throw new Error(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
30
+ }
31
+ }
32
+ /**
33
+ * Decrypt a message content
34
+ * Note: In real implementation, this would use proper decryption
35
+ */
36
+ decryptContent(encryptedContent) {
37
+ try {
38
+ const jsonContent = Buffer.from(encryptedContent, 'base64').toString('utf8');
39
+ return JSON.parse(jsonContent);
40
+ }
41
+ catch (error) {
42
+ logger.error('Failed to decrypt content:', error);
43
+ throw new Error('Failed to decrypt message content');
44
+ }
45
+ }
46
+ /**
47
+ * Get messages from a session
48
+ */
49
+ async getMessages(sessionId) {
50
+ try {
51
+ const response = await axios.get(`${this.serverUrl}/v1/sessions/${sessionId}/messages`, {
52
+ headers: {
53
+ 'Authorization': `Bearer ${this.authToken}`,
54
+ 'Content-Type': 'application/json'
55
+ }
56
+ });
57
+ logger.debug(`Retrieved ${response.data.messages.length} messages from session ${sessionId}`);
58
+ return response.data;
59
+ }
60
+ catch (error) {
61
+ logger.error('Failed to get messages:', error);
62
+ throw new Error(`Failed to get messages: ${error instanceof Error ? error.message : 'Unknown error'}`);
63
+ }
64
+ }
65
+ /**
66
+ * Send a message to a session
67
+ * Note: In real implementation, we'd encrypt the content before sending
68
+ */
69
+ async sendMessage(sessionId, content) {
70
+ try {
71
+ // For now, we'll just base64 encode the JSON content
72
+ // In production, this should use proper encryption
73
+ const jsonContent = JSON.stringify(content);
74
+ const base64Content = Buffer.from(jsonContent).toString('base64');
75
+ const messageContent = {
76
+ c: base64Content,
77
+ t: 'encrypted'
78
+ };
79
+ const response = await axios.post(`${this.serverUrl}/v1/sessions/${sessionId}/messages`, messageContent, {
80
+ headers: {
81
+ 'Authorization': `Bearer ${this.authToken}`,
82
+ 'Content-Type': 'application/json'
83
+ }
84
+ });
85
+ logger.debug(`Message sent to session ${sessionId}, seq: ${response.data.message.seq}`);
86
+ return response.data;
87
+ }
88
+ catch (error) {
89
+ logger.error('Failed to send message:', error);
90
+ throw new Error(`Failed to send message: ${error instanceof Error ? error.message : 'Unknown error'}`);
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Tests for the session service module
3
+ *
4
+ * This test verifies the complete session workflow: create session, send message, read message
5
+ * using the real handy server API. No mocking is used as per project requirements.
6
+ */
7
+ export {};