@deckasoft/waify 0.6.1 → 0.7.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.
package/dist/cli/index.js CHANGED
@@ -33,7 +33,7 @@ var init_paths = __esm({
33
33
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
34
34
  import { dirname } from "path";
35
35
  import { z } from "zod";
36
- var RecipientSchema, ConfigSchema, parseConfig, defaultConfig, loadConfig, saveConfig, assertConfigReady;
36
+ var RecipientSchema, LANGUAGES, supportedTimezones, detectTimezone, isValidTimezone, ConfigSchema, parseConfig, defaultConfig, loadConfig, saveConfig, assertConfigReady;
37
37
  var init_config = __esm({
38
38
  "src/core/config.ts"() {
39
39
  "use strict";
@@ -42,11 +42,23 @@ var init_config = __esm({
42
42
  chatId: z.string(),
43
43
  name: z.string().optional()
44
44
  });
45
+ LANGUAGES = ["Spanish", "English", "Portuguese", "French", "German", "Italian"];
46
+ supportedTimezones = () => {
47
+ const supportedValuesOf = Intl.supportedValuesOf;
48
+ const zones = supportedValuesOf ? supportedValuesOf("timeZone") : [];
49
+ return zones.includes("UTC") ? zones : ["UTC", ...zones];
50
+ };
51
+ detectTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
52
+ isValidTimezone = (tz) => supportedTimezones().includes(tz);
45
53
  ConfigSchema = z.object({
46
54
  openwaBaseUrl: z.string().url().default("http://localhost:2785"),
47
55
  openwaSessionId: z.string().nullable().default(null),
48
56
  openwaApiKey: z.string().nullable().default(null),
49
- recipients: z.array(RecipientSchema).max(1).default([])
57
+ recipients: z.array(RecipientSchema).max(1).default([]),
58
+ // Human language name (e.g. 'Spanish') injected into the generation prompt.
59
+ language: z.string().min(1).default("Spanish"),
60
+ // IANA timezone the Ofelia scheduler evaluates cron in (via the TZ env).
61
+ timezone: z.string().min(1).refine(isValidTimezone, { message: "timezone must be a valid IANA zone (e.g. America/Guayaquil)" }).default("UTC")
50
62
  });
51
63
  parseConfig = (raw) => {
52
64
  const source = typeof raw === "object" && raw !== null ? raw : {};
@@ -92,7 +104,7 @@ var init_prompt = __esm({
92
104
  defaultPrompt = {
93
105
  systemPrompt: [
94
106
  "You write short WhatsApp messages from a man to his partner to brighten her day.",
95
- "Write in casual, natural Spanish \u2014 the way a real person texts, not a greeting card.",
107
+ "Write the way a real person texts \u2014 casual and natural, not a greeting card.",
96
108
  "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.",
97
109
  "Never sound formal, poetic, or like a motivational poster.",
98
110
  "",
@@ -118,7 +130,11 @@ var init_prompt = __esm({
118
130
  mkdirSync2(dirname2(path), { recursive: true });
119
131
  writeFileSync2(path, JSON.stringify(prompt, null, 2) + "\n", "utf-8");
120
132
  };
121
- generateMessage = async ({ provider, prompt }) => provider.generateMessage(prompt.systemPrompt, prompt.examples);
133
+ generateMessage = async ({
134
+ provider,
135
+ prompt,
136
+ language
137
+ }) => provider.generateMessage({ systemPrompt: prompt.systemPrompt, examples: prompt.examples, language });
122
138
  }
123
139
  });
124
140
 
@@ -126,7 +142,7 @@ var init_prompt = __esm({
126
142
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
127
143
  import { dirname as dirname3 } from "path";
128
144
  import { z as z3 } from "zod";
129
- var CRON_RANGES, STEP_RE, RANGE_RE, isValidCronField, isValidCron, ScheduledJobSchema, ScheduleSchema, defaultSchedule, scheduleJsonPath2, loadSchedule, saveSchedule, ofeliaRuntime, renderEnv, renderJob, renderOfeliaIni, regenerateOfeliaIni, addJob, removeJob;
145
+ var CRON_RANGES, STEP_RE, RANGE_RE, isValidCronAtom, isValidCronField, isValidCron, DAY_LABELS, DAY_LOOKUP, parseDays, FREQUENCIES, CronBuilderSchema, dowField, buildCron, ScheduledJobSchema, ScheduleSchema, defaultSchedule, scheduleJsonPath2, loadSchedule, saveSchedule, ofeliaRuntime, renderEnv, renderJob, renderOfeliaIni, regenerateOfeliaIni, addJob, removeJob;
130
146
  var init_schedule = __esm({
131
147
  "src/core/schedule.ts"() {
132
148
  "use strict";
@@ -147,16 +163,60 @@ var init_schedule = __esm({
147
163
  ];
148
164
  STEP_RE = /^(\*|\d+)\/\d+$/;
149
165
  RANGE_RE = /^\d+-\d+$/;
150
- isValidCronField = (field, [min, max]) => {
151
- if (field === "*") return true;
152
- if (STEP_RE.test(field) || RANGE_RE.test(field)) return true;
153
- const n = Number(field);
166
+ isValidCronAtom = (atom, [min, max]) => {
167
+ if (atom === "*") return true;
168
+ if (STEP_RE.test(atom) || RANGE_RE.test(atom)) return true;
169
+ const n = Number(atom);
154
170
  return Number.isInteger(n) && n >= min && n <= max;
155
171
  };
172
+ isValidCronField = (field, range) => {
173
+ const atoms = field.split(",");
174
+ return atoms.length > 0 && atoms.every((a) => a.length > 0 && isValidCronAtom(a, range));
175
+ };
156
176
  isValidCron = (value) => {
157
177
  const fields = value.trim().split(/\s+/);
158
178
  return fields.length === 6 && fields.every((f, i) => isValidCronField(f, CRON_RANGES[i]));
159
179
  };
180
+ DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
181
+ DAY_LOOKUP = Object.fromEntries(
182
+ DAY_LABELS.map((label, i) => [label.toLowerCase(), i])
183
+ );
184
+ parseDays = (input) => {
185
+ const tokens = input.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);
186
+ if (tokens.length === 0) return null;
187
+ const nums = tokens.map((t) => {
188
+ if (t in DAY_LOOKUP) return DAY_LOOKUP[t];
189
+ const n = Number(t);
190
+ return Number.isInteger(n) && n >= 0 && n <= 6 ? n : null;
191
+ });
192
+ if (nums.some((n) => n === null)) return null;
193
+ return [...new Set(nums)].sort((a, b) => a - b);
194
+ };
195
+ FREQUENCIES = ["daily", "weekdays", "weekends", "custom"];
196
+ CronBuilderSchema = z3.object({
197
+ hour: z3.number().int().min(0).max(23),
198
+ minute: z3.number().int().min(0).max(59),
199
+ frequency: z3.enum(FREQUENCIES),
200
+ days: z3.array(z3.number().int().min(0).max(6)).optional()
201
+ }).refine((v) => v.frequency !== "custom" || (v.days?.length ?? 0) > 0, {
202
+ message: "custom frequency requires at least one weekday"
203
+ });
204
+ dowField = (frequency, days = []) => {
205
+ switch (frequency) {
206
+ case "daily":
207
+ return "*";
208
+ case "weekdays":
209
+ return "1-5";
210
+ case "weekends":
211
+ return "0,6";
212
+ case "custom":
213
+ return [...new Set(days)].sort((a, b) => a - b).join(",");
214
+ }
215
+ };
216
+ buildCron = (input) => {
217
+ const { hour, minute, frequency, days } = CronBuilderSchema.parse(input);
218
+ return `0 ${minute} ${hour} * * ${dowField(frequency, days)}`;
219
+ };
160
220
  ScheduledJobSchema = z3.object({
161
221
  name: z3.string().min(1).regex(/^[a-z0-9-]+$/, "name must be lowercase alphanumeric with dashes"),
162
222
  schedule: z3.string().min(1).refine(isValidCron, { message: "schedule must be a 6-field cron expression (e.g. 0 0 9 * * *)" }),
@@ -190,7 +250,7 @@ var init_schedule = __esm({
190
250
  hostDataDir: process.env["WAIFY_HOST_DATA_DIR"] ?? dataDir(),
191
251
  apiBaseUrl: process.env["WAIFY_API_INTERNAL_URL"] ?? "http://openwa-api:2785"
192
252
  });
193
- renderEnv = (key, value) => `environment = ${key}\\=${value}`;
253
+ renderEnv = (key, value) => `environment = ${key}=${value}`;
194
254
  renderJob = (job, runtime) => [
195
255
  `[job-run "${job.name}"]`,
196
256
  `schedule = ${job.schedule}`,
@@ -242,8 +302,70 @@ var init_schedule = __esm({
242
302
  }
243
303
  });
244
304
 
305
+ // src/core/compose.ts
306
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
307
+ import { dirname as dirname5 } from "path";
308
+ var composeTemplate, writeCompose;
309
+ var init_compose = __esm({
310
+ "src/core/compose.ts"() {
311
+ "use strict";
312
+ init_paths();
313
+ composeTemplate = (timezone) => `services:
314
+ openwa-api:
315
+ image: ghcr.io/deckasoft/openwa:latest
316
+ ports:
317
+ - '2785:2785'
318
+ environment:
319
+ - NODE_ENV=production
320
+ - PORT=2785
321
+ - DATABASE_TYPE=sqlite
322
+ - DATABASE_SYNCHRONIZE=true
323
+ - ENGINE_TYPE=whatsapp-web.js
324
+ - SESSION_DATA_PATH=/app/data/sessions
325
+ - PUPPETEER_HEADLESS=true
326
+ - PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu
327
+ - STORAGE_TYPE=local
328
+ - STORAGE_LOCAL_PATH=/app/data/media
329
+ - QUEUE_ENABLED=false
330
+ - REDIS_ENABLED=false
331
+ - REDIS_BUILTIN=false
332
+ volumes:
333
+ - openwa-data:/app/data
334
+ networks:
335
+ - waify-network
336
+ restart: unless-stopped
337
+
338
+ scheduler:
339
+ image: mcuadros/ofelia:latest
340
+ depends_on:
341
+ - openwa-api
342
+ command: daemon --config=/etc/ofelia/config.ini
343
+ environment:
344
+ - TZ=${timezone}
345
+ volumes:
346
+ - /var/run/docker.sock:/var/run/docker.sock
347
+ - ${schedulePath()}:/etc/ofelia/config.ini:ro
348
+ networks:
349
+ - waify-network
350
+ restart: unless-stopped
351
+
352
+ volumes:
353
+ openwa-data:
354
+
355
+ networks:
356
+ waify-network:
357
+ name: waify-network
358
+ `;
359
+ writeCompose = (timezone) => {
360
+ const path = composePath();
361
+ mkdirSync5(dirname5(path), { recursive: true });
362
+ writeFileSync5(path, composeTemplate(timezone), "utf-8");
363
+ };
364
+ }
365
+ });
366
+
245
367
  // src/core/secrets.ts
246
- import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
368
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync6 } from "fs";
247
369
  import { z as z4 } from "zod";
248
370
  var SecretsSchema, parseEnvFile, loadSecrets, tryLoadSecrets, saveSecrets;
249
371
  var init_secrets = __esm({
@@ -271,7 +393,7 @@ var init_secrets = __esm({
271
393
  const existing = existsSync5(path) ? parseEnvFile(readFileSync4(path, "utf-8")) : {};
272
394
  const merged = { ...existing, ...next };
273
395
  const body = Object.entries(merged).filter(([, v]) => typeof v === "string" && v.length > 0).map(([k, v]) => `${k}=${v}`).join("\n");
274
- writeFileSync5(path, body + "\n", "utf-8");
396
+ writeFileSync6(path, body + "\n", "utf-8");
275
397
  };
276
398
  }
277
399
  });
@@ -282,7 +404,7 @@ var composeSystemInstruction, createGeminiProvider;
282
404
  var init_gemini = __esm({
283
405
  "src/core/providers/gemini.ts"() {
284
406
  "use strict";
285
- composeSystemInstruction = (systemPrompt, examples) => {
407
+ composeSystemInstruction = (systemPrompt, examples, language) => {
286
408
  const exampleLines = examples.map((e) => `- "${e}"`).join("\n");
287
409
  return [
288
410
  systemPrompt,
@@ -290,16 +412,17 @@ var init_gemini = __esm({
290
412
  "Here are examples of the exact tone and style to match:",
291
413
  exampleLines,
292
414
  "",
415
+ `Write the message in ${language}, regardless of the language of the examples above.`,
293
416
  "Output only the message text \u2014 no quotes, no labels, no explanations."
294
417
  ].join("\n");
295
418
  };
296
419
  createGeminiProvider = ({ apiKey, model = "gemini-2.5-flash" }) => ({
297
- generateMessage: async (systemPrompt, examples) => {
420
+ generateMessage: async ({ systemPrompt, examples, language }) => {
298
421
  const ai = new GoogleGenAI({ apiKey });
299
422
  const response = await ai.models.generateContent({
300
423
  model,
301
424
  contents: "Send the message.",
302
- config: { systemInstruction: composeSystemInstruction(systemPrompt, examples) }
425
+ config: { systemInstruction: composeSystemInstruction(systemPrompt, examples, language) }
303
426
  });
304
427
  const text = response.text;
305
428
  if (!text) {
@@ -347,8 +470,8 @@ var init_sender = __esm({
347
470
  });
348
471
 
349
472
  // src/core/logger.ts
350
- import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync6 } from "fs";
351
- import { dirname as dirname6 } from "path";
473
+ import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync7, readFileSync as readFileSync6 } from "fs";
474
+ import { dirname as dirname7 } from "path";
352
475
  var log, LINE_RE, parseLine, readHistory;
353
476
  var init_logger = __esm({
354
477
  "src/core/logger.ts"() {
@@ -356,7 +479,7 @@ var init_logger = __esm({
356
479
  init_paths();
357
480
  log = (status, detail) => {
358
481
  const path = logPath();
359
- mkdirSync6(dirname6(path), { recursive: true });
482
+ mkdirSync7(dirname7(path), { recursive: true });
360
483
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
361
484
  const line = `[${timestamp}] ${status.toUpperCase()} | ${detail}
362
485
  `;
@@ -380,6 +503,56 @@ var init_logger = __esm({
380
503
  }
381
504
  });
382
505
 
506
+ // src/core/scheduler.ts
507
+ import { spawn } from "child_process";
508
+ var restartScheduler;
509
+ var init_scheduler = __esm({
510
+ "src/core/scheduler.ts"() {
511
+ "use strict";
512
+ init_paths();
513
+ restartScheduler = () => new Promise((resolve) => {
514
+ const child = spawn("docker", [
515
+ "compose",
516
+ "-f",
517
+ composePath(),
518
+ "up",
519
+ "-d",
520
+ "--force-recreate",
521
+ "scheduler"
522
+ ]);
523
+ const chunks = [];
524
+ const collect = (data) => chunks.push(data);
525
+ child.stdout?.on("data", collect);
526
+ child.stderr?.on("data", collect);
527
+ child.on("error", (err) => resolve({ ok: false, output: err.message }));
528
+ child.on(
529
+ "close",
530
+ (code) => resolve({ ok: code === 0, output: Buffer.concat(chunks).toString().trim() })
531
+ );
532
+ });
533
+ }
534
+ });
535
+
536
+ // src/core/session.ts
537
+ var stopSession;
538
+ var init_session = __esm({
539
+ "src/core/session.ts"() {
540
+ "use strict";
541
+ stopSession = async ({ baseUrl, apiKey, sessionId }) => {
542
+ if (!sessionId) {
543
+ throw new Error("No active session to disconnect.");
544
+ }
545
+ const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/stop`, {
546
+ method: "POST",
547
+ headers: { "X-API-Key": apiKey }
548
+ });
549
+ if (!response.ok) {
550
+ throw new Error(`OpenWA responded with ${response.status}: ${await response.text()}`);
551
+ }
552
+ };
553
+ }
554
+ });
555
+
383
556
  // src/tui/screens/Home.tsx
384
557
  import { Box, Text, useInput } from "ink";
385
558
  import Spinner from "ink-spinner";
@@ -394,6 +567,7 @@ var init_Home = __esm({
394
567
  init_prompt();
395
568
  init_gemini();
396
569
  init_sender();
570
+ init_session();
397
571
  init_logger();
398
572
  init_schedule();
399
573
  Home = () => {
@@ -414,7 +588,7 @@ var init_Home = __esm({
414
588
  try {
415
589
  const prompt = loadPrompt();
416
590
  const provider = createGeminiProvider({ apiKey: secrets.GEMINI_API_KEY });
417
- const text = await generateMessage({ provider, prompt });
591
+ const text = await generateMessage({ provider, prompt, language: cfg.language });
418
592
  setState({ kind: "preview", text });
419
593
  } catch (err) {
420
594
  setState({ kind: "error", message: err instanceof Error ? err.message : String(err) });
@@ -435,7 +609,7 @@ var init_Home = __esm({
435
609
  try {
436
610
  const prompt = loadPrompt();
437
611
  const provider = createGeminiProvider({ apiKey: secrets.GEMINI_API_KEY ?? "" });
438
- const text = await generateMessage({ provider, prompt });
612
+ const text = await generateMessage({ provider, prompt, language: cfg.language });
439
613
  await sendMessage({
440
614
  baseUrl: cfg.openwaBaseUrl,
441
615
  apiKey: secrets.OPENWA_API_KEY ?? "",
@@ -451,10 +625,30 @@ var init_Home = __esm({
451
625
  setState({ kind: "error", message });
452
626
  }
453
627
  };
628
+ const doDisconnect = async () => {
629
+ setState({ kind: "busy", label: "Disconnecting WhatsApp" });
630
+ try {
631
+ await stopSession({
632
+ baseUrl: cfg.openwaBaseUrl,
633
+ apiKey: secrets.OPENWA_API_KEY ?? "",
634
+ sessionId: cfg.openwaSessionId ?? ""
635
+ });
636
+ saveConfig({ ...cfg, openwaSessionId: null });
637
+ setState({ kind: "disconnected" });
638
+ } catch (err) {
639
+ setState({ kind: "error", message: err instanceof Error ? err.message : String(err) });
640
+ }
641
+ };
454
642
  useInput((input) => {
455
643
  if (state.kind === "busy") return;
644
+ if (state.kind === "confirm-disconnect") {
645
+ if (input === "y") void doDisconnect();
646
+ else setState({ kind: "idle" });
647
+ return;
648
+ }
456
649
  if (input === "p") void doPreview();
457
650
  if (input === "s") void doSend();
651
+ if (input === "x") setState({ kind: "confirm-disconnect" });
458
652
  });
459
653
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, children: [
460
654
  /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
@@ -478,7 +672,16 @@ var init_Home = __esm({
478
672
  ] }),
479
673
  /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
480
674
  /* @__PURE__ */ jsx(Text, { bold: true, children: "Actions" }),
481
- /* @__PURE__ */ jsx(Text, { children: " [p] preview \xB7 [s] send now" })
675
+ /* @__PURE__ */ jsx(Text, { children: " [p] preview \xB7 [s] send now \xB7 [x] disconnect WhatsApp" })
676
+ ] }),
677
+ state.kind === "confirm-disconnect" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
678
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "yellow", children: "Disconnect WhatsApp?" }),
679
+ /* @__PURE__ */ jsx(Text, { children: "This stops the session. Reconnecting means re-running `waify setup` (scan the QR)." }),
680
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "[y] confirm \xB7 any other key cancels" })
681
+ ] }),
682
+ state.kind === "disconnected" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [
683
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "red", children: "WhatsApp disconnected" }),
684
+ /* @__PURE__ */ jsx(Text, { children: "Run `waify setup` to reconnect." })
482
685
  ] }),
483
686
  state.kind === "busy" && /* @__PURE__ */ jsxs(Text, { children: [
484
687
  /* @__PURE__ */ jsx(Spinner, { type: "dots" }),
@@ -550,42 +753,106 @@ var init_History = __esm({
550
753
  }
551
754
  });
552
755
 
553
- // src/tui/screens/Settings.tsx
756
+ // src/tui/components/SelectList.tsx
554
757
  import { Box as Box3, Text as Text3, useInput as useInput3 } from "ink";
555
758
  import TextInput from "ink-text-input";
556
- import { useEffect, useState as useState3 } from "react";
759
+ import { useState as useState3 } from "react";
557
760
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
558
- var buildFields, Settings;
761
+ var SelectList;
762
+ var init_SelectList = __esm({
763
+ "src/tui/components/SelectList.tsx"() {
764
+ "use strict";
765
+ SelectList = ({ items, onSelect, onCancel, filterable = false, pageSize = 8 }) => {
766
+ const [filter, setFilter] = useState3("");
767
+ const [cursor, setCursor] = useState3(0);
768
+ const filtered = filter ? items.filter((i) => i.toLowerCase().includes(filter.toLowerCase())) : items;
769
+ const active = Math.min(cursor, Math.max(0, filtered.length - 1));
770
+ useInput3((_input, key) => {
771
+ if (key.escape) return onCancel();
772
+ if (key.upArrow) return setCursor((c) => Math.max(0, c - 1));
773
+ if (key.downArrow) return setCursor((c) => Math.min(filtered.length - 1, c + 1));
774
+ if (key.return) {
775
+ const item = filtered[active];
776
+ if (item) onSelect(item);
777
+ }
778
+ });
779
+ const start = Math.max(
780
+ 0,
781
+ Math.min(active - Math.floor(pageSize / 2), Math.max(0, filtered.length - pageSize))
782
+ );
783
+ const view = filtered.slice(start, start + pageSize);
784
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
785
+ filterable && /* @__PURE__ */ jsxs3(Box3, { children: [
786
+ /* @__PURE__ */ jsx3(Text3, { children: "filter: " }),
787
+ /* @__PURE__ */ jsx3(
788
+ TextInput,
789
+ {
790
+ value: filter,
791
+ onChange: (v) => {
792
+ setFilter(v);
793
+ setCursor(0);
794
+ }
795
+ }
796
+ )
797
+ ] }),
798
+ view.length === 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "(no matches)" }) : view.map((item, i) => {
799
+ const idx = start + i;
800
+ return /* @__PURE__ */ jsxs3(Text3, { color: idx === active ? "cyan" : void 0, children: [
801
+ idx === active ? "\u25B8 " : " ",
802
+ item
803
+ ] }, item);
804
+ }),
805
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
806
+ "\u2191/\u2193 move \xB7 enter select \xB7 esc cancel",
807
+ filterable ? " \xB7 type to filter" : ""
808
+ ] })
809
+ ] });
810
+ };
811
+ }
812
+ });
813
+
814
+ // src/tui/screens/Settings.tsx
815
+ import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
816
+ import TextInput2 from "ink-text-input";
817
+ import { useEffect, useState as useState4 } from "react";
818
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
819
+ var buildFields, LANGUAGE_OPTIONS, Settings;
559
820
  var init_Settings = __esm({
560
821
  "src/tui/screens/Settings.tsx"() {
561
822
  "use strict";
562
823
  init_config();
563
824
  init_secrets();
825
+ init_compose();
826
+ init_scheduler();
827
+ init_SelectList();
564
828
  buildFields = () => {
565
829
  const cfg = loadConfig();
566
830
  const secrets = tryLoadSecrets();
567
831
  return [
568
- { key: "openwaBaseUrl", label: "openwaBaseUrl", group: "config", value: cfg.openwaBaseUrl, secret: false },
569
- { key: "openwaSessionId", label: "openwaSessionId", group: "config", value: cfg.openwaSessionId ?? "", secret: false },
570
- { key: "openwaApiKey", label: "openwaApiKey", group: "config", value: cfg.openwaApiKey ?? "", secret: false },
571
- { key: "recipientChatId", label: "recipients[0].chatId", group: "recipient", value: cfg.recipients[0]?.chatId ?? "", secret: false },
572
- { key: "GEMINI_API_KEY", label: "GEMINI_API_KEY", group: "secret", value: secrets.GEMINI_API_KEY ?? "", secret: true },
573
- { key: "OPENWA_API_KEY", label: "OPENWA_API_KEY", group: "secret", value: secrets.OPENWA_API_KEY ?? "", secret: true }
832
+ { key: "openwaBaseUrl", label: "openwaBaseUrl", group: "config", kind: "text", value: cfg.openwaBaseUrl, secret: false },
833
+ { key: "openwaSessionId", label: "openwaSessionId", group: "config", kind: "text", value: cfg.openwaSessionId ?? "", secret: false },
834
+ { key: "openwaApiKey", label: "openwaApiKey", group: "config", kind: "text", value: cfg.openwaApiKey ?? "", secret: false },
835
+ { key: "recipientChatId", label: "recipients[0].chatId", group: "recipient", kind: "text", value: cfg.recipients[0]?.chatId ?? "", secret: false },
836
+ { key: "language", label: "language", group: "config", kind: "language", value: cfg.language, secret: false },
837
+ { key: "timezone", label: "timezone", group: "config", kind: "timezone", value: cfg.timezone, secret: false },
838
+ { key: "GEMINI_API_KEY", label: "GEMINI_API_KEY", group: "secret", kind: "text", value: secrets.GEMINI_API_KEY ?? "", secret: true },
839
+ { key: "OPENWA_API_KEY", label: "OPENWA_API_KEY", group: "secret", kind: "text", value: secrets.OPENWA_API_KEY ?? "", secret: true }
574
840
  ];
575
841
  };
842
+ LANGUAGE_OPTIONS = [...LANGUAGES, "Other\u2026"];
576
843
  Settings = ({ onFocusChange }) => {
577
- const [fields, setFields] = useState3(buildFields);
578
- const [cursor, setCursor] = useState3(0);
579
- const [editing, setEditing] = useState3(false);
580
- const [draft, setDraft] = useState3("");
581
- const [message, setMessage] = useState3(null);
844
+ const [fields, setFields] = useState4(buildFields);
845
+ const [cursor, setCursor] = useState4(0);
846
+ const [mode, setMode] = useState4("none");
847
+ const [draft, setDraft] = useState4("");
848
+ const [message, setMessage] = useState4(null);
582
849
  useEffect(() => {
583
- onFocusChange(editing);
584
- }, [editing, onFocusChange]);
585
- useInput3((input, key) => {
586
- if (editing) {
587
- if (key.escape) {
588
- setEditing(false);
850
+ onFocusChange(mode !== "none");
851
+ }, [mode, onFocusChange]);
852
+ useInput4((input, key) => {
853
+ if (mode !== "none") {
854
+ if ((mode === "text" || mode === "language-other") && key.escape) {
855
+ setMode("none");
589
856
  setDraft("");
590
857
  }
591
858
  return;
@@ -594,85 +861,143 @@ var init_Settings = __esm({
594
861
  if (key.downArrow || input === "j") setCursor((c) => Math.min(fields.length - 1, c + 1));
595
862
  if (key.return || input === "e") {
596
863
  const field = fields[cursor];
597
- if (field) {
864
+ if (!field) return;
865
+ if (field.kind === "language") setMode("language");
866
+ else if (field.kind === "timezone") setMode("timezone");
867
+ else {
598
868
  setDraft(field.value);
599
- setEditing(true);
869
+ setMode("text");
600
870
  }
601
871
  }
602
872
  });
603
- const commit = (value) => {
873
+ const saveConfigField = (key, value) => {
874
+ saveConfig(ConfigSchema.parse({ ...loadConfig(), [key]: value }));
875
+ };
876
+ const finish = (msg) => {
877
+ setFields(buildFields());
878
+ setMessage(msg);
879
+ setMode("none");
880
+ setDraft("");
881
+ };
882
+ const commitText = (value) => {
604
883
  const field = fields[cursor];
605
884
  if (!field) return;
606
885
  try {
607
886
  if (field.group === "secret") {
608
- const parsed = SecretsSchema.partial().parse({ [field.key]: value });
609
- saveSecrets(parsed);
887
+ saveSecrets(SecretsSchema.partial().parse({ [field.key]: value }));
610
888
  } else if (field.group === "recipient") {
611
889
  const cfg = loadConfig();
612
- const existing = cfg.recipients[0];
613
- const next = ConfigSchema.parse({
614
- ...cfg,
615
- recipients: [{ ...existing, chatId: value }]
616
- });
617
- saveConfig(next);
890
+ saveConfig(ConfigSchema.parse({ ...cfg, recipients: [{ ...cfg.recipients[0], chatId: value }] }));
618
891
  } else {
619
- const cfg = loadConfig();
620
- const next = ConfigSchema.parse({ ...cfg, [field.key]: value || null });
621
- saveConfig(next);
892
+ saveConfigField(field.key, value || null);
622
893
  }
894
+ finish(`saved ${field.label}`);
895
+ } catch (err) {
896
+ setMessage(err instanceof Error ? err.message : String(err));
897
+ setMode("none");
898
+ setDraft("");
899
+ }
900
+ };
901
+ const commitLanguage = (value) => {
902
+ if (value === "Other\u2026") {
903
+ setDraft("");
904
+ setMode("language-other");
905
+ return;
906
+ }
907
+ const lang = value.trim();
908
+ if (!lang) {
909
+ setMode("none");
910
+ setDraft("");
911
+ return;
912
+ }
913
+ try {
914
+ saveConfigField("language", lang);
915
+ finish(`saved language \u2192 ${lang}`);
916
+ } catch (err) {
917
+ setMessage(err instanceof Error ? err.message : String(err));
918
+ setMode("none");
919
+ }
920
+ };
921
+ const commitTimezone = async (value) => {
922
+ setMode("none");
923
+ try {
924
+ saveConfigField("timezone", value);
623
925
  setFields(buildFields());
624
- setMessage(`saved ${field.label}`);
926
+ writeCompose(value);
927
+ setMessage(`timezone \u2192 ${value} \xB7 restarting scheduler\u2026`);
928
+ const res = await restartScheduler();
929
+ setMessage(
930
+ res.ok ? `timezone \u2192 ${value} \xB7 scheduler restarted` : `timezone saved \xB7 restart failed \u2014 run: docker compose up -d --force-recreate scheduler`
931
+ );
625
932
  } catch (err) {
626
933
  setMessage(err instanceof Error ? err.message : String(err));
627
934
  }
628
- setEditing(false);
629
- setDraft("");
630
935
  };
936
+ if (mode === "language") {
937
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
938
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Settings \xB7 language" }),
939
+ /* @__PURE__ */ jsx4(SelectList, { items: LANGUAGE_OPTIONS, onSelect: commitLanguage, onCancel: () => setMode("none") })
940
+ ] });
941
+ }
942
+ if (mode === "timezone") {
943
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
944
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Settings \xB7 timezone" }),
945
+ /* @__PURE__ */ jsx4(
946
+ SelectList,
947
+ {
948
+ items: supportedTimezones(),
949
+ filterable: true,
950
+ onSelect: (v) => void commitTimezone(v),
951
+ onCancel: () => setMode("none")
952
+ }
953
+ )
954
+ ] });
955
+ }
631
956
  const renderValue = (f) => {
632
- if (!f.value) return /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "(unset)" });
633
- if (f.secret) return /* @__PURE__ */ jsxs3(Text3, { children: [
957
+ if (!f.value) return /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "(unset)" });
958
+ if (f.secret) return /* @__PURE__ */ jsxs4(Text4, { children: [
634
959
  f.value.slice(0, 4),
635
960
  "\u2026"
636
961
  ] });
637
- return /* @__PURE__ */ jsx3(Text3, { children: f.value });
962
+ return /* @__PURE__ */ jsx4(Text4, { children: f.value });
638
963
  };
639
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
640
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Settings" }),
641
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u2191/\u2193 to move \xB7 enter to edit \xB7 esc to cancel" }),
642
- /* @__PURE__ */ jsx3(Box3, { marginTop: 1, flexDirection: "column", children: fields.map((f, i) => /* @__PURE__ */ jsxs3(Box3, { children: [
643
- /* @__PURE__ */ jsx3(Box3, { width: 24, children: /* @__PURE__ */ jsxs3(Text3, { color: i === cursor ? "cyan" : void 0, children: [
964
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
965
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Settings" }),
966
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191/\u2193 to move \xB7 enter to edit \xB7 esc to cancel" }),
967
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, flexDirection: "column", children: fields.map((f, i) => /* @__PURE__ */ jsxs4(Box4, { children: [
968
+ /* @__PURE__ */ jsx4(Box4, { width: 24, children: /* @__PURE__ */ jsxs4(Text4, { color: i === cursor ? "cyan" : void 0, children: [
644
969
  i === cursor ? "\u25B8 " : " ",
645
970
  f.label
646
971
  ] }) }),
647
- /* @__PURE__ */ jsx3(Text3, { children: " = " }),
648
- editing && i === cursor ? /* @__PURE__ */ jsx3(TextInput, { value: draft, onChange: setDraft, onSubmit: commit }) : renderValue(f)
972
+ /* @__PURE__ */ jsx4(Text4, { children: " = " }),
973
+ mode === "text" && i === cursor ? /* @__PURE__ */ jsx4(TextInput2, { value: draft, onChange: setDraft, onSubmit: commitText }) : mode === "language-other" && i === cursor ? /* @__PURE__ */ jsx4(TextInput2, { value: draft, onChange: setDraft, onSubmit: commitLanguage, placeholder: "language name" }) : renderValue(f)
649
974
  ] }, f.key)) }),
650
- message && /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "green", children: message }) })
975
+ message && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { color: "green", children: message }) })
651
976
  ] });
652
977
  };
653
978
  }
654
979
  });
655
980
 
656
981
  // src/tui/screens/Prompt.tsx
657
- import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
658
- import TextInput2 from "ink-text-input";
659
- import { useEffect as useEffect2, useState as useState4 } from "react";
660
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
982
+ import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
983
+ import TextInput3 from "ink-text-input";
984
+ import { useEffect as useEffect2, useState as useState5 } from "react";
985
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
661
986
  var PromptScreen;
662
987
  var init_Prompt = __esm({
663
988
  "src/tui/screens/Prompt.tsx"() {
664
989
  "use strict";
665
990
  init_prompt();
666
991
  PromptScreen = ({ onFocusChange }) => {
667
- const [prompt, setPrompt] = useState4(loadPrompt);
668
- const [cursor, setCursor] = useState4(0);
669
- const [adding, setAdding] = useState4(false);
670
- const [draft, setDraft] = useState4("");
671
- const [message, setMessage] = useState4(null);
992
+ const [prompt, setPrompt] = useState5(loadPrompt);
993
+ const [cursor, setCursor] = useState5(0);
994
+ const [adding, setAdding] = useState5(false);
995
+ const [draft, setDraft] = useState5("");
996
+ const [message, setMessage] = useState5(null);
672
997
  useEffect2(() => {
673
998
  onFocusChange(adding);
674
999
  }, [adding, onFocusChange]);
675
- useInput4((input, key) => {
1000
+ useInput5((input, key) => {
676
1001
  if (adding) {
677
1002
  if (key.escape) {
678
1003
  setAdding(false);
@@ -711,68 +1036,141 @@ var init_Prompt = __esm({
711
1036
  setDraft("");
712
1037
  setMessage(`added example #${next.examples.length}`);
713
1038
  };
714
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
715
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Prompt" }),
716
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "system prompt is shown read-only here; edit via `wife prompt edit`" }),
717
- /* @__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)) }),
718
- /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexDirection: "column", children: [
719
- /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
1039
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
1040
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Prompt" }),
1041
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "system prompt is shown read-only here; edit via `wife prompt edit`" }),
1042
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, borderStyle: "single", paddingX: 1, flexDirection: "column", children: prompt.systemPrompt.split("\n").map((line, i) => /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: line || " " }, i)) }),
1043
+ /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
1044
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
720
1045
  "Examples (",
721
1046
  prompt.examples.length,
722
1047
  ")"
723
1048
  ] }),
724
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191/\u2193 to move \xB7 [a]dd \xB7 [d]elete" }),
725
- prompt.examples.map((e, i) => /* @__PURE__ */ jsxs4(Text4, { color: i === cursor ? "cyan" : void 0, children: [
1049
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2191/\u2193 to move \xB7 [a]dd \xB7 [d]elete" }),
1050
+ prompt.examples.map((e, i) => /* @__PURE__ */ jsxs5(Text5, { color: i === cursor ? "cyan" : void 0, children: [
726
1051
  i === cursor ? "\u25B8 " : " ",
727
1052
  i + 1,
728
1053
  ". ",
729
1054
  e
730
1055
  ] }, i)),
731
- adding && /* @__PURE__ */ jsxs4(Box4, { children: [
732
- /* @__PURE__ */ jsx4(Text4, { children: "+ " }),
733
- /* @__PURE__ */ jsx4(TextInput2, { value: draft, onChange: setDraft, onSubmit: commit })
1056
+ adding && /* @__PURE__ */ jsxs5(Box5, { children: [
1057
+ /* @__PURE__ */ jsx5(Text5, { children: "+ " }),
1058
+ /* @__PURE__ */ jsx5(TextInput3, { value: draft, onChange: setDraft, onSubmit: commit })
734
1059
  ] })
735
1060
  ] }),
736
- message && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { color: "green", children: message }) })
1061
+ message && /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "green", children: message }) })
1062
+ ] });
1063
+ };
1064
+ }
1065
+ });
1066
+
1067
+ // src/tui/components/DayPicker.tsx
1068
+ import { Box as Box6, Text as Text6, useInput as useInput6 } from "ink";
1069
+ import { useState as useState6 } from "react";
1070
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1071
+ var DayPicker;
1072
+ var init_DayPicker = __esm({
1073
+ "src/tui/components/DayPicker.tsx"() {
1074
+ "use strict";
1075
+ init_schedule();
1076
+ DayPicker = ({ onSubmit, onCancel }) => {
1077
+ const [cursor, setCursor] = useState6(1);
1078
+ const [selected, setSelected] = useState6(/* @__PURE__ */ new Set());
1079
+ useInput6((input, key) => {
1080
+ if (key.escape) return onCancel();
1081
+ if (key.leftArrow) return setCursor((c) => Math.max(0, c - 1));
1082
+ if (key.rightArrow) return setCursor((c) => Math.min(DAY_LABELS.length - 1, c + 1));
1083
+ if (input === " ") {
1084
+ return setSelected((s) => {
1085
+ const next = new Set(s);
1086
+ if (next.has(cursor)) next.delete(cursor);
1087
+ else next.add(cursor);
1088
+ return next;
1089
+ });
1090
+ }
1091
+ if (key.return && selected.size > 0) {
1092
+ onSubmit([...selected].sort((a, b) => a - b));
1093
+ }
1094
+ });
1095
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
1096
+ /* @__PURE__ */ jsx6(Box6, { children: DAY_LABELS.map((d, i) => /* @__PURE__ */ jsxs6(Text6, { color: i === cursor ? "cyan" : selected.has(i) ? "green" : void 0, children: [
1097
+ selected.has(i) ? `[${d}]` : ` ${d} `,
1098
+ " "
1099
+ ] }, d)) }),
1100
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u2190/\u2192 move \xB7 space toggle \xB7 enter confirm \xB7 esc cancel" })
737
1101
  ] });
738
1102
  };
739
1103
  }
740
1104
  });
741
1105
 
742
1106
  // src/tui/screens/Schedule.tsx
743
- import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
744
- import TextInput3 from "ink-text-input";
745
- import { useEffect as useEffect3, useState as useState5 } from "react";
746
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
747
- var ScheduleScreen;
1107
+ import { Box as Box7, Text as Text7, useInput as useInput7 } from "ink";
1108
+ import TextInput4 from "ink-text-input";
1109
+ import { useEffect as useEffect3, useState as useState7 } from "react";
1110
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1111
+ var HHMM_RE2, FREQUENCY_BY_LABEL, FREQUENCY_LABELS, ScheduleScreen;
748
1112
  var init_Schedule = __esm({
749
1113
  "src/tui/screens/Schedule.tsx"() {
750
1114
  "use strict";
751
1115
  init_schedule();
1116
+ init_scheduler();
1117
+ init_SelectList();
1118
+ init_DayPicker();
1119
+ HHMM_RE2 = /^([01]?\d|2[0-3]):([0-5]\d)$/;
1120
+ FREQUENCY_BY_LABEL = {
1121
+ Daily: "daily",
1122
+ Weekdays: "weekdays",
1123
+ Weekends: "weekends",
1124
+ "Custom days": "custom"
1125
+ };
1126
+ FREQUENCY_LABELS = Object.keys(FREQUENCY_BY_LABEL);
752
1127
  ScheduleScreen = ({ onFocusChange }) => {
753
- const [schedule, setSchedule] = useState5(loadSchedule);
754
- const [cursor, setCursor] = useState5(0);
755
- const [addStep, setAddStep] = useState5(null);
756
- const [draftName, setDraftName] = useState5("");
757
- const [draftCron, setDraftCron] = useState5("");
758
- const [message, setMessage] = useState5(null);
1128
+ const [schedule, setSchedule] = useState7(loadSchedule);
1129
+ const [cursor, setCursor] = useState7(0);
1130
+ const [addStep, setAddStep] = useState7(null);
1131
+ const [draftName, setDraftName] = useState7("");
1132
+ const [draftTime, setDraftTime] = useState7("");
1133
+ const [message, setMessage] = useState7(null);
759
1134
  useEffect3(() => {
760
1135
  onFocusChange(addStep !== null);
761
1136
  }, [addStep, onFocusChange]);
762
- useInput5((input, key) => {
1137
+ const resetAdd = () => {
1138
+ setAddStep(null);
1139
+ setDraftName("");
1140
+ setDraftTime("");
1141
+ };
1142
+ const applyRestart = async (note) => {
1143
+ setMessage(`${note} \xB7 restarting scheduler\u2026`);
1144
+ const res = await restartScheduler();
1145
+ setMessage(
1146
+ res.ok ? `${note} \xB7 scheduler restarted` : `${note} \xB7 restart failed \u2014 run: docker compose up -d --force-recreate scheduler`
1147
+ );
1148
+ };
1149
+ const finishAdd = async (cron) => {
1150
+ try {
1151
+ const job = ScheduledJobSchema.parse({ name: draftName, schedule: cron, command: "send" });
1152
+ const next = addJob(job);
1153
+ setSchedule(next);
1154
+ resetAdd();
1155
+ await applyRestart(`added "${job.name}"`);
1156
+ } catch (err) {
1157
+ setMessage(err instanceof Error ? err.message : String(err));
1158
+ resetAdd();
1159
+ }
1160
+ };
1161
+ const [hourStr, minuteStr] = draftTime.split(":");
1162
+ const hour = Number(hourStr);
1163
+ const minute = Number(minuteStr);
1164
+ useInput7((input, key) => {
763
1165
  if (addStep !== null) {
764
- if (key.escape) {
765
- setAddStep(null);
766
- setDraftName("");
767
- setDraftCron("");
768
- }
1166
+ if ((addStep === "name" || addStep === "time") && key.escape) resetAdd();
769
1167
  return;
770
1168
  }
771
1169
  if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1));
772
1170
  if (key.downArrow || input === "j") setCursor((c) => Math.min(schedule.jobs.length - 1, c + 1));
773
1171
  if (input === "a") {
774
1172
  setDraftName("");
775
- setDraftCron("");
1173
+ setDraftTime("");
776
1174
  setAddStep("name");
777
1175
  }
778
1176
  if (input === "d") {
@@ -782,43 +1180,77 @@ var init_Schedule = __esm({
782
1180
  const next = removeJob(job.name);
783
1181
  setSchedule(next);
784
1182
  setCursor((c) => Math.max(0, Math.min(next.jobs.length - 1, c)));
785
- setMessage(`removed "${job.name}" \u2014 restart scheduler to apply`);
1183
+ void applyRestart(`removed "${job.name}"`);
786
1184
  } catch (err) {
787
1185
  setMessage(err instanceof Error ? err.message : String(err));
788
1186
  }
789
1187
  }
1188
+ if (input === "r") void applyRestart("manual restart");
790
1189
  });
791
1190
  const submitName = (value) => {
792
- if (value.trim().length === 0) {
793
- setAddStep(null);
1191
+ const name = value.trim();
1192
+ if (name.length === 0) return resetAdd();
1193
+ if (!/^[a-z0-9-]+$/.test(name)) {
1194
+ setMessage("Name must be lowercase letters, numbers, and dashes only.");
794
1195
  return;
795
1196
  }
796
- setDraftName(value.trim());
797
- setAddStep("cron");
1197
+ setDraftName(name);
1198
+ setAddStep("time");
798
1199
  };
799
- const submitCron = (value) => {
800
- if (value.trim().length === 0) {
801
- setAddStep(null);
802
- setDraftName("");
1200
+ const submitTime = (value) => {
1201
+ const time = value.trim();
1202
+ if (time.length === 0) return resetAdd();
1203
+ if (!HHMM_RE2.test(time)) {
1204
+ setMessage("Invalid time. Use 24h HH:MM, e.g. 09:00.");
803
1205
  return;
804
1206
  }
805
- try {
806
- const job = ScheduledJobSchema.parse({ name: draftName, schedule: value.trim(), command: "send" });
807
- const next = addJob(job);
808
- setSchedule(next);
809
- setMessage(`added "${job.name}" \u2014 restart scheduler to apply`);
810
- } catch (err) {
811
- setMessage(err instanceof Error ? err.message : String(err));
1207
+ setDraftTime(time);
1208
+ setAddStep("frequency");
1209
+ };
1210
+ const onFrequency = (label) => {
1211
+ const frequency = FREQUENCY_BY_LABEL[label];
1212
+ if (!frequency) return;
1213
+ if (frequency === "custom") {
1214
+ setAddStep("days");
1215
+ return;
812
1216
  }
813
- setAddStep(null);
814
- setDraftName("");
815
- setDraftCron("");
1217
+ void finishAdd(buildCron({ hour, minute, frequency }));
816
1218
  };
817
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
818
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Schedule" }),
819
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2191/\u2193 to move \xB7 [a]dd \xB7 [d]elete \xB7 changes require: docker compose restart scheduler" }),
820
- /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
821
- 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: [
1219
+ if (addStep === "frequency") {
1220
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1221
+ /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
1222
+ "Schedule \xB7 ",
1223
+ draftName,
1224
+ " @ ",
1225
+ draftTime,
1226
+ " \u2014 frequency"
1227
+ ] }),
1228
+ /* @__PURE__ */ jsx7(SelectList, { items: FREQUENCY_LABELS, onSelect: onFrequency, onCancel: resetAdd })
1229
+ ] });
1230
+ }
1231
+ if (addStep === "days") {
1232
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1233
+ /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
1234
+ "Schedule \xB7 ",
1235
+ draftName,
1236
+ " @ ",
1237
+ draftTime,
1238
+ " \u2014 pick days"
1239
+ ] }),
1240
+ /* @__PURE__ */ jsx7(
1241
+ DayPicker,
1242
+ {
1243
+ onSubmit: (days) => void finishAdd(buildCron({ hour, minute, frequency: "custom", days })),
1244
+ onCancel: resetAdd
1245
+ }
1246
+ )
1247
+ ] });
1248
+ }
1249
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1250
+ /* @__PURE__ */ jsx7(Text7, { bold: true, children: "Schedule" }),
1251
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u2191/\u2193 move \xB7 [a]dd \xB7 [d]elete \xB7 [r] restart scheduler \xB7 changes auto-restart" }),
1252
+ /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexDirection: "column", children: [
1253
+ schedule.jobs.length === 0 ? /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "(no jobs)" }) : schedule.jobs.map((j, i) => /* @__PURE__ */ jsxs7(Text7, { color: i === cursor ? "cyan" : void 0, children: [
822
1254
  i === cursor ? "\u25B8 " : " ",
823
1255
  j.name.padEnd(20),
824
1256
  " ",
@@ -826,29 +1258,29 @@ var init_Schedule = __esm({
826
1258
  " \u2192 ",
827
1259
  j.command
828
1260
  ] }, j.name)),
829
- addStep === "name" && /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, children: [
830
- /* @__PURE__ */ jsx5(Text5, { children: "+ name: " }),
831
- /* @__PURE__ */ jsx5(TextInput3, { value: draftName, onChange: setDraftName, onSubmit: submitName })
1261
+ addStep === "name" && /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
1262
+ /* @__PURE__ */ jsx7(Text7, { children: "+ name: " }),
1263
+ /* @__PURE__ */ jsx7(TextInput4, { value: draftName, onChange: setDraftName, onSubmit: submitName })
832
1264
  ] }),
833
- addStep === "cron" && /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, children: [
834
- /* @__PURE__ */ jsxs5(Text5, { children: [
1265
+ addStep === "time" && /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
1266
+ /* @__PURE__ */ jsxs7(Text7, { children: [
835
1267
  "+ ",
836
1268
  draftName,
837
- " cron: "
1269
+ " time (HH:MM): "
838
1270
  ] }),
839
- /* @__PURE__ */ jsx5(TextInput3, { value: draftCron, onChange: setDraftCron, onSubmit: submitCron, placeholder: "0 9 * * *" })
1271
+ /* @__PURE__ */ jsx7(TextInput4, { value: draftTime, onChange: setDraftTime, onSubmit: submitTime, placeholder: "09:00" })
840
1272
  ] })
841
1273
  ] }),
842
- message && /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "green", children: message }) })
1274
+ message && /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { color: "green", children: message }) })
843
1275
  ] });
844
1276
  };
845
1277
  }
846
1278
  });
847
1279
 
848
1280
  // src/tui/App.tsx
849
- import { Box as Box6, Text as Text6, useApp, useInput as useInput6 } from "ink";
850
- import { useState as useState6 } from "react";
851
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1281
+ import { Box as Box8, Text as Text8, useApp, useInput as useInput8 } from "ink";
1282
+ import { useState as useState8 } from "react";
1283
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
852
1284
  var TABS, TabBar, App;
853
1285
  var init_App = __esm({
854
1286
  "src/tui/App.tsx"() {
@@ -865,17 +1297,17 @@ var init_App = __esm({
865
1297
  { key: "4", label: "Prompt" },
866
1298
  { key: "5", label: "Schedule" }
867
1299
  ];
868
- 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: [
1300
+ TabBar = ({ active }) => /* @__PURE__ */ jsx8(Box8, { gap: 2, children: TABS.map((t) => /* @__PURE__ */ jsxs8(Text8, { bold: t.key === active, color: t.key === active ? "cyan" : void 0, children: [
869
1301
  "[",
870
1302
  t.key,
871
1303
  "] ",
872
1304
  t.label
873
1305
  ] }, t.key)) });
874
1306
  App = () => {
875
- const [tab, setTab] = useState6("1");
876
- const [focused, setFocused] = useState6(false);
1307
+ const [tab, setTab] = useState8("1");
1308
+ const [focused, setFocused] = useState8(false);
877
1309
  const { exit } = useApp();
878
- useInput6((input) => {
1310
+ useInput8((input) => {
879
1311
  if (focused) return;
880
1312
  if (input === "q") {
881
1313
  exit();
@@ -884,20 +1316,20 @@ var init_App = __esm({
884
1316
  const found = TABS.find((t) => t.key === input);
885
1317
  if (found) setTab(found.key);
886
1318
  });
887
- return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 1, children: [
888
- /* @__PURE__ */ jsxs6(Box6, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
889
- /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: "wife" }),
890
- /* @__PURE__ */ jsx6(Text6, { children: " \u2014 daily mood-lifter control panel" })
1319
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", paddingX: 1, children: [
1320
+ /* @__PURE__ */ jsxs8(Box8, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1321
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", children: "wife" }),
1322
+ /* @__PURE__ */ jsx8(Text8, { children: " \u2014 daily mood-lifter control panel" })
891
1323
  ] }),
892
- /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(TabBar, { active: tab }) }),
893
- /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
894
- tab === "1" && /* @__PURE__ */ jsx6(Home, {}),
895
- tab === "2" && /* @__PURE__ */ jsx6(History, {}),
896
- tab === "3" && /* @__PURE__ */ jsx6(Settings, { onFocusChange: setFocused }),
897
- tab === "4" && /* @__PURE__ */ jsx6(PromptScreen, { onFocusChange: setFocused }),
898
- tab === "5" && /* @__PURE__ */ jsx6(ScheduleScreen, { onFocusChange: setFocused })
1324
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(TabBar, { active: tab }) }),
1325
+ /* @__PURE__ */ jsxs8(Box8, { marginTop: 1, flexDirection: "column", children: [
1326
+ tab === "1" && /* @__PURE__ */ jsx8(Home, {}),
1327
+ tab === "2" && /* @__PURE__ */ jsx8(History, {}),
1328
+ tab === "3" && /* @__PURE__ */ jsx8(Settings, { onFocusChange: setFocused }),
1329
+ tab === "4" && /* @__PURE__ */ jsx8(PromptScreen, { onFocusChange: setFocused }),
1330
+ tab === "5" && /* @__PURE__ */ jsx8(ScheduleScreen, { onFocusChange: setFocused })
899
1331
  ] }),
900
- /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
1332
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
901
1333
  "1-5 switch tabs \xB7 q quit",
902
1334
  focused ? " \xB7 esc to leave input" : ""
903
1335
  ] }) })
@@ -998,9 +1430,9 @@ var registerInit = (program2) => {
998
1430
 
999
1431
  // src/cli/commands/setup.ts
1000
1432
  import { spawnSync } from "child_process";
1001
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
1433
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync7 } from "fs";
1002
1434
  import { homedir as homedir2 } from "os";
1003
- import { dirname as dirname5, join as join2 } from "path";
1435
+ import { dirname as dirname6, join as join2 } from "path";
1004
1436
  import { fileURLToPath } from "url";
1005
1437
  import { createInterface } from "readline";
1006
1438
  import qrcode from "qrcode-terminal";
@@ -1038,6 +1470,7 @@ var saveQrImage = (dataUrl) => {
1038
1470
 
1039
1471
  // src/cli/commands/setup.ts
1040
1472
  init_paths();
1473
+ init_compose();
1041
1474
  init_config();
1042
1475
  init_secrets();
1043
1476
  init_schedule();
@@ -1124,52 +1557,8 @@ var presentQr = (dataUrl, sessionId, baseUrl, apiKey) => {
1124
1557
  );
1125
1558
  console.warn(` | base64 -d > waify-qr.png`);
1126
1559
  };
1127
- var composeTemplate = () => `services:
1128
- openwa-api:
1129
- image: ghcr.io/deckasoft/openwa:latest
1130
- ports:
1131
- - '2785:2785'
1132
- environment:
1133
- - NODE_ENV=production
1134
- - PORT=2785
1135
- - DATABASE_TYPE=sqlite
1136
- - DATABASE_SYNCHRONIZE=true
1137
- - ENGINE_TYPE=whatsapp-web.js
1138
- - SESSION_DATA_PATH=/app/data/sessions
1139
- - PUPPETEER_HEADLESS=true
1140
- - PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu
1141
- - STORAGE_TYPE=local
1142
- - STORAGE_LOCAL_PATH=/app/data/media
1143
- - QUEUE_ENABLED=false
1144
- - REDIS_ENABLED=false
1145
- - REDIS_BUILTIN=false
1146
- volumes:
1147
- - openwa-data:/app/data
1148
- networks:
1149
- - waify-network
1150
- restart: unless-stopped
1151
-
1152
- scheduler:
1153
- image: mcuadros/ofelia:latest
1154
- depends_on:
1155
- - openwa-api
1156
- command: daemon --config=/etc/ofelia/config.ini
1157
- volumes:
1158
- - /var/run/docker.sock:/var/run/docker.sock
1159
- - ${schedulePath()}:/etc/ofelia/config.ini:ro
1160
- networks:
1161
- - waify-network
1162
- restart: unless-stopped
1163
-
1164
- volumes:
1165
- openwa-data:
1166
-
1167
- networks:
1168
- waify-network:
1169
- name: waify-network
1170
- `;
1171
1560
  var waifyVersion = () => {
1172
- const dir = dirname5(fileURLToPath(import.meta.url));
1561
+ const dir = dirname6(fileURLToPath(import.meta.url));
1173
1562
  const candidates = [
1174
1563
  join2(dir, "..", "..", "package.json"),
1175
1564
  join2(dir, "..", "..", "..", "package.json")
@@ -1188,7 +1577,7 @@ ENTRYPOINT ["waify"]
1188
1577
  `;
1189
1578
  var buildSenderImage = () => {
1190
1579
  console.warn("Writing Dockerfile and building sender image...");
1191
- writeFileSync6(dockerfilePath(), dockerfileTemplate(waifyVersion()), "utf-8");
1580
+ writeFileSync7(dockerfilePath(), dockerfileTemplate(waifyVersion()), "utf-8");
1192
1581
  const result = spawnSync(
1193
1582
  "docker",
1194
1583
  ["build", "-t", "openwa-scripts-sender:latest", "-f", dockerfilePath(), dataDir()],
@@ -1228,6 +1617,77 @@ var promptUntilValid = async (promptFn, question, validate, errorMsg) => {
1228
1617
  process.stderr.write(errorMsg + "\n");
1229
1618
  return promptUntilValid(promptFn, question, validate, errorMsg);
1230
1619
  };
1620
+ var promptLanguage = async (promptFn) => {
1621
+ process.stderr.write("\nMessage language:\n");
1622
+ LANGUAGES.forEach((l, i) => process.stderr.write(` ${i + 1}) ${l}
1623
+ `));
1624
+ process.stderr.write(` ${LANGUAGES.length + 1}) Other (type your own)
1625
+ `);
1626
+ const answer = (await promptFn(`Choose [1-${LANGUAGES.length + 1}] (default 1 \u2014 Spanish): `)).trim();
1627
+ if (answer === "") return "Spanish";
1628
+ const n = Number(answer);
1629
+ if (Number.isInteger(n)) {
1630
+ if (n >= 1 && n <= LANGUAGES.length) return LANGUAGES[n - 1];
1631
+ if (n === LANGUAGES.length + 1) {
1632
+ const custom = (await promptFn("Language name: ")).trim();
1633
+ return custom || "Spanish";
1634
+ }
1635
+ return "Spanish";
1636
+ }
1637
+ return answer;
1638
+ };
1639
+ var promptTimezone = async (promptFn) => {
1640
+ const detected = detectTimezone();
1641
+ const zones = new Set(supportedTimezones());
1642
+ process.stderr.write(
1643
+ `
1644
+ Timezone for your schedule (IANA name, e.g. America/Guayaquil). Detected: ${detected}
1645
+ `
1646
+ );
1647
+ const ask = async () => {
1648
+ const answer = (await promptFn(`Timezone [${detected}]: `)).trim();
1649
+ if (answer === "") return zones.has(detected) ? detected : "UTC";
1650
+ if (zones.has(answer)) return answer;
1651
+ process.stderr.write(`"${answer}" is not a valid IANA zone. Try e.g. Europe/Madrid, America/Sao_Paulo.
1652
+ `);
1653
+ return ask();
1654
+ };
1655
+ return ask();
1656
+ };
1657
+ var FREQUENCY_BY_CHOICE = {
1658
+ "1": "daily",
1659
+ "2": "weekdays",
1660
+ "3": "weekends",
1661
+ "4": "custom"
1662
+ };
1663
+ var HHMM_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;
1664
+ var promptJobScheduleCron = async (promptFn) => {
1665
+ const time = await promptUntilValid(
1666
+ promptFn,
1667
+ "Time (HH:MM, 24h): ",
1668
+ (v) => HHMM_RE.test(v),
1669
+ "Invalid time. Use 24h HH:MM, e.g. 09:00 or 19:30."
1670
+ );
1671
+ const [hourStr, minuteStr] = time.split(":");
1672
+ const hour = Number(hourStr);
1673
+ const minute = Number(minuteStr);
1674
+ process.stderr.write("Frequency: 1) Daily 2) Weekdays 3) Weekends 4) Custom days\n");
1675
+ const freqChoice = await promptUntilValid(
1676
+ promptFn,
1677
+ "Choose [1-4] (default 1 \u2014 Daily): ",
1678
+ (v) => v === "" || v in FREQUENCY_BY_CHOICE,
1679
+ "Choose 1, 2, 3, or 4."
1680
+ );
1681
+ const frequency = freqChoice === "" ? "daily" : FREQUENCY_BY_CHOICE[freqChoice];
1682
+ if (frequency !== "custom") return buildCron({ hour, minute, frequency });
1683
+ const daysAnswer = await promptUntilValid(
1684
+ promptFn,
1685
+ "Days (e.g. mon,wed,fri): ",
1686
+ (v) => parseDays(v) !== null,
1687
+ `Invalid days. Use ${DAY_LABELS.map((d) => d.toLowerCase()).join(",")} or 0-6, comma-separated.`
1688
+ );
1689
+ return buildCron({ hour, minute, frequency, days: parseDays(daysAnswer) });
1690
+ };
1231
1691
  var collectJobs = async (promptFn, accumulated = []) => {
1232
1692
  const name = await promptUntilValid(
1233
1693
  promptFn,
@@ -1235,12 +1695,7 @@ var collectJobs = async (promptFn, accumulated = []) => {
1235
1695
  (v) => /^[a-z0-9-]+$/.test(v),
1236
1696
  "Name must be lowercase letters, numbers, and dashes only."
1237
1697
  );
1238
- const schedule = await promptUntilValid(
1239
- promptFn,
1240
- "Cron pattern (e.g. 0 0 9 * * *): ",
1241
- isValidCron,
1242
- "Invalid cron pattern. Use 6 space-separated fields, e.g. 0 0 9 * * *"
1243
- );
1698
+ const schedule = await promptJobScheduleCron(promptFn);
1244
1699
  const job = ScheduledJobSchema.parse({ name, schedule, command: "send" });
1245
1700
  const jobs = [...accumulated, job];
1246
1701
  const more = (await promptFn("Add another schedule? (y/N) ")).trim().toLowerCase();
@@ -1248,7 +1703,7 @@ var collectJobs = async (promptFn, accumulated = []) => {
1248
1703
  };
1249
1704
  var promptScheduleJobs = async (promptFn) => {
1250
1705
  process.stderr.write(
1251
- "\nConfigure your message schedule (at least one job required).\nJob names: lowercase letters, numbers, and dashes only.\nCron pattern: 6 fields, e.g. 0 0 9 * * * (sec min hour dom month dow)\n\n"
1706
+ "\nConfigure your message schedule (at least one job required).\nJob names: lowercase letters, numbers, and dashes only.\nPick a time and frequency for each \u2014 cron is generated for you.\n\n"
1252
1707
  );
1253
1708
  return collectJobs(promptFn);
1254
1709
  };
@@ -1271,7 +1726,7 @@ var registerSetup = (program2) => {
1271
1726
  return;
1272
1727
  }
1273
1728
  console.warn("Creating config directory...");
1274
- mkdirSync5(join2(homedir2(), ".config", "waify"), { recursive: true });
1729
+ mkdirSync6(join2(homedir2(), ".config", "waify"), { recursive: true });
1275
1730
  const baseUrl = loadConfig().openwaBaseUrl;
1276
1731
  let geminiKey = "";
1277
1732
  while (!geminiKey.trim()) {
@@ -1297,11 +1752,13 @@ var registerSetup = (program2) => {
1297
1752
  }
1298
1753
  }
1299
1754
  const chatId = `${recipientNumber.trim()}@c.us`;
1755
+ const language = await promptLanguage((q) => promptLine(rl, q));
1756
+ const timezone = await promptTimezone((q) => promptLine(rl, q));
1300
1757
  saveSecrets({ GEMINI_API_KEY: geminiKey.trim(), OPENWA_API_KEY: "" });
1301
- saveConfig({ ...loadConfig(), recipients: [{ chatId }] });
1758
+ saveConfig({ ...loadConfig(), recipients: [{ chatId }], language, timezone });
1302
1759
  const jobs = await promptScheduleJobs((q) => promptLine(rl, q));
1303
1760
  console.warn("Writing docker-compose.yml...");
1304
- writeFileSync6(composePath(), composeTemplate(), "utf-8");
1761
+ writeCompose(timezone);
1305
1762
  console.warn(
1306
1763
  "Starting OpenWA containers (this may take a minute on first run)..."
1307
1764
  );
@@ -1572,7 +2029,7 @@ var registerSend = (program2) => {
1572
2029
  assertConfigReady(config);
1573
2030
  const prompt = loadPrompt();
1574
2031
  const provider = createGeminiProvider({ apiKey: secrets.GEMINI_API_KEY });
1575
- const text = await generateMessage({ provider, prompt });
2032
+ const text = await generateMessage({ provider, prompt, language: config.language });
1576
2033
  await sendMessage({
1577
2034
  baseUrl: config.openwaBaseUrl,
1578
2035
  apiKey: secrets.OPENWA_API_KEY,
@@ -1591,17 +2048,19 @@ var registerSend = (program2) => {
1591
2048
  };
1592
2049
 
1593
2050
  // src/cli/commands/preview.ts
2051
+ init_config();
1594
2052
  init_secrets();
1595
2053
  init_prompt();
1596
2054
  init_gemini();
1597
2055
  var registerPreview = (program2) => {
1598
2056
  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 }) => {
1599
2057
  const secrets = loadSecrets();
2058
+ const config = loadConfig();
1600
2059
  const prompt = loadPrompt();
1601
2060
  const provider = createGeminiProvider({ apiKey: secrets.GEMINI_API_KEY });
1602
2061
  const n = Math.max(1, parseInt(count, 10) || 1);
1603
2062
  const messages = await Promise.all(
1604
- Array.from({ length: n }, () => generateMessage({ provider, prompt }))
2063
+ Array.from({ length: n }, () => generateMessage({ provider, prompt, language: config.language }))
1605
2064
  );
1606
2065
  messages.forEach((m, i) => {
1607
2066
  if (n > 1) console.warn(`--- candidate ${i + 1} ---`);
@@ -1754,15 +2213,13 @@ var registerPrompt = (program2) => {
1754
2213
  // src/cli/commands/schedule.ts
1755
2214
  init_schedule();
1756
2215
  init_paths();
1757
- import { spawnSync as spawnSync3 } from "child_process";
1758
- var restartScheduler = () => {
2216
+ init_scheduler();
2217
+ var restartScheduler2 = async () => {
1759
2218
  console.warn("restarting scheduler\u2026");
1760
- const result = spawnSync3("docker", ["compose", "-f", composePath(), "restart", "scheduler"], {
1761
- stdio: "inherit"
1762
- });
1763
- if (result.status !== 0) {
2219
+ const result = await restartScheduler();
2220
+ if (!result.ok) {
1764
2221
  console.warn(
1765
- `warning: scheduler restart failed \u2014 run manually: docker compose -f ${composePath()} restart scheduler`
2222
+ `warning: scheduler restart failed \u2014 run manually: docker compose -f ${composePath()} up -d --force-recreate scheduler`
1766
2223
  );
1767
2224
  }
1768
2225
  };
@@ -1779,16 +2236,16 @@ var registerSchedule = (program2) => {
1779
2236
  `);
1780
2237
  });
1781
2238
  });
1782
- schedule.command("add <name> <cron>").description('Add a new job. <cron> uses 6-field syntax (sec min hour dom month dow), e.g. "0 0 9 * * *"').option("-c, --command <cmd>", "command for the sender container to run", "send").option("--no-restart", "skip automatic scheduler restart").action((name, cron, { command, restart }) => {
2239
+ schedule.command("add <name> <cron>").description('Add a new job. <cron> uses 6-field syntax (sec min hour dom month dow), e.g. "0 0 9 * * *"').option("-c, --command <cmd>", "command for the sender container to run", "send").option("--no-restart", "skip automatic scheduler restart").action(async (name, cron, { command, restart }) => {
1783
2240
  const job = ScheduledJobSchema.parse({ name, schedule: cron, command });
1784
2241
  addJob(job);
1785
2242
  console.warn(`added job "${name}"`);
1786
- if (restart) restartScheduler();
2243
+ if (restart) await restartScheduler2();
1787
2244
  });
1788
- schedule.command("remove <name>").description("Remove a job by name").option("--no-restart", "skip automatic scheduler restart").action((name, { restart }) => {
2245
+ schedule.command("remove <name>").description("Remove a job by name").option("--no-restart", "skip automatic scheduler restart").action(async (name, { restart }) => {
1789
2246
  removeJob(name);
1790
2247
  console.warn(`removed job "${name}"`);
1791
- if (restart) restartScheduler();
2248
+ if (restart) await restartScheduler2();
1792
2249
  });
1793
2250
  };
1794
2251