@flowapt/flowiq-cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # flowiq
2
2
 
3
3
  FlowIQ staff CLI. Round-trips agent prompts, questionnaires, fine-tuning,
4
- contact chats, platform webhooks, and outbound messaging webhooks through
5
- `https://api.flowiq.live/cli/*` so the Supabase service role never leaves
6
- the server.
4
+ contact chats, platform webhooks, outbound messaging webhooks, and FlowMod
5
+ master-group prompts through `https://api.flowiq.live/cli/*` so the Supabase
6
+ service role never leaves the server.
7
7
 
8
8
  ## Install
9
9
 
@@ -150,12 +150,74 @@ Validation: `platform` ∈ `{whatsapp, web}`, `type` from the per-platform
150
150
  allowed enum, valid http(s) URL, no duplicate (platform, type, url)
151
151
  tuples.
152
152
 
153
+ ### FlowMod prompts — `flowiq flowmod pull|push <slug>` (alias `fm`)
154
+
155
+ Round-trips a FlowMod org's **master-group** prompts + config. FlowMod groups
156
+ are a separate system from regular WhatsApp agents — their prompts live in the
157
+ `flowmod_master_groups` (model/pre-filter/config/signatures/custom_tools) and
158
+ `flowmod_prompts` (the `main` / `extra` prompt rows, keyed by `master_group_id`)
159
+ tables, read per-message by the `flowmod-group-*` webhooks.
160
+
161
+ ```bash
162
+ flowiq flowmod pull <organization_id>
163
+ # edit ./.flowiq/flowmod/<slug>.json
164
+ flowiq fm push <slug>
165
+ ```
166
+
167
+ The pulled JSON has a `master_groups[]` array; each entry carries its editable
168
+ fields plus a `prompts[]` array (`{ id, type, group_ids, text, data }`). Push
169
+ updates only editable columns — prompt `text`/`data`/`group_ids`/`type`, and
170
+ master-group `name`/`description`/`config`/`pre_filter_prompt`/`events_markdown`/
171
+ `custom_signature*`/`custom_tools` — scoped by row id; it never touches PKs,
172
+ FKs, or timestamps, and there's no `system_prompt` regeneration (FlowMod reads
173
+ the prompt rows directly).
174
+
175
+ Common uses: edit a master-group's moderator prompt, or flip its reasoning
176
+ effort via `config.reasoningEffort` (`none` / `low` / `medium` / `high`).
177
+
178
+ ### Group chats — `flowiq groups list|pull <name-or-jid>` (alias `grp`)
179
+
180
+ Read-only pull of **WhatsApp group** chat history (e.g. the `… x Flowapt`
181
+ client comms groups) straight out of the FlowMod **Evolution** Postgres. This
182
+ is the analogue of `messages pull`, but for a group instead of a contact, so
183
+ you can pull a window and summarise it.
184
+
185
+ > ⚠️ Unlike every other command, this talks to a database **directly** (the
186
+ > Evolution DB is not Supabase and not the `fiq_staff_…` API). To keep this
187
+ > published package secret-free, the connection string comes from your shell
188
+ > environment — set `FLOWMOD_EVO_DB_URL` (or the five `FLOWMOD_DB_*` vars the
189
+ > `flowmod-copilot` edge fn uses) before running. No auth token is needed.
190
+
191
+ ```bash
192
+ export FLOWMOD_EVO_DB_URL='postgres://user:pass@app.flowmod.ai:5555/flowiq-db'
193
+
194
+ flowiq groups list # default instance: flowapt
195
+ flowiq groups list --instance FlowMod # the other connected instance
196
+
197
+ flowiq groups pull "Phytoceutics x Flowapt" --last 7d
198
+ flowiq groups pull "Zorora x Flowapt" --since 2026-06-24 --until "2026-06-25 12:00"
199
+ flowiq grp pull 120363408455897717@g.us --last 48h # or pass the group JID
200
+ # writes ./.flowiq/groups/<instance>-<group-slug>.json + prints a timeline
201
+ ```
202
+
203
+ - Group is matched by JID, exact name, or a unique case-insensitive substring
204
+ (ambiguous matches list the candidates and exit — pass the JID to disambiguate).
205
+ - Window: `--last <48h|7d|2w|90m>` (default `48h`), or an explicit
206
+ `--since`/`--until` (SAST; `--until` defaults to now). `--limit` (default
207
+ 1000) keeps the most-recent N in the window.
208
+ - `--json` prints the full payload to stdout instead of the timeline.
209
+ - Read-only — there is no `push`; group history is a system of record.
210
+ View-once/disappearing messages and raw media bodies can't be read (captions
211
+ and text come through); history before the instance joined a group may be gappy.
212
+
153
213
  ## Environment variable overrides
154
214
 
155
215
  | Var | Default | Use |
156
216
  |---|---|---|
157
217
  | `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (local dev, staging). |
158
218
  | `FLOWIQ_TOKEN` | (saved in `~/.config/flowiq/auth.json`) | Override the auth token, useful for CI. |
219
+ | `FLOWMOD_EVO_DB_URL` | (none) | Full `postgres://` URL for `groups` (Evolution DB). Required for `groups`. |
220
+ | `FLOWMOD_DB_HOST/PORT/USER/PASS/NAME` | (none) | Alternative to `FLOWMOD_EVO_DB_URL` — assembled into a connection string. |
159
221
 
160
222
  ## Upgrading
161
223
 
package/bin/flowiq.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.1.5",
4
- "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, webhooks, and more without ever touching service-role credentials.",
3
+ "version": "0.1.6",
4
+ "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, FlowMod master-group prompts, webhooks, and more without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowiq": "bin/flowiq.js"
@@ -18,7 +18,8 @@
18
18
  "start": "node bin/flowiq.js"
19
19
  },
20
20
  "dependencies": {
21
- "commander": "^12.1.0"
21
+ "commander": "^12.1.0",
22
+ "pg": "^8.22.0"
22
23
  },
23
24
  "homepage": "https://github.com/MattFlowapt/flow-v1/tree/main/cli",
24
25
  "repository": {
@@ -0,0 +1,130 @@
1
+ // `flowiq flowmod pull <organization_id>` /
2
+ // `flowiq flowmod push <slug>`.
3
+ // Round-trips an org's FlowMod master-group prompts + config (the
4
+ // flowmod_master_groups + flowmod_prompts tables) through a local JSON file.
5
+ // No DB credentials on disk — all privileged work happens server-side in
6
+ // /api/cli/flowmod.
7
+
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { http } from "../http.js";
11
+
12
+ const FLOWMOD_DIR = path.resolve(process.cwd(), ".flowiq", "flowmod");
13
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+
15
+ function slugify(name, fallback) {
16
+ const s = String(name || "")
17
+ .toLowerCase()
18
+ .replace(/&/g, "and")
19
+ .replace(/[^a-z0-9]+/g, "-")
20
+ .replace(/-+/g, "-")
21
+ .replace(/^-|-$/g, "");
22
+ return s || fallback;
23
+ }
24
+
25
+ async function fileExists(p) {
26
+ try { await fs.access(p); return true; } catch { return false; }
27
+ }
28
+
29
+ async function resolvePushPath(identifier) {
30
+ const candidates = [];
31
+ if (path.isAbsolute(identifier)) {
32
+ candidates.push(identifier);
33
+ } else if (identifier.includes("/") || identifier.includes("\\")) {
34
+ candidates.push(path.resolve(identifier));
35
+ } else {
36
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
37
+ candidates.push(path.join(FLOWMOD_DIR, base));
38
+ candidates.push(path.resolve(base));
39
+ }
40
+ for (const c of candidates) {
41
+ if (await fileExists(c)) return c;
42
+ }
43
+ throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
44
+ }
45
+
46
+ function mainPromptChars(mg) {
47
+ const prompts = mg.prompts || [];
48
+ // Prefer the master-level main (no group_ids); fall back to any main prompt
49
+ // (some master_groups pin their main prompt to specific group_ids).
50
+ const main =
51
+ prompts.find((p) => p.type === "main" && (!Array.isArray(p.group_ids) || p.group_ids.length === 0)) ||
52
+ prompts.find((p) => p.type === "main");
53
+ return main ? (main.text || "").length : 0;
54
+ }
55
+
56
+ export async function pull(orgId) {
57
+ if (!UUID_RE.test(orgId)) {
58
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
59
+ process.exit(1);
60
+ }
61
+ let resp;
62
+ try {
63
+ resp = await http.get("flowmod", { organization_id: orgId });
64
+ } catch (e) {
65
+ console.error(`Pull failed: ${e.message}`);
66
+ process.exit(1);
67
+ }
68
+
69
+ await fs.mkdir(FLOWMOD_DIR, { recursive: true });
70
+ const slug = slugify(resp.organization_name, resp.organization_id);
71
+ const filePath = path.join(FLOWMOD_DIR, `${slug}.json`);
72
+ const overwriting = await fileExists(filePath);
73
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
74
+
75
+ const totalPrompts = (resp.master_groups || []).reduce((a, mg) => a + (mg.prompts?.length ?? 0), 0);
76
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
77
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
78
+ console.log(` master_groups: ${resp.master_groups?.length ?? 0} (${totalPrompts} prompt rows)`);
79
+ for (const mg of resp.master_groups || []) {
80
+ const cfg = mg.config || {};
81
+ const cfgBits = [
82
+ cfg.model && `model=${cfg.model}`,
83
+ cfg.reasoningEffort && `effort=${cfg.reasoningEffort}`,
84
+ Array.isArray(mg.custom_tools) && mg.custom_tools.length ? `${mg.custom_tools.length} tools` : null,
85
+ ].filter(Boolean).join(", ");
86
+ console.log(` • ${mg.name}: main=${mainPromptChars(mg)} chars${cfgBits ? ` (${cfgBits})` : ""}`);
87
+ }
88
+ }
89
+
90
+ export async function push(identifier) {
91
+ let filePath;
92
+ try {
93
+ filePath = await resolvePushPath(identifier);
94
+ } catch (e) {
95
+ console.error(`Error: ${e.message}`);
96
+ process.exit(1);
97
+ }
98
+ const raw = await fs.readFile(filePath, "utf8");
99
+ let data;
100
+ try { data = JSON.parse(raw); }
101
+ catch (e) {
102
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
103
+ process.exit(1);
104
+ }
105
+
106
+ if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
107
+ console.error(`${filePath}: organization_id missing or invalid`);
108
+ process.exit(1);
109
+ }
110
+ if (!Array.isArray(data.master_groups) || data.master_groups.length === 0) {
111
+ console.error(`${filePath}: master_groups must be a non-empty array`);
112
+ process.exit(1);
113
+ }
114
+
115
+ let resp;
116
+ try {
117
+ resp = await http.post("flowmod", {
118
+ organization_id: data.organization_id,
119
+ master_groups: data.master_groups,
120
+ });
121
+ } catch (e) {
122
+ console.error(`Push failed: ${e.message}`);
123
+ process.exit(1);
124
+ }
125
+
126
+ console.log(`Pushed ${filePath}`);
127
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
128
+ console.log(` master_groups: ${resp.master_groups_updated} updated`);
129
+ console.log(` prompts: ${resp.prompts_updated} updated`);
130
+ }
@@ -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
+ }
package/src/index.js CHANGED
@@ -12,6 +12,8 @@ import * as messagesCmd from "./commands/messages.js";
12
12
  import * as fineTuningCmd from "./commands/fine-tuning.js";
13
13
  import * as messagingWebhooksCmd from "./commands/messaging-webhooks.js";
14
14
  import * as webhooksCmd from "./commands/webhooks.js";
15
+ import * as flowmodCmd from "./commands/flowmod.js";
16
+ import * as groupsCmd from "./commands/groups.js";
15
17
 
16
18
  // Read version from package.json so it stays in sync with the published npm
17
19
  // version automatically (single source of truth — bumping package.json on each
@@ -103,5 +105,34 @@ export function run(argv) {
103
105
  .description("WooCommerce-only: flip a webhook back to status=active (WC auto-disables after delivery failures)")
104
106
  .action((id, webhookId) => webhooksCmd.reconnect(id, webhookId));
105
107
 
108
+ // flowmod (FlowMod master-group prompts + config: flowmod_master_groups + flowmod_prompts)
109
+ const flowmod = program.command("flowmod")
110
+ .alias("fm")
111
+ .description("Round-trip a FlowMod org's master-group prompts + config (flowmod_master_groups + flowmod_prompts)");
112
+ flowmod.command("pull <organization_id>")
113
+ .description("Fetch all master_groups + their flowmod_prompts into a local JSON file")
114
+ .action((orgId) => flowmodCmd.pull(orgId));
115
+ flowmod.command("push <slug-or-path>")
116
+ .description("Apply a local JSON file's master_group + prompt edits (text, config, pre_filter_prompt, signatures)")
117
+ .action((id) => flowmodCmd.push(id));
118
+
119
+ // groups (read-only: pull WhatsApp GROUP chat history from the FlowMod Evolution DB)
120
+ const groups = program.command("groups")
121
+ .alias("grp")
122
+ .description("Read WhatsApp group chat history from the FlowMod Evolution DB (set FLOWMOD_EVO_DB_URL env). Read-only.");
123
+ groups.command("list")
124
+ .description("List an Evolution instance's group chats with message counts + last activity")
125
+ .option("--instance <name>", "Evolution instance name", "flowapt")
126
+ .action((opts) => groupsCmd.list(opts));
127
+ groups.command("pull <name-or-jid>")
128
+ .description("Pull a group's messages in a time window into a local JSON file + print a timeline")
129
+ .option("--instance <name>", "Evolution instance name", "flowapt")
130
+ .option("--last <window>", "rolling window back from now, e.g. 48h, 7d, 2w, 90m", "48h")
131
+ .option("--since <date>", "window start (SAST), YYYY-MM-DD or \"YYYY-MM-DD HH:MM\" (overrides --last)")
132
+ .option("--until <date>", "window end (SAST), defaults to now")
133
+ .option("--limit <n>", "max messages to keep (most-recent in window)", String(1000))
134
+ .option("--json", "print the full JSON payload to stdout instead of the timeline")
135
+ .action((nameOrJid, opts) => groupsCmd.pull(nameOrJid, opts));
136
+
106
137
  program.parseAsync(argv);
107
138
  }