@flowapt/flowiq-cli 0.1.5 → 0.1.7

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,329 @@
1
+ // `flowiq groups list|pull` — read WhatsApp GROUP chat history out of the
2
+ // FlowMod Evolution Postgres (app.flowmod.ai:5555), mirroring `messages pull`.
3
+ //
4
+ // This is the only CLI command that talks to a database directly instead of
5
+ // going through api.flowiq.live/cli/* — the Evolution DB is a separate Postgres
6
+ // (NOT Supabase, NOT a service-role key). To keep this published npm package
7
+ // secret-free, the connection string comes from the environment, never source:
8
+ //
9
+ // FLOWMOD_EVO_DB_URL=postgres://user:pass@app.flowmod.ai:5555/flowiq-db
10
+ // — or the five vars the flowmod-copilot edge fn already uses —
11
+ // FLOWMOD_DB_HOST / FLOWMOD_DB_PORT / FLOWMOD_DB_USER / FLOWMOD_DB_PASS / FLOWMOD_DB_NAME
12
+ //
13
+ // Read-only: there is no `push` — group history is a system of record.
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+
18
+ const OUT_DIR = path.resolve(process.cwd(), ".flowiq", "groups");
19
+ const DEFAULT_INSTANCE = "flowapt";
20
+ const DEFAULT_LAST_MS = 48 * 3600 * 1000; // 48h
21
+ const DEFAULT_LIMIT = 1000;
22
+
23
+ // ---------- credential resolution (env only — no baked secret) ----------
24
+ function evoConnString() {
25
+ const url = process.env.FLOWMOD_EVO_DB_URL?.trim();
26
+ if (url) return url;
27
+ const host = process.env.FLOWMOD_DB_HOST?.trim();
28
+ const user = process.env.FLOWMOD_DB_USER?.trim();
29
+ const pass = process.env.FLOWMOD_DB_PASS;
30
+ const name = process.env.FLOWMOD_DB_NAME?.trim();
31
+ if (host && user && pass != null && name) {
32
+ const port = process.env.FLOWMOD_DB_PORT?.trim() || "5555";
33
+ return `postgres://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${host}:${port}/${name}`;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ async function connect() {
39
+ const conn = evoConnString();
40
+ if (!conn) {
41
+ console.error(
42
+ "Error: no Evolution DB connection configured.\n" +
43
+ "Set FLOWMOD_EVO_DB_URL (a full postgres:// URL) in your shell env,\n" +
44
+ "or the five FLOWMOD_DB_HOST/PORT/USER/PASS/NAME vars.\n" +
45
+ "Creds are in ~/.claude/context/external-services.md (FlowMod Evolution section)."
46
+ );
47
+ process.exit(1);
48
+ }
49
+ let Client;
50
+ try {
51
+ const mod = await import("pg");
52
+ Client = (mod.default ?? mod).Client;
53
+ } catch {
54
+ console.error("Error: the `pg` package is not installed. Run `npm install` in the cli/ folder.");
55
+ process.exit(1);
56
+ }
57
+ const client = new Client({ connectionString: conn, ssl: false });
58
+ try {
59
+ await client.connect();
60
+ } catch (e) {
61
+ console.error(`Could not connect to the Evolution DB: ${e.message}`);
62
+ process.exit(1);
63
+ }
64
+ return client;
65
+ }
66
+
67
+ // ---------- helpers ----------
68
+ function slugify(name, fallback) {
69
+ const s = String(name || "")
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9]+/g, "-")
72
+ .replace(/-+/g, "-")
73
+ .replace(/^-|-$/g, "");
74
+ return s || fallback;
75
+ }
76
+
77
+ async function fileExists(p) {
78
+ try { await fs.access(p); return true; } catch { return false; }
79
+ }
80
+
81
+ // "48h" | "7d" | "90m" | "2w" -> milliseconds (null if unparseable)
82
+ function parseLast(s) {
83
+ const m = /^(\d+)\s*(m|h|d|w)$/i.exec(String(s).trim());
84
+ if (!m) return null;
85
+ const n = Number(m[1]);
86
+ const mult = { m: 60e3, h: 3600e3, d: 86400e3, w: 604800e3 }[m[2].toLowerCase()];
87
+ return n * mult;
88
+ }
89
+
90
+ // "YYYY-MM-DD" | "YYYY-MM-DD HH:MM[:SS]" interpreted as SAST -> unix seconds
91
+ function sastToUnix(s) {
92
+ const m = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}(?::\d{2})?))?$/.exec(String(s).trim());
93
+ if (!m) return null;
94
+ let time = m[2] || "00:00";
95
+ if (time.length === 5) time += ":00";
96
+ const d = new Date(`${m[1]}T${time}+02:00`);
97
+ return Number.isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
98
+ }
99
+
100
+ // unix seconds -> "YYYY-MM-DD HH:MM" in SAST (full=false drops the year/date prefix into MM-DD)
101
+ function fmtSast(unix, full = true) {
102
+ const d = new Date(unix * 1000);
103
+ const p = new Intl.DateTimeFormat("en-ZA", {
104
+ timeZone: "Africa/Johannesburg",
105
+ year: "numeric", month: "2-digit", day: "2-digit",
106
+ hour: "2-digit", minute: "2-digit", hour12: false,
107
+ }).formatToParts(d).reduce((a, x) => ((a[x.type] = x.value), a), {});
108
+ return full
109
+ ? `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`
110
+ : `${p.month}-${p.day} ${p.hour}:${p.minute}`;
111
+ }
112
+
113
+ // Pull a human-readable body out of Evolution's `message` jsonb.
114
+ function extractText(m) {
115
+ if (!m || typeof m !== "object") return null;
116
+ if (m.conversation) return m.conversation;
117
+ if (m.extendedTextMessage?.text) return m.extendedTextMessage.text;
118
+ if (m.imageMessage) return m.imageMessage.caption ? `[image] ${m.imageMessage.caption}` : "[image]";
119
+ if (m.videoMessage) return m.videoMessage.caption ? `[video] ${m.videoMessage.caption}` : "[video]";
120
+ if (m.audioMessage) return m.audioMessage.ptt ? "[voice note]" : "[audio]";
121
+ if (m.documentMessage) return `[document] ${m.documentMessage.fileName || ""}`.trim();
122
+ if (m.documentWithCaptionMessage?.message?.documentMessage)
123
+ return `[document] ${m.documentWithCaptionMessage.message.documentMessage.fileName || ""}`.trim();
124
+ if (m.stickerMessage) return "[sticker]";
125
+ if (m.locationMessage) return "[location]";
126
+ if (m.contactMessage || m.contactsArrayMessage) return "[contact card]";
127
+ if (m.reactionMessage) return `[reaction ${m.reactionMessage.text || ""}]`.trim();
128
+ if (m.pollCreationMessage || m.pollCreationMessageV3)
129
+ return `[poll] ${(m.pollCreationMessage || m.pollCreationMessageV3).name || ""}`.trim();
130
+ if (m.protocolMessage) return null; // system/internal — skip
131
+ return null;
132
+ }
133
+
134
+ async function resolveInstanceId(client, instanceName) {
135
+ const r = await client.query('SELECT id, "profileName" FROM "Instance" WHERE name = $1', [instanceName]);
136
+ if (!r.rows.length) {
137
+ const all = await client.query('SELECT name FROM "Instance" ORDER BY name');
138
+ console.error(
139
+ `Error: no Evolution instance named "${instanceName}". Available: ${all.rows.map((x) => x.name).join(", ")}`
140
+ );
141
+ process.exit(1);
142
+ }
143
+ return r.rows[0];
144
+ }
145
+
146
+ // ---------- groups list ----------
147
+ export async function list(opts = {}) {
148
+ const instanceName = opts.instance || DEFAULT_INSTANCE;
149
+ const client = await connect();
150
+ try {
151
+ const inst = await resolveInstanceId(client, instanceName);
152
+ const r = await client.query(
153
+ `SELECT ch.name,
154
+ ch."remoteJid" AS jid,
155
+ (SELECT count(DISTINCT m.key->>'id') FROM "Message" m
156
+ WHERE m."instanceId" = $1 AND m.key->>'remoteJid' = ch."remoteJid") AS msgs,
157
+ (SELECT max((m."messageTimestamp")::bigint) FROM "Message" m
158
+ WHERE m."instanceId" = $1 AND m.key->>'remoteJid' = ch."remoteJid") AS last_ts
159
+ FROM "Chat" ch
160
+ WHERE ch."instanceId" = $1 AND ch."remoteJid" LIKE '%@g.us'
161
+ ORDER BY last_ts DESC NULLS LAST`,
162
+ [inst.id]
163
+ );
164
+ console.log(`Instance "${instanceName}" (${inst.profileName ?? "?"}) — ${r.rows.length} group chats\n`);
165
+ const rows = r.rows.map((x) => ({
166
+ group: x.name || "(no name)",
167
+ msgs: Number(x.msgs || 0),
168
+ last: x.last_ts ? fmtSast(Number(x.last_ts)) : "—",
169
+ jid: x.jid,
170
+ }));
171
+ const wName = Math.min(48, Math.max(...rows.map((x) => x.group.length), 5));
172
+ for (const x of rows) {
173
+ console.log(
174
+ ` ${x.group.padEnd(wName)} ${String(x.msgs).padStart(5)} msgs last ${x.last.padEnd(16)} ${x.jid}`
175
+ );
176
+ }
177
+ } finally {
178
+ await client.end();
179
+ }
180
+ }
181
+
182
+ // ---------- groups pull ----------
183
+ export async function pull(nameOrJid, opts = {}) {
184
+ const instanceName = opts.instance || DEFAULT_INSTANCE;
185
+ const limit = Math.max(1, Math.min(Number(opts.limit) || DEFAULT_LIMIT, 5000));
186
+
187
+ // window
188
+ let untilUnix = Math.floor(Date.now() / 1000);
189
+ let sinceUnix;
190
+ if (opts.until) {
191
+ const u = sastToUnix(opts.until);
192
+ if (u == null) { console.error(`Error: --until "${opts.until}" is not a valid date (use YYYY-MM-DD or "YYYY-MM-DD HH:MM").`); process.exit(1); }
193
+ untilUnix = u;
194
+ }
195
+ if (opts.since) {
196
+ const s = sastToUnix(opts.since);
197
+ if (s == null) { console.error(`Error: --since "${opts.since}" is not a valid date (use YYYY-MM-DD or "YYYY-MM-DD HH:MM").`); process.exit(1); }
198
+ sinceUnix = s;
199
+ } else {
200
+ const lastMs = opts.last ? parseLast(opts.last) : DEFAULT_LAST_MS;
201
+ if (lastMs == null) { console.error(`Error: --last "${opts.last}" is not valid (use e.g. 48h, 7d, 2w, 90m).`); process.exit(1); }
202
+ sinceUnix = untilUnix - Math.floor(lastMs / 1000);
203
+ }
204
+ if (sinceUnix > untilUnix) { console.error("Error: --since is after --until."); process.exit(1); }
205
+
206
+ const client = await connect();
207
+ try {
208
+ const inst = await resolveInstanceId(client, instanceName);
209
+
210
+ // resolve the group: exact JID, else exact name (case-insensitive), else contains
211
+ const groups = await client.query(
212
+ `SELECT ch."remoteJid" AS jid, ch.name FROM "Chat" ch
213
+ WHERE ch."instanceId" = $1 AND ch."remoteJid" LIKE '%@g.us'`,
214
+ [inst.id]
215
+ );
216
+ let match;
217
+ const arg = String(nameOrJid).trim();
218
+ if (/@g\.us$/.test(arg)) {
219
+ match = groups.rows.find((g) => g.jid === arg);
220
+ } else {
221
+ const lc = arg.toLowerCase();
222
+ const exact = groups.rows.filter((g) => (g.name || "").toLowerCase() === lc);
223
+ const contains = groups.rows.filter((g) => (g.name || "").toLowerCase().includes(lc));
224
+ const hits = exact.length ? exact : contains;
225
+ if (hits.length > 1) {
226
+ console.error(`"${arg}" matches ${hits.length} groups — be more specific or pass the JID:`);
227
+ for (const g of hits) console.error(` ${g.name} ${g.jid}`);
228
+ process.exit(1);
229
+ }
230
+ match = hits[0];
231
+ }
232
+ if (!match) {
233
+ console.error(`Error: no group matching "${arg}" on instance "${instanceName}". Try \`flowiq groups list --instance ${instanceName}\`.`);
234
+ process.exit(1);
235
+ }
236
+
237
+ // pull deduped messages in window
238
+ const r = await client.query(
239
+ `SELECT DISTINCT ON (m.key->>'id')
240
+ m.key->>'id' AS mid,
241
+ m."pushName" AS push_name,
242
+ m."participant" AS participant,
243
+ m."messageType" AS message_type,
244
+ m.message AS message,
245
+ (m."messageTimestamp")::bigint AS ts,
246
+ m.key->>'fromMe' AS from_me
247
+ FROM "Message" m
248
+ WHERE m."instanceId" = $1 AND m.key->>'remoteJid' = $2
249
+ AND (m."messageTimestamp")::bigint >= $3
250
+ AND (m."messageTimestamp")::bigint <= $4
251
+ ORDER BY m.key->>'id', m."messageTimestamp" DESC`,
252
+ [inst.id, match.jid, sinceUnix, untilUnix]
253
+ );
254
+
255
+ let rows = r.rows
256
+ .map((x) => {
257
+ const fromMe = x.from_me === "true" || x.from_me === true;
258
+ return {
259
+ ts: Number(x.ts),
260
+ ts_sast: fmtSast(Number(x.ts)),
261
+ sender: fromMe ? (inst.profileName || "Flowapt") : (x.push_name || "(unknown)"),
262
+ from_me: fromMe,
263
+ participant: x.participant || null,
264
+ type: x.message_type,
265
+ text: extractText(x.message),
266
+ };
267
+ })
268
+ .sort((a, b) => a.ts - b.ts);
269
+
270
+ const totalInWindow = rows.length;
271
+ let truncated = 0;
272
+ if (rows.length > limit) {
273
+ truncated = rows.length - limit;
274
+ rows = rows.slice(rows.length - limit); // keep the most recent `limit`
275
+ }
276
+
277
+ // participants
278
+ const participants = {};
279
+ for (const m of rows) if (!m.from_me) participants[m.sender] = (participants[m.sender] || 0) + 1;
280
+
281
+ const payload = {
282
+ instance: instanceName,
283
+ group_name: match.name,
284
+ group_jid: match.jid,
285
+ window: { since_sast: fmtSast(sinceUnix), until_sast: fmtSast(untilUnix) },
286
+ pulled_at_sast: fmtSast(Math.floor(Date.now() / 1000)),
287
+ message_count: rows.length,
288
+ messages_in_window: totalInWindow,
289
+ truncated_older: truncated,
290
+ participant_counts: participants,
291
+ messages: rows.map(({ ts, ts_sast, sender, from_me, type, text }) => ({ ts_sast, sender, from_me, type, text })),
292
+ };
293
+
294
+ await fs.mkdir(OUT_DIR, { recursive: true });
295
+ const slug = `${slugify(instanceName, "inst")}-${slugify(match.name, match.jid.split("@")[0])}`;
296
+ const filePath = path.join(OUT_DIR, `${slug}.json`);
297
+ const overwriting = await fileExists(filePath);
298
+ await fs.writeFile(filePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
299
+
300
+ if (opts.json) {
301
+ console.log(JSON.stringify(payload, null, 2));
302
+ return;
303
+ }
304
+
305
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
306
+ console.log(` group: ${match.name} (${match.jid})`);
307
+ console.log(` instance: ${instanceName} (${inst.profileName ?? "?"})`);
308
+ console.log(` window: ${payload.window.since_sast} → ${payload.window.until_sast} SAST`);
309
+ console.log(` messages: ${rows.length}${truncated ? ` (of ${totalInWindow} in window — ${truncated} older dropped by --limit ${limit})` : ""}`);
310
+ console.log(` participants: ${Object.keys(participants).length}`);
311
+
312
+ if (!rows.length) {
313
+ console.log("\n (no messages in this window)");
314
+ return;
315
+ }
316
+
317
+ if (rows.length > 400) {
318
+ console.log(`\n Timeline has ${rows.length} messages — full text is in the JSON file above.`);
319
+ return;
320
+ }
321
+ console.log("");
322
+ for (const m of rows) {
323
+ const t = fmtSast(m.ts, false);
324
+ console.log(` [${t}] ${m.sender}: ${m.text ?? `(${m.type} — no readable text)`}`);
325
+ }
326
+ } finally {
327
+ await client.end();
328
+ }
329
+ }
@@ -0,0 +1,33 @@
1
+ // `flowiq org info <organization_id>` — read-only org summary (platform detect
2
+ // + active agent) for the prompt-builder skill's Step 0. Raw store/Meta creds
3
+ // are stripped server-side; this only prints derived booleans + public urls.
4
+
5
+ import { http } from "../http.js";
6
+
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+
9
+ export async function info(orgId, opts = {}) {
10
+ if (!UUID_RE.test(orgId)) {
11
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
12
+ process.exit(1);
13
+ }
14
+ let resp;
15
+ try {
16
+ resp = await http.get("org", { organization_id: orgId });
17
+ } catch (e) {
18
+ console.error(`Lookup failed: ${e.message}`);
19
+ process.exit(1);
20
+ }
21
+
22
+ if (opts.json) {
23
+ console.log(JSON.stringify(resp, null, 2));
24
+ return;
25
+ }
26
+
27
+ console.log(`${resp.name} (${resp.id})`);
28
+ console.log(` platform: ${resp.platform || "(neither shopify nor woo)"}${resp.ip_whitelist ? " [ip_whitelist]" : ""}`);
29
+ if (resp.shopify_domain) console.log(` shopify: ${resp.shopify_domain}`);
30
+ if (resp.woo_store_url) console.log(` woo store: ${resp.woo_store_url}`);
31
+ console.log(` provider: ${resp.provider || "(unset → meta)"}`);
32
+ console.log(` active agent: ${resp.active_whatsapp_agent || "(NONE — set one before building a prompt)"}`);
33
+ }
@@ -0,0 +1,174 @@
1
+ // `flowiq pinboard pull <task_id>` / `push <slug>` / `list` / `list-remote [status]`.
2
+ // Round-trips a single Pin Board task (flowapt_tasks row) via /cli/pinboard.
3
+ // No DB credentials on disk — all work happens server-side.
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { http } from "../http.js";
8
+
9
+ const PIN_DIR = path.resolve(process.cwd(), ".flowiq", "pinboard");
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ const VALID_STATUS = ["open", "in_progress", "done"];
12
+
13
+ function slugify(name, fallback) {
14
+ const s = String(name || "")
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/-+/g, "-")
18
+ .replace(/^-|-$/g, "");
19
+ return (s || fallback).slice(0, 60);
20
+ }
21
+
22
+ async function fileExists(p) {
23
+ try { await fs.access(p); return true; } catch { return false; }
24
+ }
25
+
26
+ async function resolvePushPath(identifier) {
27
+ const candidates = [];
28
+ if (path.isAbsolute(identifier)) {
29
+ candidates.push(identifier);
30
+ } else if (identifier.includes("/") || identifier.includes("\\")) {
31
+ candidates.push(path.resolve(identifier));
32
+ } else {
33
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
34
+ candidates.push(path.join(PIN_DIR, base));
35
+ candidates.push(path.resolve(base));
36
+ }
37
+ for (const c of candidates) {
38
+ if (await fileExists(c)) return c;
39
+ }
40
+ throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
41
+ }
42
+
43
+ export async function pull(taskId) {
44
+ if (!UUID_RE.test(taskId)) {
45
+ console.error(`Error: "${taskId}" is not a valid task UUID.`);
46
+ process.exit(1);
47
+ }
48
+ let resp;
49
+ try {
50
+ resp = await http.get("pinboard", { task_id: taskId });
51
+ } catch (e) {
52
+ console.error(`Pull failed: ${e.message}`);
53
+ process.exit(1);
54
+ }
55
+
56
+ await fs.mkdir(PIN_DIR, { recursive: true });
57
+ const slug = slugify(resp.name, resp.id);
58
+ const filePath = path.join(PIN_DIR, `${slug}.json`);
59
+ const overwriting = await fileExists(filePath);
60
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
61
+
62
+ const orgs = resp.organizations_resolved || [];
63
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
64
+ console.log(` id: ${resp.id}`);
65
+ console.log(` name: ${resp.name}`);
66
+ console.log(` status: ${resp.status} / ${resp.urgency}`);
67
+ console.log(` due: ${resp.preferred_due_date || "(none)"}`);
68
+ console.log(` orgs: ${(resp.organizations || []).length}${orgs.length ? " (" + orgs.map((o) => o.name || o.id.slice(0, 8)).join(", ") + ")" : ""}`);
69
+ console.log(` media: ${(resp.media || []).length}`);
70
+ console.log(` messages: ${(resp.messages || []).length}`);
71
+ console.log(` created: ${resp.created_by} @ ${resp.created_at}`);
72
+ }
73
+
74
+ export async function push(identifier) {
75
+ let filePath;
76
+ try {
77
+ filePath = await resolvePushPath(identifier);
78
+ } catch (e) {
79
+ console.error(`Error: ${e.message}`);
80
+ process.exit(1);
81
+ }
82
+ const raw = await fs.readFile(filePath, "utf8");
83
+ let data;
84
+ try { data = JSON.parse(raw); }
85
+ catch (e) {
86
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
87
+ process.exit(1);
88
+ }
89
+ if (!data.id || !UUID_RE.test(data.id)) {
90
+ console.error(`${filePath}: id missing or invalid`);
91
+ process.exit(1);
92
+ }
93
+
94
+ let resp;
95
+ try {
96
+ resp = await http.post("pinboard", {
97
+ id: data.id,
98
+ name: data.name,
99
+ description: data.description ?? null,
100
+ status: data.status,
101
+ urgency: data.urgency,
102
+ preferred_due_date: data.preferred_due_date ?? null,
103
+ organizations: data.organizations ?? [],
104
+ media: data.media ?? [],
105
+ messages: data.messages ?? [],
106
+ created_by: data.created_by,
107
+ });
108
+ } catch (e) {
109
+ console.error(`Push failed: ${e.message}`);
110
+ process.exit(1);
111
+ }
112
+
113
+ console.log(`Pushed ${filePath}`);
114
+ console.log(` id: ${resp.id}`);
115
+ console.log(` name: ${resp.name}`);
116
+ console.log(` status: ${resp.status} / ${resp.urgency}`);
117
+ console.log(` due: ${resp.preferred_due_date || "(none)"}`);
118
+ console.log(` orgs: ${resp.orgs}`);
119
+ console.log(` media: ${resp.media}`);
120
+ console.log(` messages: ${resp.messages}`);
121
+ }
122
+
123
+ export async function list() {
124
+ let files;
125
+ try {
126
+ files = (await fs.readdir(PIN_DIR)).filter((f) => f.endsWith(".json"));
127
+ } catch {
128
+ console.log("(no local task files; run `pinboard pull <task_id>`, or `pinboard list-remote` to see ids)");
129
+ return;
130
+ }
131
+ if (!files.length) {
132
+ console.log("(no local task files; run `pinboard pull <task_id>`, or `pinboard list-remote` to see ids)");
133
+ return;
134
+ }
135
+ files.sort();
136
+ for (const f of files) {
137
+ const fp = path.join(PIN_DIR, f);
138
+ try {
139
+ const d = JSON.parse(await fs.readFile(fp, "utf8"));
140
+ console.log(f);
141
+ console.log(` id: ${d.id || "?"}`);
142
+ console.log(` name: ${d.name || "(unknown)"}`);
143
+ console.log(` status: ${d.status || "?"} / ${d.urgency || "?"}`);
144
+ console.log(` msgs: ${Array.isArray(d.messages) ? d.messages.length : "?"}`);
145
+ console.log(` pulled: ${d.pulled_at || "(unknown)"}`);
146
+ } catch (e) {
147
+ console.log(`${f} [invalid JSON: ${e.message}]`);
148
+ }
149
+ }
150
+ }
151
+
152
+ export async function listRemote(statusFilter) {
153
+ if (statusFilter && !VALID_STATUS.includes(statusFilter)) {
154
+ console.error(`Invalid status filter "${statusFilter}". Use one of ${VALID_STATUS.join(" | ")}.`);
155
+ process.exit(1);
156
+ }
157
+ let resp;
158
+ try {
159
+ resp = await http.get("pinboard", { list: "1", status: statusFilter });
160
+ } catch (e) {
161
+ console.error(`List failed: ${e.message}`);
162
+ process.exit(1);
163
+ }
164
+ const tasks = resp.tasks || [];
165
+ if (!tasks.length) {
166
+ console.log(statusFilter ? `(no tasks with status=${statusFilter})` : "(no tasks in flowapt_tasks)");
167
+ return;
168
+ }
169
+ console.log(`Found ${tasks.length} task${tasks.length === 1 ? "" : "s"}${statusFilter ? ` (status=${statusFilter})` : ""}:\n`);
170
+ for (const t of tasks) {
171
+ const updated = t.updated_at ? new Date(t.updated_at).toISOString().replace("T", " ").slice(0, 16) : "?";
172
+ console.log(` ${t.id} [${String(t.status).padEnd(11)}] [${String(t.urgency).padEnd(8)}] ${updated} ${t.created_by} — ${t.name}`);
173
+ }
174
+ }
@@ -0,0 +1,156 @@
1
+ // `flowiq templates pull <org_id>` / `list` — read WhatsApp templates from Meta.
2
+ // `flowiq templates create <org> --request-file f.json` / `status` — §12 (create).
3
+ // Pull is read-only via /cli/templates; create proxies /cli/meta-templates.
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { http } from "../http.js";
8
+
9
+ const TPL_DIR = path.resolve(process.cwd(), ".flowiq", "templates");
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+
12
+ function slugify(name, fallback) {
13
+ const s = String(name || "")
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9]+/g, "-")
16
+ .replace(/-+/g, "-")
17
+ .replace(/^-|-$/g, "");
18
+ return s || fallback;
19
+ }
20
+
21
+ async function fileExists(p) {
22
+ try { await fs.access(p); return true; } catch { return false; }
23
+ }
24
+
25
+ export async function pull(orgId) {
26
+ if (!UUID_RE.test(orgId)) {
27
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
28
+ process.exit(1);
29
+ }
30
+ let resp;
31
+ try {
32
+ resp = await http.get("templates", { organization_id: orgId });
33
+ } catch (e) {
34
+ console.error(`Pull failed: ${e.message}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ await fs.mkdir(TPL_DIR, { recursive: true });
39
+ const slug = slugify(resp.organization_name, resp.organization_id);
40
+ const filePath = path.join(TPL_DIR, `${slug}.json`);
41
+ const overwriting = await fileExists(filePath);
42
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
43
+
44
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
45
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
46
+ console.log(` waba: ${resp.waba_id}`);
47
+ if (resp.meta_phone_number) console.log(` phone: ${resp.meta_phone_number}`);
48
+ console.log(` templates: ${resp.template_count}`);
49
+ console.log(` status: ${JSON.stringify(resp.summary?.byStatus || {})}`);
50
+ console.log(` category: ${JSON.stringify(resp.summary?.byCategory || {})}`);
51
+ console.log(` language: ${JSON.stringify(resp.summary?.byLanguage || {})}`);
52
+ }
53
+
54
+ export async function create(orgId, opts = {}) {
55
+ if (!opts.requestFile) {
56
+ console.error("Error: --request-file <path> is required (JSON with template_request, optional template_data/media_header/cards_media).");
57
+ process.exit(1);
58
+ }
59
+ let filePath = opts.requestFile;
60
+ if (!path.isAbsolute(filePath)) filePath = path.resolve(filePath);
61
+ let data;
62
+ try {
63
+ data = JSON.parse(await fs.readFile(filePath, "utf8"));
64
+ } catch (e) {
65
+ console.error(`Cannot read request file ${filePath}: ${e.message}`);
66
+ process.exit(1);
67
+ }
68
+
69
+ // The file may be the full edge-fn body, or just the inner template_request.
70
+ const organizationId = orgId || data.organization_id;
71
+ if (!organizationId || !UUID_RE.test(organizationId)) {
72
+ console.error("Error: organization_id missing/invalid (pass <org_id> or include it in the request file).");
73
+ process.exit(1);
74
+ }
75
+ const templateRequest = data.template_request || (data.name ? data : null);
76
+ if (!templateRequest) {
77
+ console.error("Error: request file must contain a `template_request` object (or be one, with a `name`).");
78
+ process.exit(1);
79
+ }
80
+
81
+ let resp;
82
+ try {
83
+ resp = await http.post("meta-templates", {
84
+ organization_id: organizationId,
85
+ template_request: templateRequest,
86
+ template_data: data.template_data,
87
+ media_header: data.media_header,
88
+ cards_media: data.cards_media,
89
+ });
90
+ } catch (e) {
91
+ console.error(`Create failed: ${e.message}`);
92
+ if (e.body) console.error(JSON.stringify(e.body, null, 2));
93
+ process.exit(1);
94
+ }
95
+
96
+ console.log(`Submitted template "${templateRequest.name}" for org ${organizationId}`);
97
+ console.log(JSON.stringify(resp, null, 2));
98
+ }
99
+
100
+ export async function status(orgId, opts = {}) {
101
+ if (!UUID_RE.test(orgId)) {
102
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
103
+ process.exit(1);
104
+ }
105
+ let resp;
106
+ try {
107
+ resp = await http.get("meta-templates", { organization_id: orgId, name: opts.name });
108
+ } catch (e) {
109
+ console.error(`Status lookup failed: ${e.message}`);
110
+ process.exit(1);
111
+ }
112
+ const rows = resp.templates || [];
113
+ if (!rows.length) {
114
+ console.log(opts.name ? `(no templates matching "${opts.name}")` : "(no templates on record for this org)");
115
+ return;
116
+ }
117
+ console.log(`${rows.length} template row${rows.length === 1 ? "" : "s"}${opts.name ? ` matching "${opts.name}"` : ""}:`);
118
+ for (const t of rows) {
119
+ const name = t.template_name || t.name || "(unnamed)";
120
+ const st = t.status || "(no status)";
121
+ const cat = t.category ? ` [${t.category}]` : "";
122
+ console.log(` ${name.padEnd(40)} ${String(st).padEnd(16)}${cat}`);
123
+ }
124
+ console.log("\nNote: status is captured at submit time (older rows may be null). For the live");
125
+ console.log("Meta-side status + rejection reasons, run `templates pull <org_id>`.");
126
+ }
127
+
128
+ export async function list() {
129
+ let files;
130
+ try {
131
+ files = (await fs.readdir(TPL_DIR)).filter((f) => f.endsWith(".json"));
132
+ } catch {
133
+ console.log("(no local template files; run `templates pull <org_id>`)");
134
+ return;
135
+ }
136
+ if (!files.length) {
137
+ console.log("(no local template files; run `templates pull <org_id>`)");
138
+ return;
139
+ }
140
+ files.sort();
141
+ for (const f of files) {
142
+ const fp = path.join(TPL_DIR, f);
143
+ try {
144
+ const d = JSON.parse(await fs.readFile(fp, "utf8"));
145
+ console.log(f);
146
+ console.log(` org: ${d.organization_name ?? "(unknown)"} (${d.organization_id ?? "?"})`);
147
+ console.log(` waba: ${d.waba_id ?? "?"}`);
148
+ console.log(` phone: ${d.meta_phone_number ?? "(none)"}`);
149
+ console.log(` templates: ${d.template_count ?? (Array.isArray(d.templates) ? d.templates.length : "?")}`);
150
+ if (d.summary?.byStatus) console.log(` status: ${JSON.stringify(d.summary.byStatus)}`);
151
+ console.log(` pulled: ${d.pulled_at ?? "(unknown)"}`);
152
+ } catch (e) {
153
+ console.log(`${f} [invalid JSON: ${e.message}]`);
154
+ }
155
+ }
156
+ }