@deckasoft/waify 0.1.0

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.
@@ -0,0 +1,1380 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/core/paths.ts
13
+ import { join } from "path";
14
+ import { homedir } from "os";
15
+ var dataDir, configPath, promptPath, scheduleJsonPath, schedulePath, logPath, envPath, composePath;
16
+ var init_paths = __esm({
17
+ "src/core/paths.ts"() {
18
+ "use strict";
19
+ dataDir = () => process.env["WAIFY_DATA_DIR"] ?? join(homedir(), ".config", "waify");
20
+ configPath = () => join(dataDir(), "config.json");
21
+ promptPath = () => join(dataDir(), "prompt.json");
22
+ scheduleJsonPath = () => join(dataDir(), "schedule.json");
23
+ schedulePath = () => join(dataDir(), "ofelia.ini");
24
+ logPath = () => join(dataDir(), "messages.log");
25
+ envPath = () => process.env["WAIFY_ENV_PATH"] ?? join(dataDir(), ".env");
26
+ composePath = () => join(dataDir(), "docker-compose.yml");
27
+ }
28
+ });
29
+
30
+ // src/core/config.ts
31
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
32
+ import { dirname } from "path";
33
+ import { z } from "zod";
34
+ var RecipientSchema, ConfigSchema, defaultConfig, loadConfig, saveConfig, assertConfigReady;
35
+ var init_config = __esm({
36
+ "src/core/config.ts"() {
37
+ "use strict";
38
+ init_paths();
39
+ RecipientSchema = z.object({
40
+ chatId: z.string(),
41
+ name: z.string().optional()
42
+ });
43
+ ConfigSchema = z.object({
44
+ openwaBaseUrl: z.string().url().default("http://localhost:2785"),
45
+ openwaSessionId: z.string().nullable().default(null),
46
+ openwaApiKey: z.string().nullable().default(null),
47
+ recipients: z.array(RecipientSchema).max(1).default([])
48
+ });
49
+ defaultConfig = () => ConfigSchema.parse({});
50
+ loadConfig = () => {
51
+ const path = configPath();
52
+ if (!existsSync(path)) return defaultConfig();
53
+ const raw = readFileSync(path, "utf-8");
54
+ return ConfigSchema.parse(JSON.parse(raw));
55
+ };
56
+ saveConfig = (config) => {
57
+ const path = configPath();
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf-8");
60
+ };
61
+ assertConfigReady = (config) => {
62
+ if (!config.openwaSessionId) {
63
+ throw new Error("openwaSessionId is not set. Run `waify config set openwaSessionId <id>` or use the TUI.");
64
+ }
65
+ const [recipient] = config.recipients;
66
+ if (!recipient?.chatId) {
67
+ throw new Error("Run `waify setup` to configure a recipient");
68
+ }
69
+ };
70
+ }
71
+ });
72
+
73
+ // src/core/prompt.ts
74
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
75
+ import { dirname as dirname2 } from "path";
76
+ import { z as z2 } from "zod";
77
+ var PromptSchema, defaultPrompt, loadPrompt, savePrompt, generateMessage;
78
+ var init_prompt = __esm({
79
+ "src/core/prompt.ts"() {
80
+ "use strict";
81
+ init_paths();
82
+ PromptSchema = z2.object({
83
+ systemPrompt: z2.string().min(1),
84
+ examples: z2.array(z2.string().min(1)).min(1)
85
+ });
86
+ defaultPrompt = {
87
+ systemPrompt: [
88
+ "You write short WhatsApp messages from a man to his partner to brighten her day.",
89
+ "Write in casual, natural Spanish \u2014 the way a real person texts, not a greeting card.",
90
+ "Keep it to 1\u20133 sentences. Vary the mood each time: sometimes sweet, sometimes a low-key hype, sometimes playful or funny, sometimes a quick inside-joke vibe, sometimes a chill inspirational nudge.",
91
+ "Never sound formal, poetic, or like a motivational poster.",
92
+ "",
93
+ "Focus on lifting HER day \u2014 her strength, her mood, what's ahead for her. Avoid messages that are mostly about your own feelings or longing."
94
+ ].join("\n"),
95
+ examples: [
96
+ "Hoy vas a romperla, ya lo s\xE9. Y si no, igual te quiero igual.",
97
+ "Alerta cient\xEDfica: estudios demuestran que eres demasiado genial para un d\xEDa normal. Act\xFAa con precauci\xF3n.",
98
+ "Ya te debo un abrazo de esos buenos. Hoy te lo cobro.",
99
+ "Hoy puede ponerse feo. Aguanta, que luego hay algo bueno. Probablemente yo.",
100
+ "Oye, estoy convencido de que hoy vas a hacer algo incre\xEDble. Pero si no, tampoco pasa nada, igual eres mi favorita.",
101
+ "No s\xE9 qu\xE9 traiga el d\xEDa, pero s\xE9 que t\xFA puedes con lo que sea. Y si no, aqu\xED estoy yo."
102
+ ]
103
+ };
104
+ loadPrompt = () => {
105
+ const path = promptPath();
106
+ if (!existsSync2(path)) return defaultPrompt;
107
+ const raw = readFileSync2(path, "utf-8");
108
+ return PromptSchema.parse(JSON.parse(raw));
109
+ };
110
+ savePrompt = (prompt) => {
111
+ const path = promptPath();
112
+ mkdirSync2(dirname2(path), { recursive: true });
113
+ writeFileSync2(path, JSON.stringify(prompt, null, 2) + "\n", "utf-8");
114
+ };
115
+ generateMessage = async ({ provider, prompt }) => provider.generateMessage(prompt.systemPrompt, prompt.examples);
116
+ }
117
+ });
118
+
119
+ // src/core/schedule.ts
120
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
121
+ import { dirname as dirname3, resolve } from "path";
122
+ import { z as z3 } from "zod";
123
+ var ScheduledJobSchema, ScheduleSchema, defaultSchedule, scheduleJsonPath2, loadSchedule, saveSchedule, ofeliaRuntime, renderJob, renderOfeliaIni, regenerateOfeliaIni, addJob, removeJob;
124
+ var init_schedule = __esm({
125
+ "src/core/schedule.ts"() {
126
+ "use strict";
127
+ init_paths();
128
+ ScheduledJobSchema = z3.object({
129
+ name: z3.string().min(1).regex(/^[a-z0-9-]+$/, "name must be lowercase alphanumeric with dashes"),
130
+ schedule: z3.string().min(1),
131
+ command: z3.string().min(1).default("send")
132
+ });
133
+ ScheduleSchema = z3.object({
134
+ jobs: z3.array(ScheduledJobSchema)
135
+ });
136
+ defaultSchedule = {
137
+ jobs: [
138
+ { name: "waify-morning", schedule: "0 0 9 * * *", command: "send" },
139
+ { name: "waify-evening", schedule: "0 0 19 * * *", command: "send" }
140
+ ]
141
+ };
142
+ scheduleJsonPath2 = scheduleJsonPath;
143
+ loadSchedule = () => {
144
+ const path = scheduleJsonPath2();
145
+ if (!existsSync3(path)) return defaultSchedule;
146
+ const raw = readFileSync3(path, "utf-8");
147
+ return ScheduleSchema.parse(JSON.parse(raw));
148
+ };
149
+ saveSchedule = (schedule) => {
150
+ const path = scheduleJsonPath2();
151
+ mkdirSync3(dirname3(path), { recursive: true });
152
+ writeFileSync3(path, JSON.stringify(schedule, null, 2) + "\n", "utf-8");
153
+ regenerateOfeliaIni(schedule);
154
+ };
155
+ ofeliaRuntime = () => ({
156
+ image: process.env["WAIFY_SENDER_IMAGE"] ?? "openwa-scripts-sender:latest",
157
+ network: process.env["WAIFY_NETWORK"] ?? "waify-network",
158
+ hostDataDir: process.env["WAIFY_HOST_DATA_DIR"] ?? resolve(process.cwd(), "data"),
159
+ hostEnvFile: process.env["WAIFY_HOST_ENV_FILE"] ?? resolve(process.cwd(), ".env")
160
+ });
161
+ renderJob = (job, runtime) => [
162
+ `[job-run "${job.name}"]`,
163
+ `schedule = ${job.schedule}`,
164
+ `image = ${runtime.image}`,
165
+ `network = ${runtime.network}`,
166
+ `command = ${job.command}`,
167
+ `volume = ${runtime.hostDataDir}:/data`,
168
+ `volume = ${runtime.hostEnvFile}:/app/.env:ro`
169
+ ].join("\n");
170
+ renderOfeliaIni = (schedule, runtime = ofeliaRuntime()) => {
171
+ const header = [
172
+ "# Generated by waify. Edit via TUI or `waify schedule` subcommands.",
173
+ "# Restart the scheduler service after changes: docker compose restart scheduler",
174
+ "",
175
+ "[global]",
176
+ "save-folder = /tmp/ofelia",
177
+ ""
178
+ ].join("\n");
179
+ const body = schedule.jobs.map((j) => renderJob(j, runtime)).join("\n\n");
180
+ return header + body + "\n";
181
+ };
182
+ regenerateOfeliaIni = (schedule) => {
183
+ const path = schedulePath();
184
+ mkdirSync3(dirname3(path), { recursive: true });
185
+ writeFileSync3(path, renderOfeliaIni(schedule), "utf-8");
186
+ };
187
+ addJob = (job) => {
188
+ const current = loadSchedule();
189
+ if (current.jobs.some((j) => j.name === job.name)) {
190
+ throw new Error(`Job with name "${job.name}" already exists`);
191
+ }
192
+ const next = { jobs: [...current.jobs, job] };
193
+ saveSchedule(next);
194
+ return next;
195
+ };
196
+ removeJob = (name) => {
197
+ const current = loadSchedule();
198
+ const next = { jobs: current.jobs.filter((j) => j.name !== name) };
199
+ saveSchedule(next);
200
+ return next;
201
+ };
202
+ }
203
+ });
204
+
205
+ // src/core/secrets.ts
206
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
207
+ import { z as z4 } from "zod";
208
+ var SecretsSchema, parseEnvFile, loadSecrets, tryLoadSecrets, saveSecrets;
209
+ var init_secrets = __esm({
210
+ "src/core/secrets.ts"() {
211
+ "use strict";
212
+ init_paths();
213
+ SecretsSchema = z4.object({
214
+ GEMINI_API_KEY: z4.string().min(1),
215
+ OPENWA_API_KEY: z4.string().min(1)
216
+ });
217
+ parseEnvFile = (content) => content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).reduce((acc, line) => {
218
+ const eq = line.indexOf("=");
219
+ if (eq === -1) return acc;
220
+ const key = line.slice(0, eq).trim();
221
+ const value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
222
+ return { ...acc, [key]: value };
223
+ }, {});
224
+ loadSecrets = () => SecretsSchema.parse(process.env);
225
+ tryLoadSecrets = () => {
226
+ const parsed = SecretsSchema.partial().safeParse(process.env);
227
+ return parsed.success ? parsed.data : {};
228
+ };
229
+ saveSecrets = (next) => {
230
+ const path = envPath();
231
+ const existing = existsSync5(path) ? parseEnvFile(readFileSync4(path, "utf-8")) : {};
232
+ const merged = { ...existing, ...next };
233
+ const body = Object.entries(merged).filter(([, v]) => typeof v === "string" && v.length > 0).map(([k, v]) => `${k}=${v}`).join("\n");
234
+ writeFileSync4(path, body + "\n", "utf-8");
235
+ };
236
+ }
237
+ });
238
+
239
+ // src/core/providers/gemini.ts
240
+ import { GoogleGenAI } from "@google/genai";
241
+ var composeSystemInstruction, createGeminiProvider;
242
+ var init_gemini = __esm({
243
+ "src/core/providers/gemini.ts"() {
244
+ "use strict";
245
+ composeSystemInstruction = (systemPrompt, examples) => {
246
+ const exampleLines = examples.map((e) => `- "${e}"`).join("\n");
247
+ return [
248
+ systemPrompt,
249
+ "",
250
+ "Here are examples of the exact tone and style to match:",
251
+ exampleLines,
252
+ "",
253
+ "Output only the message text \u2014 no quotes, no labels, no explanations."
254
+ ].join("\n");
255
+ };
256
+ createGeminiProvider = ({ apiKey, model = "gemini-2.5-flash" }) => ({
257
+ generateMessage: async (systemPrompt, examples) => {
258
+ const ai = new GoogleGenAI({ apiKey });
259
+ const response = await ai.models.generateContent({
260
+ model,
261
+ contents: "Send the message.",
262
+ config: { systemInstruction: composeSystemInstruction(systemPrompt, examples) }
263
+ });
264
+ const text = response.text;
265
+ if (!text) {
266
+ throw new Error("Unexpected empty response from Gemini");
267
+ }
268
+ return text.trim();
269
+ }
270
+ });
271
+ }
272
+ });
273
+
274
+ // src/core/sender.ts
275
+ import { z as z6 } from "zod";
276
+ var MessageResponseSchema, sendMessage;
277
+ var init_sender = __esm({
278
+ "src/core/sender.ts"() {
279
+ "use strict";
280
+ MessageResponseSchema = z6.object({
281
+ messageId: z6.string(),
282
+ timestamp: z6.number()
283
+ });
284
+ sendMessage = async ({
285
+ baseUrl,
286
+ apiKey,
287
+ sessionId,
288
+ chatId,
289
+ text
290
+ }) => {
291
+ const url = `${baseUrl}/api/sessions/${sessionId}/messages/send-text`;
292
+ const response = await fetch(url, {
293
+ method: "POST",
294
+ headers: {
295
+ "Content-Type": "application/json",
296
+ "X-API-Key": apiKey
297
+ },
298
+ body: JSON.stringify({ chatId, text })
299
+ });
300
+ if (!response.ok) {
301
+ throw new Error(`OpenWA responded with ${response.status}: ${await response.text()}`);
302
+ }
303
+ const data = await response.json();
304
+ MessageResponseSchema.parse(data);
305
+ };
306
+ }
307
+ });
308
+
309
+ // src/core/logger.ts
310
+ import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync5 } from "fs";
311
+ import { dirname as dirname4 } from "path";
312
+ var log, LINE_RE, parseLine, readHistory;
313
+ var init_logger = __esm({
314
+ "src/core/logger.ts"() {
315
+ "use strict";
316
+ init_paths();
317
+ log = (status, detail) => {
318
+ const path = logPath();
319
+ mkdirSync5(dirname4(path), { recursive: true });
320
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
321
+ const line = `[${timestamp}] ${status} | ${detail}
322
+ `;
323
+ appendFileSync(path, line, "utf-8");
324
+ };
325
+ LINE_RE = /^\[([^\]]+)\]\s+(sent|error)\s+\|\s+(.*)$/;
326
+ parseLine = (line) => {
327
+ const match = LINE_RE.exec(line);
328
+ if (!match) return null;
329
+ const [, timestamp, status, detail] = match;
330
+ if (!timestamp || !status || detail === void 0) return null;
331
+ return { timestamp, status, detail };
332
+ };
333
+ readHistory = (limit) => {
334
+ const path = logPath();
335
+ if (!existsSync7(path)) return [];
336
+ const lines = readFileSync5(path, "utf-8").split("\n").filter((l) => l.length > 0);
337
+ const entries = lines.map(parseLine).filter((e) => e !== null);
338
+ return limit ? entries.slice(-limit) : entries;
339
+ };
340
+ }
341
+ });
342
+
343
+ // src/tui/screens/Home.tsx
344
+ import { Box, Text, useInput } from "ink";
345
+ import Spinner from "ink-spinner";
346
+ import { useState } from "react";
347
+ import { jsx, jsxs } from "react/jsx-runtime";
348
+ var Home;
349
+ var init_Home = __esm({
350
+ "src/tui/screens/Home.tsx"() {
351
+ "use strict";
352
+ init_config();
353
+ init_secrets();
354
+ init_prompt();
355
+ init_gemini();
356
+ init_sender();
357
+ init_logger();
358
+ init_schedule();
359
+ Home = () => {
360
+ const [state, setState] = useState({ kind: "idle" });
361
+ const cfg = loadConfig();
362
+ const secrets = tryLoadSecrets();
363
+ const schedule = loadSchedule();
364
+ const history = readHistory(1);
365
+ const lastSent = history[0];
366
+ const hasSecrets = Boolean(secrets.GEMINI_API_KEY && secrets.OPENWA_API_KEY);
367
+ const hasConfig = Boolean(cfg.openwaSessionId && cfg.recipients[0]?.chatId);
368
+ const doPreview = async () => {
369
+ if (!secrets.GEMINI_API_KEY) {
370
+ setState({ kind: "error", message: "GEMINI_API_KEY missing in .env" });
371
+ return;
372
+ }
373
+ setState({ kind: "busy", label: "Generating preview" });
374
+ try {
375
+ const prompt = loadPrompt();
376
+ const provider = createGeminiProvider({ apiKey: secrets.GEMINI_API_KEY });
377
+ const text = await generateMessage({ provider, prompt });
378
+ setState({ kind: "preview", text });
379
+ } catch (err) {
380
+ setState({ kind: "error", message: err instanceof Error ? err.message : String(err) });
381
+ }
382
+ };
383
+ const doSend = async () => {
384
+ try {
385
+ assertConfigReady(cfg);
386
+ } catch (err) {
387
+ setState({ kind: "error", message: err instanceof Error ? err.message : String(err) });
388
+ return;
389
+ }
390
+ if (!hasSecrets) {
391
+ setState({ kind: "error", message: "Secrets missing in .env" });
392
+ return;
393
+ }
394
+ setState({ kind: "busy", label: "Generating + sending" });
395
+ try {
396
+ const prompt = loadPrompt();
397
+ const provider = createGeminiProvider({ apiKey: secrets.GEMINI_API_KEY ?? "" });
398
+ const text = await generateMessage({ provider, prompt });
399
+ await sendMessage({
400
+ baseUrl: cfg.openwaBaseUrl,
401
+ apiKey: secrets.OPENWA_API_KEY ?? "",
402
+ sessionId: cfg.openwaSessionId ?? "",
403
+ chatId: cfg.recipients[0]?.chatId ?? "",
404
+ text
405
+ });
406
+ log("sent", text.slice(0, 80));
407
+ setState({ kind: "sent", text });
408
+ } catch (err) {
409
+ const message = err instanceof Error ? err.message : String(err);
410
+ log("error", message);
411
+ setState({ kind: "error", message });
412
+ }
413
+ };
414
+ useInput((input) => {
415
+ if (state.kind === "busy") return;
416
+ if (input === "p") void doPreview();
417
+ if (input === "s") void doSend();
418
+ });
419
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, children: [
420
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
421
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Status" }),
422
+ /* @__PURE__ */ jsxs(Text, { children: [
423
+ " Secrets: ",
424
+ hasSecrets ? /* @__PURE__ */ jsx(Text, { color: "green", children: "ok" }) : /* @__PURE__ */ jsx(Text, { color: "red", children: "missing" })
425
+ ] }),
426
+ /* @__PURE__ */ jsxs(Text, { children: [
427
+ " Config: ",
428
+ hasConfig ? /* @__PURE__ */ jsx(Text, { color: "green", children: "ok" }) : /* @__PURE__ */ jsx(Text, { color: "red", children: "incomplete" })
429
+ ] }),
430
+ /* @__PURE__ */ jsxs(Text, { children: [
431
+ " Jobs: ",
432
+ schedule.jobs.length
433
+ ] }),
434
+ /* @__PURE__ */ jsxs(Text, { children: [
435
+ " Last: ",
436
+ lastSent ? `${lastSent.status} @ ${lastSent.timestamp}` : "(none)"
437
+ ] })
438
+ ] }),
439
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
440
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Actions" }),
441
+ /* @__PURE__ */ jsx(Text, { children: " [p] preview \xB7 [s] send now" })
442
+ ] }),
443
+ state.kind === "busy" && /* @__PURE__ */ jsxs(Text, { children: [
444
+ /* @__PURE__ */ jsx(Spinner, { type: "dots" }),
445
+ " ",
446
+ state.label,
447
+ "\u2026"
448
+ ] }),
449
+ state.kind === "preview" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
450
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "yellow", children: "preview" }),
451
+ /* @__PURE__ */ jsx(Text, { children: state.text })
452
+ ] }),
453
+ state.kind === "sent" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [
454
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "green", children: "sent" }),
455
+ /* @__PURE__ */ jsx(Text, { children: state.text })
456
+ ] }),
457
+ state.kind === "error" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [
458
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "red", children: "error" }),
459
+ /* @__PURE__ */ jsx(Text, { children: state.message })
460
+ ] })
461
+ ] });
462
+ };
463
+ }
464
+ });
465
+
466
+ // src/tui/screens/History.tsx
467
+ import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
468
+ import { useMemo, useState as useState2 } from "react";
469
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
470
+ var PAGE_SIZE, History;
471
+ var init_History = __esm({
472
+ "src/tui/screens/History.tsx"() {
473
+ "use strict";
474
+ init_logger();
475
+ PAGE_SIZE = 15;
476
+ History = () => {
477
+ const entries = useMemo(() => readHistory(), []);
478
+ const [offset, setOffset] = useState2(Math.max(0, entries.length - PAGE_SIZE));
479
+ useInput2((input, key) => {
480
+ if (key.upArrow || input === "k") setOffset((o) => Math.max(0, o - 1));
481
+ if (key.downArrow || input === "j") setOffset((o) => Math.min(Math.max(0, entries.length - PAGE_SIZE), o + 1));
482
+ if (key.pageUp) setOffset((o) => Math.max(0, o - PAGE_SIZE));
483
+ if (key.pageDown) setOffset((o) => Math.min(Math.max(0, entries.length - PAGE_SIZE), o + PAGE_SIZE));
484
+ });
485
+ const slice = entries.slice(offset, offset + PAGE_SIZE);
486
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
487
+ /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
488
+ "History (",
489
+ entries.length,
490
+ " entries)"
491
+ ] }),
492
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2191/\u2193 or j/k scroll \xB7 PgUp/PgDn page" }),
493
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, flexDirection: "column", children: slice.length === 0 ? /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "(no entries yet)" }) : slice.map((e, i) => /* @__PURE__ */ jsxs2(Text2, { children: [
494
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: e.timestamp }),
495
+ " ",
496
+ /* @__PURE__ */ jsx2(Text2, { color: e.status === "sent" ? "green" : "red", children: e.status }),
497
+ " ",
498
+ e.detail
499
+ ] }, offset + i)) }),
500
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
501
+ "showing ",
502
+ offset + 1,
503
+ "-",
504
+ Math.min(offset + PAGE_SIZE, entries.length),
505
+ " of ",
506
+ entries.length
507
+ ] })
508
+ ] });
509
+ };
510
+ }
511
+ });
512
+
513
+ // src/tui/screens/Settings.tsx
514
+ import { Box as Box3, Text as Text3, useInput as useInput3 } from "ink";
515
+ import TextInput from "ink-text-input";
516
+ import { useEffect, useState as useState3 } from "react";
517
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
518
+ var buildFields, Settings;
519
+ var init_Settings = __esm({
520
+ "src/tui/screens/Settings.tsx"() {
521
+ "use strict";
522
+ init_config();
523
+ init_secrets();
524
+ buildFields = () => {
525
+ const cfg = loadConfig();
526
+ const secrets = tryLoadSecrets();
527
+ return [
528
+ { key: "openwaBaseUrl", label: "openwaBaseUrl", group: "config", value: cfg.openwaBaseUrl, secret: false },
529
+ { key: "openwaSessionId", label: "openwaSessionId", group: "config", value: cfg.openwaSessionId ?? "", secret: false },
530
+ { key: "openwaApiKey", label: "openwaApiKey", group: "config", value: cfg.openwaApiKey ?? "", secret: false },
531
+ { key: "recipientChatId", label: "recipients[0].chatId", group: "recipient", value: cfg.recipients[0]?.chatId ?? "", secret: false },
532
+ { key: "GEMINI_API_KEY", label: "GEMINI_API_KEY", group: "secret", value: secrets.GEMINI_API_KEY ?? "", secret: true },
533
+ { key: "OPENWA_API_KEY", label: "OPENWA_API_KEY", group: "secret", value: secrets.OPENWA_API_KEY ?? "", secret: true }
534
+ ];
535
+ };
536
+ Settings = ({ onFocusChange }) => {
537
+ const [fields, setFields] = useState3(buildFields);
538
+ const [cursor, setCursor] = useState3(0);
539
+ const [editing, setEditing] = useState3(false);
540
+ const [draft, setDraft] = useState3("");
541
+ const [message, setMessage] = useState3(null);
542
+ useEffect(() => {
543
+ onFocusChange(editing);
544
+ }, [editing, onFocusChange]);
545
+ useInput3((input, key) => {
546
+ if (editing) {
547
+ if (key.escape) {
548
+ setEditing(false);
549
+ setDraft("");
550
+ }
551
+ return;
552
+ }
553
+ if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1));
554
+ if (key.downArrow || input === "j") setCursor((c) => Math.min(fields.length - 1, c + 1));
555
+ if (key.return || input === "e") {
556
+ const field = fields[cursor];
557
+ if (field) {
558
+ setDraft(field.value);
559
+ setEditing(true);
560
+ }
561
+ }
562
+ });
563
+ const commit = (value) => {
564
+ const field = fields[cursor];
565
+ if (!field) return;
566
+ try {
567
+ if (field.group === "secret") {
568
+ const parsed = SecretsSchema.partial().parse({ [field.key]: value });
569
+ saveSecrets(parsed);
570
+ } else if (field.group === "recipient") {
571
+ const cfg = loadConfig();
572
+ const existing = cfg.recipients[0];
573
+ const next = ConfigSchema.parse({
574
+ ...cfg,
575
+ recipients: [{ ...existing, chatId: value }]
576
+ });
577
+ saveConfig(next);
578
+ } else {
579
+ const cfg = loadConfig();
580
+ const next = ConfigSchema.parse({ ...cfg, [field.key]: value || null });
581
+ saveConfig(next);
582
+ }
583
+ setFields(buildFields());
584
+ setMessage(`saved ${field.label}`);
585
+ } catch (err) {
586
+ setMessage(err instanceof Error ? err.message : String(err));
587
+ }
588
+ setEditing(false);
589
+ setDraft("");
590
+ };
591
+ const renderValue = (f) => {
592
+ if (!f.value) return /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "(unset)" });
593
+ if (f.secret) return /* @__PURE__ */ jsxs3(Text3, { children: [
594
+ f.value.slice(0, 4),
595
+ "\u2026"
596
+ ] });
597
+ return /* @__PURE__ */ jsx3(Text3, { children: f.value });
598
+ };
599
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
600
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Settings" }),
601
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u2191/\u2193 to move \xB7 enter to edit \xB7 esc to cancel" }),
602
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, flexDirection: "column", children: fields.map((f, i) => /* @__PURE__ */ jsxs3(Box3, { children: [
603
+ /* @__PURE__ */ jsx3(Box3, { width: 24, children: /* @__PURE__ */ jsxs3(Text3, { color: i === cursor ? "cyan" : void 0, children: [
604
+ i === cursor ? "\u25B8 " : " ",
605
+ f.label
606
+ ] }) }),
607
+ /* @__PURE__ */ jsx3(Text3, { children: " = " }),
608
+ editing && i === cursor ? /* @__PURE__ */ jsx3(TextInput, { value: draft, onChange: setDraft, onSubmit: commit }) : renderValue(f)
609
+ ] }, f.key)) }),
610
+ message && /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "green", children: message }) })
611
+ ] });
612
+ };
613
+ }
614
+ });
615
+
616
+ // src/tui/screens/Prompt.tsx
617
+ import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
618
+ import TextInput2 from "ink-text-input";
619
+ import { useEffect as useEffect2, useState as useState4 } from "react";
620
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
621
+ var PromptScreen;
622
+ var init_Prompt = __esm({
623
+ "src/tui/screens/Prompt.tsx"() {
624
+ "use strict";
625
+ init_prompt();
626
+ PromptScreen = ({ onFocusChange }) => {
627
+ const [prompt, setPrompt] = useState4(loadPrompt);
628
+ const [cursor, setCursor] = useState4(0);
629
+ const [adding, setAdding] = useState4(false);
630
+ const [draft, setDraft] = useState4("");
631
+ const [message, setMessage] = useState4(null);
632
+ useEffect2(() => {
633
+ onFocusChange(adding);
634
+ }, [adding, onFocusChange]);
635
+ useInput4((input, key) => {
636
+ if (adding) {
637
+ if (key.escape) {
638
+ setAdding(false);
639
+ setDraft("");
640
+ }
641
+ return;
642
+ }
643
+ if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1));
644
+ if (key.downArrow || input === "j") setCursor((c) => Math.min(prompt.examples.length - 1, c + 1));
645
+ if (input === "a") {
646
+ setDraft("");
647
+ setAdding(true);
648
+ }
649
+ if (input === "d") {
650
+ if (prompt.examples.length <= 1) {
651
+ setMessage("cannot delete: at least one example required");
652
+ return;
653
+ }
654
+ const next = { ...prompt, examples: prompt.examples.filter((_, i) => i !== cursor) };
655
+ savePrompt(next);
656
+ setPrompt(next);
657
+ setCursor((c) => Math.max(0, Math.min(next.examples.length - 1, c)));
658
+ setMessage(`deleted example #${cursor + 1}`);
659
+ }
660
+ });
661
+ const commit = (value) => {
662
+ if (value.trim().length === 0) {
663
+ setAdding(false);
664
+ setDraft("");
665
+ return;
666
+ }
667
+ const next = { ...prompt, examples: [...prompt.examples, value.trim()] };
668
+ savePrompt(next);
669
+ setPrompt(next);
670
+ setAdding(false);
671
+ setDraft("");
672
+ setMessage(`added example #${next.examples.length}`);
673
+ };
674
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
675
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Prompt" }),
676
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "system prompt is shown read-only here; edit via `wife prompt edit`" }),
677
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, borderStyle: "single", paddingX: 1, flexDirection: "column", children: prompt.systemPrompt.split("\n").map((line, i) => /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: line || " " }, i)) }),
678
+ /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexDirection: "column", children: [
679
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
680
+ "Examples (",
681
+ prompt.examples.length,
682
+ ")"
683
+ ] }),
684
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191/\u2193 to move \xB7 [a]dd \xB7 [d]elete" }),
685
+ prompt.examples.map((e, i) => /* @__PURE__ */ jsxs4(Text4, { color: i === cursor ? "cyan" : void 0, children: [
686
+ i === cursor ? "\u25B8 " : " ",
687
+ i + 1,
688
+ ". ",
689
+ e
690
+ ] }, i)),
691
+ adding && /* @__PURE__ */ jsxs4(Box4, { children: [
692
+ /* @__PURE__ */ jsx4(Text4, { children: "+ " }),
693
+ /* @__PURE__ */ jsx4(TextInput2, { value: draft, onChange: setDraft, onSubmit: commit })
694
+ ] })
695
+ ] }),
696
+ message && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { color: "green", children: message }) })
697
+ ] });
698
+ };
699
+ }
700
+ });
701
+
702
+ // src/tui/screens/Schedule.tsx
703
+ import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
704
+ import TextInput3 from "ink-text-input";
705
+ import { useEffect as useEffect3, useState as useState5 } from "react";
706
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
707
+ var ScheduleScreen;
708
+ var init_Schedule = __esm({
709
+ "src/tui/screens/Schedule.tsx"() {
710
+ "use strict";
711
+ init_schedule();
712
+ ScheduleScreen = ({ onFocusChange }) => {
713
+ const [schedule, setSchedule] = useState5(loadSchedule);
714
+ const [cursor, setCursor] = useState5(0);
715
+ const [addStep, setAddStep] = useState5(null);
716
+ const [draftName, setDraftName] = useState5("");
717
+ const [draftCron, setDraftCron] = useState5("");
718
+ const [message, setMessage] = useState5(null);
719
+ useEffect3(() => {
720
+ onFocusChange(addStep !== null);
721
+ }, [addStep, onFocusChange]);
722
+ useInput5((input, key) => {
723
+ if (addStep !== null) {
724
+ if (key.escape) {
725
+ setAddStep(null);
726
+ setDraftName("");
727
+ setDraftCron("");
728
+ }
729
+ return;
730
+ }
731
+ if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1));
732
+ if (key.downArrow || input === "j") setCursor((c) => Math.min(schedule.jobs.length - 1, c + 1));
733
+ if (input === "a") {
734
+ setDraftName("");
735
+ setDraftCron("");
736
+ setAddStep("name");
737
+ }
738
+ if (input === "d") {
739
+ const job = schedule.jobs[cursor];
740
+ if (!job) return;
741
+ try {
742
+ const next = removeJob(job.name);
743
+ setSchedule(next);
744
+ setCursor((c) => Math.max(0, Math.min(next.jobs.length - 1, c)));
745
+ setMessage(`removed "${job.name}" \u2014 restart scheduler to apply`);
746
+ } catch (err) {
747
+ setMessage(err instanceof Error ? err.message : String(err));
748
+ }
749
+ }
750
+ });
751
+ const submitName = (value) => {
752
+ if (value.trim().length === 0) {
753
+ setAddStep(null);
754
+ return;
755
+ }
756
+ setDraftName(value.trim());
757
+ setAddStep("cron");
758
+ };
759
+ const submitCron = (value) => {
760
+ if (value.trim().length === 0) {
761
+ setAddStep(null);
762
+ setDraftName("");
763
+ return;
764
+ }
765
+ try {
766
+ const job = ScheduledJobSchema.parse({ name: draftName, schedule: value.trim(), command: "send" });
767
+ const next = addJob(job);
768
+ setSchedule(next);
769
+ setMessage(`added "${job.name}" \u2014 restart scheduler to apply`);
770
+ } catch (err) {
771
+ setMessage(err instanceof Error ? err.message : String(err));
772
+ }
773
+ setAddStep(null);
774
+ setDraftName("");
775
+ setDraftCron("");
776
+ };
777
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
778
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Schedule" }),
779
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2191/\u2193 to move \xB7 [a]dd \xB7 [d]elete \xB7 changes require: docker compose restart scheduler" }),
780
+ /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
781
+ schedule.jobs.length === 0 ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "(no jobs)" }) : schedule.jobs.map((j, i) => /* @__PURE__ */ jsxs5(Text5, { color: i === cursor ? "cyan" : void 0, children: [
782
+ i === cursor ? "\u25B8 " : " ",
783
+ j.name.padEnd(20),
784
+ " ",
785
+ j.schedule.padEnd(16),
786
+ " \u2192 ",
787
+ j.command
788
+ ] }, j.name)),
789
+ addStep === "name" && /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, children: [
790
+ /* @__PURE__ */ jsx5(Text5, { children: "+ name: " }),
791
+ /* @__PURE__ */ jsx5(TextInput3, { value: draftName, onChange: setDraftName, onSubmit: submitName })
792
+ ] }),
793
+ addStep === "cron" && /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, children: [
794
+ /* @__PURE__ */ jsxs5(Text5, { children: [
795
+ "+ ",
796
+ draftName,
797
+ " cron: "
798
+ ] }),
799
+ /* @__PURE__ */ jsx5(TextInput3, { value: draftCron, onChange: setDraftCron, onSubmit: submitCron, placeholder: "0 9 * * *" })
800
+ ] })
801
+ ] }),
802
+ message && /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "green", children: message }) })
803
+ ] });
804
+ };
805
+ }
806
+ });
807
+
808
+ // src/tui/App.tsx
809
+ import { Box as Box6, Text as Text6, useApp, useInput as useInput6 } from "ink";
810
+ import { useState as useState6 } from "react";
811
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
812
+ var TABS, TabBar, App;
813
+ var init_App = __esm({
814
+ "src/tui/App.tsx"() {
815
+ "use strict";
816
+ init_Home();
817
+ init_History();
818
+ init_Settings();
819
+ init_Prompt();
820
+ init_Schedule();
821
+ TABS = [
822
+ { key: "1", label: "Home" },
823
+ { key: "2", label: "History" },
824
+ { key: "3", label: "Settings" },
825
+ { key: "4", label: "Prompt" },
826
+ { key: "5", label: "Schedule" }
827
+ ];
828
+ TabBar = ({ active }) => /* @__PURE__ */ jsx6(Box6, { gap: 2, children: TABS.map((t) => /* @__PURE__ */ jsxs6(Text6, { bold: t.key === active, color: t.key === active ? "cyan" : void 0, children: [
829
+ "[",
830
+ t.key,
831
+ "] ",
832
+ t.label
833
+ ] }, t.key)) });
834
+ App = () => {
835
+ const [tab, setTab] = useState6("1");
836
+ const [focused, setFocused] = useState6(false);
837
+ const { exit } = useApp();
838
+ useInput6((input) => {
839
+ if (focused) return;
840
+ if (input === "q") {
841
+ exit();
842
+ return;
843
+ }
844
+ const found = TABS.find((t) => t.key === input);
845
+ if (found) setTab(found.key);
846
+ });
847
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 1, children: [
848
+ /* @__PURE__ */ jsxs6(Box6, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
849
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: "wife" }),
850
+ /* @__PURE__ */ jsx6(Text6, { children: " \u2014 daily mood-lifter control panel" })
851
+ ] }),
852
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(TabBar, { active: tab }) }),
853
+ /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
854
+ tab === "1" && /* @__PURE__ */ jsx6(Home, {}),
855
+ tab === "2" && /* @__PURE__ */ jsx6(History, {}),
856
+ tab === "3" && /* @__PURE__ */ jsx6(Settings, { onFocusChange: setFocused }),
857
+ tab === "4" && /* @__PURE__ */ jsx6(PromptScreen, { onFocusChange: setFocused }),
858
+ tab === "5" && /* @__PURE__ */ jsx6(ScheduleScreen, { onFocusChange: setFocused })
859
+ ] }),
860
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
861
+ "1-5 switch tabs \xB7 q quit",
862
+ focused ? " \xB7 esc to leave input" : ""
863
+ ] }) })
864
+ ] });
865
+ };
866
+ }
867
+ });
868
+
869
+ // src/tui/start.ts
870
+ var start_exports = {};
871
+ __export(start_exports, {
872
+ startTui: () => startTui
873
+ });
874
+ import "dotenv/config";
875
+ import { render } from "ink";
876
+ import { createElement } from "react";
877
+ var startTui;
878
+ var init_start = __esm({
879
+ "src/tui/start.ts"() {
880
+ "use strict";
881
+ init_App();
882
+ startTui = async () => {
883
+ const instance = render(createElement(App));
884
+ await instance.waitUntilExit();
885
+ };
886
+ }
887
+ });
888
+
889
+ // src/cli/index.ts
890
+ import "dotenv/config";
891
+ import { Command } from "commander";
892
+
893
+ // src/cli/commands/init.ts
894
+ init_paths();
895
+ init_config();
896
+ init_prompt();
897
+ init_schedule();
898
+ import { existsSync as existsSync4 } from "fs";
899
+ var migrateLegacyEnv = () => {
900
+ const base = defaultConfig();
901
+ const legacySessionId = process.env["OPENWA_SESSION_ID"];
902
+ const legacyChatId = process.env["WIFE_CHAT_ID"];
903
+ const recipients = legacyChatId ? [{ chatId: legacyChatId }] : base.recipients;
904
+ const next = ConfigSchema.parse({
905
+ openwaBaseUrl: base.openwaBaseUrl,
906
+ openwaSessionId: legacySessionId ?? base.openwaSessionId,
907
+ openwaApiKey: base.openwaApiKey,
908
+ recipients
909
+ });
910
+ const changed = Boolean(legacySessionId || legacyChatId);
911
+ return { changed, config: next };
912
+ };
913
+ var registerInit = (program2) => {
914
+ program2.command("init").description("Seed data/config.json, data/prompt.json, and data/schedule.json with defaults").option("--force", "overwrite files even if they exist").action(({ force }) => {
915
+ const migration = migrateLegacyEnv();
916
+ const tasks = [
917
+ {
918
+ label: "config.json",
919
+ exists: existsSync4(configPath()),
920
+ run: () => saveConfig(migration.config),
921
+ note: migration.changed ? "(migrated from .env legacy keys)" : void 0
922
+ },
923
+ {
924
+ label: "prompt.json",
925
+ exists: existsSync4(promptPath()),
926
+ run: () => savePrompt(defaultPrompt)
927
+ },
928
+ {
929
+ label: "schedule.json + ofelia.ini",
930
+ exists: false,
931
+ run: () => saveSchedule(defaultSchedule)
932
+ }
933
+ ];
934
+ tasks.forEach((t) => {
935
+ if (t.exists && !force) {
936
+ console.warn(`skip ${t.label} (already exists; pass --force to overwrite)`);
937
+ return;
938
+ }
939
+ t.run();
940
+ console.warn(`wrote ${t.label}${t.note ? " " + t.note : ""}`);
941
+ });
942
+ console.warn("\nNext steps:");
943
+ console.warn(" 1. Ensure .env has GEMINI_API_KEY and OPENWA_API_KEY");
944
+ if (!migration.changed) {
945
+ console.warn(" 2. Set the runtime values:");
946
+ console.warn(" waify config set openwaSessionId <uuid from openwa dashboard>");
947
+ console.warn(" waify config set wifeChatId <countrycode+number>@c.us");
948
+ } else {
949
+ console.warn(" 2. Verify migrated values: waify config list");
950
+ }
951
+ console.warn(" 3. Test with `waify preview`, then `waify send`");
952
+ });
953
+ };
954
+
955
+ // src/cli/commands/setup.ts
956
+ init_paths();
957
+ init_config();
958
+ init_secrets();
959
+ init_schedule();
960
+ init_prompt();
961
+ import { spawnSync } from "child_process";
962
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
963
+ import { homedir as homedir2 } from "os";
964
+ import { join as join2 } from "path";
965
+ import { createInterface } from "readline";
966
+ import * as crypto from "crypto";
967
+ import { z as z5 } from "zod";
968
+ import qrcode from "qrcode-terminal";
969
+ var SessionResponseSchema = z5.object({
970
+ id: z5.string().optional(),
971
+ name: z5.string().optional()
972
+ });
973
+ var QrResponseSchema = z5.object({
974
+ qr: z5.string().optional()
975
+ });
976
+ var StatusResponseSchema = z5.object({
977
+ status: z5.string().optional(),
978
+ connected: z5.boolean().optional()
979
+ });
980
+ var wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
981
+ var composeTemplate = (openwaApiKey) => `services:
982
+ openwa-api:
983
+ image: ghcr.io/deckasoft/openwa:latest
984
+ ports:
985
+ - '2785:3000'
986
+ environment:
987
+ - NODE_ENV=production
988
+ - OPENWA_API_KEY=${openwaApiKey}
989
+ volumes:
990
+ - openwa-data:/app/data
991
+ restart: unless-stopped
992
+
993
+ openwa-dashboard:
994
+ image: ghcr.io/deckasoft/openwa:latest
995
+ ports:
996
+ - '2886:4000'
997
+ environment:
998
+ - NODE_ENV=production
999
+ - OPENWA_API_KEY=${openwaApiKey}
1000
+ restart: unless-stopped
1001
+
1002
+ volumes:
1003
+ openwa-data:
1004
+ `;
1005
+ var promptLine = (rl, question) => new Promise((resolve2) => rl.question(question, resolve2));
1006
+ var registerSetup = (program2) => {
1007
+ program2.command("setup").description("Guided first-run wizard: installs OpenWA, authenticates WhatsApp, and configures waify").action(async () => {
1008
+ console.warn("Checking Docker...");
1009
+ const dockerCheck = spawnSync("docker", ["info"], { stdio: "pipe" });
1010
+ if (dockerCheck.status !== 0) {
1011
+ console.error("Docker is not running or not installed. Please install Docker and start it before running setup.");
1012
+ process.exit(1);
1013
+ }
1014
+ console.warn("Creating config directory...");
1015
+ mkdirSync4(join2(homedir2(), ".config", "waify"), { recursive: true });
1016
+ console.warn("Generating credentials...");
1017
+ const openwaApiKey = crypto.randomUUID();
1018
+ console.warn("Writing docker-compose.yml...");
1019
+ writeFileSync5(composePath(), composeTemplate(openwaApiKey), "utf-8");
1020
+ console.warn("Starting OpenWA containers (this may take a minute on first run)...");
1021
+ const upResult = spawnSync("docker", ["compose", "-f", composePath(), "up", "-d"], {
1022
+ stdio: "inherit"
1023
+ });
1024
+ if (upResult.status !== 0) {
1025
+ console.error("Failed to start OpenWA containers. Check docker compose logs for details.");
1026
+ process.exit(1);
1027
+ }
1028
+ console.warn("Waiting for OpenWA API to start...");
1029
+ let apiReady = false;
1030
+ for (let attempt = 0; attempt < 30; attempt++) {
1031
+ try {
1032
+ const res = await fetch("http://localhost:2785/api");
1033
+ if (res.status >= 200 && res.status < 300) {
1034
+ apiReady = true;
1035
+ break;
1036
+ }
1037
+ } catch {
1038
+ }
1039
+ await wait(2e3);
1040
+ }
1041
+ if (!apiReady) {
1042
+ console.error(
1043
+ "OpenWA API did not become ready in time. Check logs with: docker compose -f " + composePath() + " logs openwa-api"
1044
+ );
1045
+ process.exit(1);
1046
+ }
1047
+ console.warn("Creating WhatsApp session...");
1048
+ const sessionRes = await fetch("http://localhost:2785/api/sessions", {
1049
+ method: "POST",
1050
+ headers: {
1051
+ "X-API-Key": openwaApiKey,
1052
+ "Content-Type": "application/json"
1053
+ },
1054
+ body: JSON.stringify({ name: "waify" })
1055
+ });
1056
+ if (!sessionRes.ok) {
1057
+ throw new Error(`Failed to create session: ${sessionRes.status} ${sessionRes.statusText}`);
1058
+ }
1059
+ const sessionData = SessionResponseSchema.parse(await sessionRes.json());
1060
+ const sessionId = sessionData.id ?? sessionData.name ?? "waify";
1061
+ saveConfig({ ...loadConfig(), openwaApiKey, openwaSessionId: sessionId });
1062
+ console.warn("\u{1F4F1} Scan the QR code below with WhatsApp on your phone:");
1063
+ console.warn(" (Settings \u2192 Linked Devices \u2192 Link a Device)");
1064
+ const qrRes = await fetch("http://localhost:2785/api/sessions/waify/qr", {
1065
+ headers: { "X-API-Key": openwaApiKey }
1066
+ });
1067
+ if (!qrRes.ok) {
1068
+ console.warn(" Could not fetch QR code \u2014 use the browser link below.");
1069
+ }
1070
+ const qrData = qrRes.ok ? QrResponseSchema.parse(await qrRes.json()) : { qr: void 0 };
1071
+ const rawQr = qrData.qr ?? "";
1072
+ const qrString = rawQr.startsWith("data:image/png;base64,") ? rawQr.slice("data:image/png;base64,".length) : rawQr;
1073
+ qrcode.generate(qrString, { small: true });
1074
+ console.warn(" Or open in browser: http://localhost:2886");
1075
+ let connected = false;
1076
+ for (let attempt = 0; attempt < 60; attempt++) {
1077
+ try {
1078
+ const statusRes = await fetch("http://localhost:2785/api/sessions/waify", {
1079
+ headers: { "X-API-Key": openwaApiKey }
1080
+ });
1081
+ const parsed = StatusResponseSchema.safeParse(await statusRes.json());
1082
+ if (!parsed.success) continue;
1083
+ const statusData = parsed.data;
1084
+ if (statusData.status === "CONNECTED" || statusData.connected === true) {
1085
+ connected = true;
1086
+ break;
1087
+ }
1088
+ } catch {
1089
+ }
1090
+ await wait(2e3);
1091
+ }
1092
+ if (!connected) {
1093
+ console.error("WhatsApp did not connect within 2 minutes. Please re-run `waify setup` to try again.");
1094
+ process.exit(1);
1095
+ }
1096
+ console.warn("\u2713 WhatsApp connected!");
1097
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1098
+ try {
1099
+ let geminiKey = "";
1100
+ while (!geminiKey.trim()) {
1101
+ geminiKey = await promptLine(
1102
+ rl,
1103
+ "Enter your Gemini API key (get one free at https://aistudio.google.com/apikey):\n> "
1104
+ );
1105
+ if (!geminiKey.trim()) {
1106
+ console.warn("Gemini API key cannot be empty. Please try again.");
1107
+ }
1108
+ }
1109
+ saveSecrets({ GEMINI_API_KEY: geminiKey.trim() });
1110
+ let recipientNumber = "";
1111
+ const phoneRegex = /^\d{8,15}$/;
1112
+ while (!phoneRegex.test(recipientNumber.trim())) {
1113
+ recipientNumber = await promptLine(
1114
+ rl,
1115
+ "Enter your recipient's WhatsApp number (e.g. 5511999998888 \u2014 digits only, no + or spaces):\n> "
1116
+ );
1117
+ if (!phoneRegex.test(recipientNumber.trim())) {
1118
+ console.warn("Invalid number format. Use digits only, 8\u201315 characters. Please try again.");
1119
+ }
1120
+ }
1121
+ const chatId = `${recipientNumber.trim()}@c.us`;
1122
+ saveConfig({ ...loadConfig(), recipients: [{ chatId }] });
1123
+ } finally {
1124
+ rl.close();
1125
+ }
1126
+ if (!existsSync6(promptPath())) {
1127
+ savePrompt(defaultPrompt);
1128
+ }
1129
+ if (!existsSync6(scheduleJsonPath())) {
1130
+ saveSchedule(defaultSchedule);
1131
+ }
1132
+ console.warn("\u2713 All done! Run `waify send` to send your first message.");
1133
+ });
1134
+ };
1135
+
1136
+ // src/cli/commands/send.ts
1137
+ init_config();
1138
+ init_secrets();
1139
+ init_prompt();
1140
+ init_gemini();
1141
+ init_sender();
1142
+ init_logger();
1143
+ var registerSend = (program2) => {
1144
+ program2.command("send").description("Generate a message via Gemini and send it via WhatsApp").action(async () => {
1145
+ try {
1146
+ const secrets = loadSecrets();
1147
+ const config = loadConfig();
1148
+ assertConfigReady(config);
1149
+ const prompt = loadPrompt();
1150
+ const provider = createGeminiProvider({ apiKey: secrets.GEMINI_API_KEY });
1151
+ const text = await generateMessage({ provider, prompt });
1152
+ await sendMessage({
1153
+ baseUrl: config.openwaBaseUrl,
1154
+ apiKey: secrets.OPENWA_API_KEY,
1155
+ sessionId: config.openwaSessionId ?? "",
1156
+ chatId: config.recipients[0]?.chatId ?? "",
1157
+ text
1158
+ });
1159
+ log("sent", text.slice(0, 80));
1160
+ console.warn(`sent: ${text}`);
1161
+ } catch (err) {
1162
+ const message = err instanceof Error ? err.message : String(err);
1163
+ log("error", message);
1164
+ throw err;
1165
+ }
1166
+ });
1167
+ };
1168
+
1169
+ // src/cli/commands/preview.ts
1170
+ init_secrets();
1171
+ init_prompt();
1172
+ init_gemini();
1173
+ var registerPreview = (program2) => {
1174
+ program2.command("preview").description("Generate a candidate message and print it to stdout (no send)").option("-n, --count <n>", "generate N candidates", "1").action(async ({ count }) => {
1175
+ const secrets = loadSecrets();
1176
+ const prompt = loadPrompt();
1177
+ const provider = createGeminiProvider({ apiKey: secrets.GEMINI_API_KEY });
1178
+ const n = Math.max(1, parseInt(count, 10) || 1);
1179
+ const messages = await Promise.all(
1180
+ Array.from({ length: n }, () => generateMessage({ provider, prompt }))
1181
+ );
1182
+ messages.forEach((m, i) => {
1183
+ if (n > 1) console.warn(`--- candidate ${i + 1} ---`);
1184
+ process.stdout.write(m + "\n");
1185
+ });
1186
+ });
1187
+ };
1188
+
1189
+ // src/cli/commands/history.ts
1190
+ init_logger();
1191
+ var registerHistory = (program2) => {
1192
+ program2.command("history").description("Show entries from messages.log").option("-n, --tail <n>", "show only the last N entries").option("-g, --grep <pattern>", "filter entries by substring (case-insensitive)").action(({ tail, grep }) => {
1193
+ const limit = tail ? Math.max(1, parseInt(tail, 10)) : void 0;
1194
+ const entries = readHistory(limit);
1195
+ const filtered = grep ? entries.filter((e) => e.detail.toLowerCase().includes(grep.toLowerCase())) : entries;
1196
+ if (filtered.length === 0) {
1197
+ console.warn("(no entries)");
1198
+ return;
1199
+ }
1200
+ filtered.forEach((e) => {
1201
+ process.stdout.write(`[${e.timestamp}] ${e.status} | ${e.detail}
1202
+ `);
1203
+ });
1204
+ });
1205
+ };
1206
+
1207
+ // src/cli/commands/config.ts
1208
+ init_config();
1209
+ init_secrets();
1210
+ var SECRET_KEYS = new Set(Object.keys(SecretsSchema.shape));
1211
+ var CONFIG_KEYS = new Set(Object.keys(ConfigSchema.shape).filter((k) => k !== "recipients"));
1212
+ var ALIAS_KEYS = /* @__PURE__ */ new Set(["wifeChatId"]);
1213
+ var registerConfig = (program2) => {
1214
+ const config = program2.command("config").description("Inspect or update settings");
1215
+ config.command("list").description("Print all config and secret keys (secrets masked)").action(() => {
1216
+ const cfg = loadConfig();
1217
+ const secrets = tryLoadSecrets();
1218
+ console.warn("# config.json");
1219
+ Object.entries(cfg).forEach(([k, v]) => {
1220
+ if (k === "recipients") {
1221
+ const chatId = cfg.recipients[0]?.chatId ?? "(unset)";
1222
+ console.warn(` recipients[0].chatId = ${chatId}`);
1223
+ } else {
1224
+ console.warn(` ${k} = ${v ?? "(unset)"}`);
1225
+ }
1226
+ });
1227
+ console.warn("\n# .env (secrets)");
1228
+ Array.from(SECRET_KEYS).forEach((k) => {
1229
+ const v = secrets[k];
1230
+ const display = v ? `${v.slice(0, 4)}\u2026(${v.length} chars)` : "(unset)";
1231
+ console.warn(` ${k} = ${display}`);
1232
+ });
1233
+ });
1234
+ config.command("get <key>").description("Print a single config or secret value").action((key) => {
1235
+ if (SECRET_KEYS.has(key)) {
1236
+ const secrets = tryLoadSecrets();
1237
+ const v = secrets[key];
1238
+ process.stdout.write((v ?? "") + "\n");
1239
+ return;
1240
+ }
1241
+ if (ALIAS_KEYS.has(key)) {
1242
+ const cfg = loadConfig();
1243
+ process.stdout.write((cfg.recipients[0]?.chatId ?? "") + "\n");
1244
+ return;
1245
+ }
1246
+ if (CONFIG_KEYS.has(key)) {
1247
+ const cfg = loadConfig();
1248
+ const v = cfg[key];
1249
+ process.stdout.write((v ?? "") + "\n");
1250
+ return;
1251
+ }
1252
+ const knownKeys = [...CONFIG_KEYS, ...ALIAS_KEYS, ...SECRET_KEYS];
1253
+ throw new Error(`Unknown key: ${key}. Known keys: ${knownKeys.join(", ")}`);
1254
+ });
1255
+ config.command("set <key> <value>").description("Update a config value (config.json) or secret (.env)").action((key, value) => {
1256
+ if (SECRET_KEYS.has(key)) {
1257
+ saveSecrets({ [key]: value });
1258
+ console.warn(`updated secret: ${key}`);
1259
+ return;
1260
+ }
1261
+ if (ALIAS_KEYS.has(key)) {
1262
+ const cfg = loadConfig();
1263
+ const existing = cfg.recipients[0];
1264
+ const next = ConfigSchema.parse({
1265
+ ...cfg,
1266
+ recipients: [{ ...existing, chatId: value }]
1267
+ });
1268
+ saveConfig(next);
1269
+ console.warn(`updated config: recipients[0].chatId = ${value}`);
1270
+ return;
1271
+ }
1272
+ if (CONFIG_KEYS.has(key)) {
1273
+ const cfg = loadConfig();
1274
+ const next = ConfigSchema.parse({ ...cfg, [key]: value });
1275
+ saveConfig(next);
1276
+ console.warn(`updated config: ${key} = ${value}`);
1277
+ return;
1278
+ }
1279
+ const knownKeys = [...CONFIG_KEYS, ...ALIAS_KEYS, ...SECRET_KEYS];
1280
+ throw new Error(`Unknown key: ${key}. Known keys: ${knownKeys.join(", ")}`);
1281
+ });
1282
+ };
1283
+
1284
+ // src/cli/commands/prompt.ts
1285
+ init_paths();
1286
+ init_prompt();
1287
+ import { spawnSync as spawnSync2 } from "child_process";
1288
+ import { existsSync as existsSync8 } from "fs";
1289
+ var registerPrompt = (program2) => {
1290
+ const prompt = program2.command("prompt").description("View or edit the system prompt and examples");
1291
+ prompt.command("show").description("Print the current prompt and examples").action(() => {
1292
+ const p = loadPrompt();
1293
+ console.warn("# system prompt");
1294
+ process.stdout.write(p.systemPrompt + "\n");
1295
+ console.warn("\n# examples");
1296
+ p.examples.forEach((e, i) => process.stdout.write(`${i + 1}. ${e}
1297
+ `));
1298
+ });
1299
+ prompt.command("edit").description("Open prompt.json in $EDITOR").action(() => {
1300
+ const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? "vi";
1301
+ const path = promptPath();
1302
+ if (!existsSync8(path)) {
1303
+ savePrompt(loadPrompt());
1304
+ }
1305
+ const result = spawnSync2(editor, [path], { stdio: "inherit" });
1306
+ if (result.status !== 0) {
1307
+ throw new Error(`Editor exited with status ${result.status}`);
1308
+ }
1309
+ const reloaded = loadPrompt();
1310
+ PromptSchema.parse(reloaded);
1311
+ console.warn("prompt updated and re-validated");
1312
+ });
1313
+ prompt.command("add-example <text>").description("Append a new few-shot example").action((text) => {
1314
+ const current = loadPrompt();
1315
+ savePrompt({ ...current, examples: [...current.examples, text] });
1316
+ console.warn(`added example #${current.examples.length + 1}`);
1317
+ });
1318
+ prompt.command("remove-example <index>").description("Remove the Nth example (1-indexed)").action((index) => {
1319
+ const current = loadPrompt();
1320
+ const i = parseInt(index, 10) - 1;
1321
+ if (Number.isNaN(i) || i < 0 || i >= current.examples.length) {
1322
+ throw new Error(`Index out of range. Have ${current.examples.length} examples.`);
1323
+ }
1324
+ const next = current.examples.filter((_, idx) => idx !== i);
1325
+ savePrompt({ ...current, examples: next });
1326
+ console.warn(`removed example #${i + 1}`);
1327
+ });
1328
+ };
1329
+
1330
+ // src/cli/commands/schedule.ts
1331
+ init_schedule();
1332
+ var registerSchedule = (program2) => {
1333
+ const schedule = program2.command("schedule").description("Manage Ofelia scheduled jobs");
1334
+ schedule.command("list").description("List all scheduled jobs").action(() => {
1335
+ const s = loadSchedule();
1336
+ if (s.jobs.length === 0) {
1337
+ console.warn("(no jobs)");
1338
+ return;
1339
+ }
1340
+ s.jobs.forEach((j) => {
1341
+ process.stdout.write(`${j.name} ${j.schedule} ${j.command}
1342
+ `);
1343
+ });
1344
+ });
1345
+ schedule.command("add <name> <cron>").description('Add a new job. <cron> uses 5-field crontab syntax, e.g. "0 9 * * *"').option("-c, --command <cmd>", "command for the sender container to run", "send").action((name, cron, { command }) => {
1346
+ const job = ScheduledJobSchema.parse({ name, schedule: cron, command });
1347
+ addJob(job);
1348
+ console.warn(`added job "${name}" \u2014 restart scheduler: docker compose restart scheduler`);
1349
+ });
1350
+ schedule.command("remove <name>").description("Remove a job by name").action((name) => {
1351
+ removeJob(name);
1352
+ console.warn(`removed job "${name}" \u2014 restart scheduler: docker compose restart scheduler`);
1353
+ });
1354
+ };
1355
+
1356
+ // src/cli/commands/tui.ts
1357
+ var registerTui = (program2) => {
1358
+ program2.command("tui").description("Launch the interactive terminal UI").action(async () => {
1359
+ const { startTui: startTui2 } = await Promise.resolve().then(() => (init_start(), start_exports));
1360
+ await startTui2();
1361
+ });
1362
+ };
1363
+
1364
+ // src/cli/index.ts
1365
+ var program = new Command();
1366
+ program.name("waify").description("AI-powered daily message sender for WhatsApp").version("0.1.0");
1367
+ registerInit(program);
1368
+ registerSetup(program);
1369
+ registerSend(program);
1370
+ registerPreview(program);
1371
+ registerHistory(program);
1372
+ registerConfig(program);
1373
+ registerPrompt(program);
1374
+ registerSchedule(program);
1375
+ registerTui(program);
1376
+ program.parseAsync(process.argv).catch((err) => {
1377
+ const message = err instanceof Error ? err.message : String(err);
1378
+ console.error(`error: ${message}`);
1379
+ process.exit(1);
1380
+ });