wispy-cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ /**
2
+ * core/user-model.mjs — User modeling for personalized responses (Hermes-inspired)
3
+ *
4
+ * Wispy builds a deepening understanding of who you are — behavioral patterns,
5
+ * preferences, and working style — beyond simple memory facts.
6
+ *
7
+ * Storage: ~/.wispy/user-model.json
8
+ */
9
+
10
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+
13
+ const MODEL_FILE = "user-model.json";
14
+ const UPDATE_INTERVAL = 10; // Update every N messages
15
+
16
+ const DEFAULT_MODEL = {
17
+ basics: {
18
+ name: null,
19
+ language: null,
20
+ role: null,
21
+ expertise: [],
22
+ os: null,
23
+ },
24
+ preferences: {
25
+ responseStyle: null,
26
+ codeStyle: null,
27
+ frameworkPreferences: [],
28
+ communicationLanguage: null,
29
+ },
30
+ patterns: {
31
+ peakHours: [],
32
+ avgSessionLength: 0,
33
+ commonTasks: [],
34
+ totalSessions: 0,
35
+ totalMessages: 0,
36
+ },
37
+ updatedAt: null,
38
+ };
39
+
40
+ export class UserModel {
41
+ constructor(wispyDir, engine = null) {
42
+ this.wispyDir = wispyDir;
43
+ this.modelPath = join(wispyDir, MODEL_FILE);
44
+ this.engine = engine;
45
+ this._cache = null;
46
+ this._messagesSinceUpdate = 0;
47
+ }
48
+
49
+ // ── Storage ──────────────────────────────────────────────────────────────────
50
+
51
+ async _load() {
52
+ if (this._cache) return this._cache;
53
+ try {
54
+ const raw = await readFile(this.modelPath, "utf8");
55
+ this._cache = { ...DEFAULT_MODEL, ...JSON.parse(raw) };
56
+ } catch {
57
+ this._cache = JSON.parse(JSON.stringify(DEFAULT_MODEL));
58
+ }
59
+ return this._cache;
60
+ }
61
+
62
+ async _save(model) {
63
+ await mkdir(this.wispyDir, { recursive: true });
64
+ model.updatedAt = new Date().toISOString();
65
+ this._cache = model;
66
+ await writeFile(this.modelPath, JSON.stringify(model, null, 2) + "\n", "utf8");
67
+ }
68
+
69
+ // ── Observation ──────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Observe a conversation and update the user model.
73
+ * Lightweight — only runs every UPDATE_INTERVAL messages.
74
+ * Non-blocking (should be called with fire-and-forget).
75
+ *
76
+ * @param {Array} messages Conversation messages
77
+ */
78
+ async observe(messages) {
79
+ try {
80
+ if (!this.engine) return;
81
+
82
+ // Track total messages
83
+ const model = await this._load();
84
+ const userMessages = messages.filter(m => m.role === "user");
85
+ model.patterns.totalMessages += userMessages.length;
86
+
87
+ this._messagesSinceUpdate += userMessages.length;
88
+
89
+ // Only do AI analysis every UPDATE_INTERVAL messages
90
+ if (this._messagesSinceUpdate < UPDATE_INTERVAL) {
91
+ await this._save(model);
92
+ return;
93
+ }
94
+
95
+ this._messagesSinceUpdate = 0;
96
+
97
+ // Record current hour as a peak hour signal
98
+ const currentHour = new Date().getHours();
99
+ if (!model.patterns.peakHours.includes(currentHour)) {
100
+ model.patterns.peakHours.push(currentHour);
101
+ if (model.patterns.peakHours.length > 12) {
102
+ // Keep only the most recent 12
103
+ model.patterns.peakHours = model.patterns.peakHours.slice(-12);
104
+ }
105
+ }
106
+
107
+ // AI-powered analysis (lightweight)
108
+ const recentUserContent = userMessages.slice(-10).map(m => m.content).join("\n---\n").slice(0, 2000);
109
+ if (!recentUserContent) return;
110
+
111
+ const analysisPrompt = `Analyze these user messages and extract insights about the user.
112
+ Be conservative — only assert things that are clearly evident.
113
+
114
+ Messages:
115
+ ${recentUserContent}
116
+
117
+ Current known profile:
118
+ ${JSON.stringify(model.basics, null, 2)}
119
+
120
+ Respond with JSON only (include only fields where you have HIGH confidence):
121
+ {
122
+ "basics": {
123
+ "name": null or "string if mentioned",
124
+ "language": null or "ko|en|ja|zh|...",
125
+ "role": null or "developer|student|designer|...",
126
+ "expertise": [] or ["tech1", "tech2"],
127
+ "os": null or "macOS|Windows|Linux"
128
+ },
129
+ "preferences": {
130
+ "responseStyle": null or "concise|detailed",
131
+ "codeStyle": null or "functional|oop|...",
132
+ "frameworkPreferences": [],
133
+ "communicationLanguage": null or "Korean|English|..."
134
+ },
135
+ "commonTasks": []
136
+ }`;
137
+
138
+ let analysisResult;
139
+ try {
140
+ const res = await this.engine.providers.chat(
141
+ [
142
+ { role: "system", content: "You are a user behavior analysis system. Respond with JSON only. Be conservative — only include fields with high confidence." },
143
+ { role: "user", content: analysisPrompt },
144
+ ],
145
+ [],
146
+ {}
147
+ );
148
+ analysisResult = res.type === "text" ? res.text : "";
149
+ } catch {
150
+ await this._save(model);
151
+ return;
152
+ }
153
+
154
+ // Parse and merge
155
+ const jsonMatch = analysisResult.match(/\{[\s\S]*\}/);
156
+ if (!jsonMatch) {
157
+ await this._save(model);
158
+ return;
159
+ }
160
+
161
+ let extracted;
162
+ try {
163
+ extracted = JSON.parse(jsonMatch[0]);
164
+ } catch {
165
+ await this._save(model);
166
+ return;
167
+ }
168
+
169
+ // Merge basics (non-destructive — only update null fields or extend arrays)
170
+ if (extracted.basics) {
171
+ for (const [key, val] of Object.entries(extracted.basics)) {
172
+ if (val === null || val === undefined) continue;
173
+ if (Array.isArray(val)) {
174
+ // Extend array with new unique values
175
+ const current = model.basics[key] ?? [];
176
+ model.basics[key] = [...new Set([...current, ...val])];
177
+ } else if (!model.basics[key]) {
178
+ // Only set if not already known
179
+ model.basics[key] = val;
180
+ }
181
+ }
182
+ }
183
+
184
+ // Merge preferences
185
+ if (extracted.preferences) {
186
+ for (const [key, val] of Object.entries(extracted.preferences)) {
187
+ if (val === null || val === undefined) continue;
188
+ if (Array.isArray(val)) {
189
+ const current = model.preferences[key] ?? [];
190
+ model.preferences[key] = [...new Set([...current, ...val])];
191
+ } else if (!model.preferences[key]) {
192
+ model.preferences[key] = val;
193
+ }
194
+ }
195
+ }
196
+
197
+ // Merge common tasks
198
+ if (extracted.commonTasks?.length > 0) {
199
+ const current = model.patterns.commonTasks ?? [];
200
+ model.patterns.commonTasks = [...new Set([...current, ...extracted.commonTasks])].slice(0, 20);
201
+ }
202
+
203
+ await this._save(model);
204
+ } catch {
205
+ // User model update should never throw
206
+ }
207
+ }
208
+
209
+ // ── Query methods ─────────────────────────────────────────────────────────────
210
+
211
+ async getProfile() {
212
+ const model = await this._load();
213
+ return model.basics;
214
+ }
215
+
216
+ async getPreferences() {
217
+ const model = await this._load();
218
+ return model.preferences;
219
+ }
220
+
221
+ async getExpertise() {
222
+ const model = await this._load();
223
+ return model.basics.expertise ?? [];
224
+ }
225
+
226
+ async getWorkingPattern() {
227
+ const model = await this._load();
228
+ return {
229
+ peakHours: model.patterns.peakHours,
230
+ avgSessionLength: model.patterns.avgSessionLength,
231
+ commonTasks: model.patterns.commonTasks,
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Generate a personalized addition to the system prompt.
237
+ * Only adds context if we have meaningful data.
238
+ */
239
+ async getSystemPromptAddition() {
240
+ try {
241
+ const model = await this._load();
242
+ const parts = [];
243
+
244
+ const { basics, preferences, patterns } = model;
245
+
246
+ if (basics.name) parts.push(`User's name: ${basics.name}`);
247
+ if (basics.role) parts.push(`Role: ${basics.role}`);
248
+ if (basics.os) parts.push(`OS: ${basics.os}`);
249
+ if (basics.expertise?.length > 0) parts.push(`Expertise: ${basics.expertise.join(", ")}`);
250
+
251
+ if (preferences.communicationLanguage) {
252
+ parts.push(`Preferred language: ${preferences.communicationLanguage}`);
253
+ }
254
+ if (preferences.responseStyle) {
255
+ parts.push(`Response style preference: ${preferences.responseStyle}`);
256
+ }
257
+ if (preferences.frameworkPreferences?.length > 0) {
258
+ parts.push(`Framework preferences: ${preferences.frameworkPreferences.join(", ")}`);
259
+ }
260
+
261
+ if (patterns.commonTasks?.length > 0) {
262
+ parts.push(`Common tasks: ${patterns.commonTasks.slice(0, 5).join(", ")}`);
263
+ }
264
+
265
+ if (patterns.peakHours?.length > 0) {
266
+ const currentHour = new Date().getHours();
267
+ const isActive = patterns.peakHours.includes(currentHour);
268
+ if (!isActive && patterns.peakHours.length > 3) {
269
+ parts.push(`Note: User is usually active around hours ${patterns.peakHours.slice(0, 5).join(", ")}`);
270
+ }
271
+ }
272
+
273
+ if (parts.length === 0) return null;
274
+
275
+ return `## User Profile\n${parts.map(p => `- ${p}`).join("\n")}`;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Update total sessions count.
283
+ */
284
+ async recordSession() {
285
+ try {
286
+ const model = await this._load();
287
+ model.patterns.totalSessions = (model.patterns.totalSessions ?? 0) + 1;
288
+ await this._save(model);
289
+ } catch { /* ignore */ }
290
+ }
291
+
292
+ /**
293
+ * Manually update profile fields.
294
+ */
295
+ async updateProfile(updates) {
296
+ const model = await this._load();
297
+ if (updates.basics) Object.assign(model.basics, updates.basics);
298
+ if (updates.preferences) Object.assign(model.preferences, updates.preferences);
299
+ await this._save(model);
300
+ return model;
301
+ }
302
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * lib/channels/email.mjs — Email channel adapter
3
+ *
4
+ * Uses nodemailer (send) + imapflow (receive) — optional peer dependencies.
5
+ * Install: npm install nodemailer imapflow
6
+ *
7
+ * Config (channels.json):
8
+ * {
9
+ * "email": {
10
+ * "imap": { "host": "imap.gmail.com", "port": 993, "user": "...", "password": "..." },
11
+ * "smtp": { "host": "smtp.gmail.com", "port": 587, "user": "...", "password": "..." },
12
+ * "allowedSenders": ["trusted@example.com"],
13
+ * "pollIntervalMs": 30000,
14
+ * "subjectPrefix": "[wispy]"
15
+ * }
16
+ * }
17
+ *
18
+ * For Gmail: use App Passwords (2FA required), not your main password.
19
+ */
20
+
21
+ import { ChannelAdapter } from "./base.mjs";
22
+
23
+ let nodemailer, ImapFlow;
24
+
25
+ async function loadDeps() {
26
+ const missing = [];
27
+ try {
28
+ nodemailer = (await import("nodemailer")).default;
29
+ } catch {
30
+ missing.push("nodemailer");
31
+ }
32
+ try {
33
+ ImapFlow = (await import("imapflow")).ImapFlow;
34
+ } catch {
35
+ missing.push("imapflow");
36
+ }
37
+ if (missing.length > 0) {
38
+ throw new Error(
39
+ `Email adapter requires: npm install ${missing.join(" ")}`
40
+ );
41
+ }
42
+ }
43
+
44
+ export class EmailAdapter extends ChannelAdapter {
45
+ constructor(config = {}) {
46
+ super(config);
47
+ this.imapConfig = config.imap ?? {};
48
+ this.smtpConfig = config.smtp ?? {};
49
+ this.allowedSenders = (config.allowedSenders ?? []).map(s => s.toLowerCase());
50
+ this.pollIntervalMs = config.pollIntervalMs ?? 30_000;
51
+ this.subjectPrefix = config.subjectPrefix ?? "[wispy]";
52
+ this._transporter = null;
53
+ this._pollTimer = null;
54
+ this._seen = new Set(); // Track processed message UIDs
55
+ }
56
+
57
+ async start() {
58
+ await loadDeps();
59
+
60
+ // Verify SMTP
61
+ this._transporter = nodemailer.createTransport({
62
+ host: this.smtpConfig.host,
63
+ port: this.smtpConfig.port ?? 587,
64
+ secure: (this.smtpConfig.port ?? 587) === 465,
65
+ auth: {
66
+ user: this.smtpConfig.user,
67
+ pass: this.smtpConfig.password,
68
+ },
69
+ });
70
+
71
+ try {
72
+ await this._transporter.verify();
73
+ console.log(`✅ Email SMTP connected (${this.smtpConfig.user})`);
74
+ } catch (err) {
75
+ throw new Error(`Email SMTP verify failed: ${err.message}`);
76
+ }
77
+
78
+ // Start IMAP polling
79
+ await this._poll(); // Initial poll
80
+ this._pollTimer = setInterval(() => this._poll().catch(() => {}), this.pollIntervalMs);
81
+ console.log(`✅ Email IMAP polling every ${this.pollIntervalMs / 1000}s`);
82
+ }
83
+
84
+ async stop() {
85
+ if (this._pollTimer) {
86
+ clearInterval(this._pollTimer);
87
+ this._pollTimer = null;
88
+ }
89
+ if (this._transporter) {
90
+ this._transporter.close?.();
91
+ this._transporter = null;
92
+ }
93
+ }
94
+
95
+ async sendMessage(chatId, text) {
96
+ // chatId is the reply-to email address
97
+ if (!this._transporter) throw new Error("Email adapter not started");
98
+
99
+ const subject = `${this.subjectPrefix} Response`;
100
+ const MAX_LEN = 50000;
101
+
102
+ await this._transporter.sendMail({
103
+ from: this.smtpConfig.user,
104
+ to: chatId,
105
+ subject,
106
+ text: text.slice(0, MAX_LEN),
107
+ });
108
+ }
109
+
110
+ async _poll() {
111
+ const client = new ImapFlow({
112
+ host: this.imapConfig.host,
113
+ port: this.imapConfig.port ?? 993,
114
+ secure: true,
115
+ auth: {
116
+ user: this.imapConfig.user,
117
+ pass: this.imapConfig.password,
118
+ },
119
+ logger: false,
120
+ });
121
+
122
+ try {
123
+ await client.connect();
124
+
125
+ const lock = await client.getMailboxLock("INBOX");
126
+ try {
127
+ // Search for unread messages with our subject prefix (or all unread)
128
+ const criteria = ["UNSEEN"];
129
+
130
+ for await (const msg of client.fetch(criteria, { envelope: true, bodyStructure: true })) {
131
+ const uid = String(msg.uid);
132
+ if (this._seen.has(uid)) continue;
133
+ this._seen.add(uid);
134
+
135
+ const from = msg.envelope.from?.[0];
136
+ if (!from) continue;
137
+
138
+ const senderEmail = from.address?.toLowerCase() ?? "";
139
+ const senderName = from.name ?? senderEmail;
140
+
141
+ // Filter allowed senders
142
+ if (
143
+ this.allowedSenders.length > 0 &&
144
+ !this.allowedSenders.includes(senderEmail)
145
+ ) continue;
146
+
147
+ // Get message body
148
+ let bodyText = "";
149
+ try {
150
+ for await (const part of client.download(uid, { uid: true })) {
151
+ for await (const chunk of part.content) {
152
+ bodyText += chunk.toString("utf8");
153
+ }
154
+ }
155
+ } catch {
156
+ bodyText = `[Subject: ${msg.envelope.subject ?? "(no subject)"}]`;
157
+ }
158
+
159
+ // Strip HTML if needed (simple approach)
160
+ bodyText = bodyText
161
+ .replace(/<[^>]+>/g, " ")
162
+ .replace(/\s+/g, " ")
163
+ .trim()
164
+ .slice(0, 4000);
165
+
166
+ if (!bodyText) continue;
167
+
168
+ // Emit (chatId = reply address)
169
+ this._emit(senderEmail, senderEmail, senderName, bodyText, msg);
170
+
171
+ // Mark as read
172
+ try {
173
+ await client.messageFlagsAdd({ uid: [msg.uid] }, ["\\Seen"]);
174
+ } catch { /* non-fatal */ }
175
+ }
176
+ } finally {
177
+ lock.release();
178
+ }
179
+ } catch (err) {
180
+ if (process.env.WISPY_DEBUG) {
181
+ console.error("[email] IMAP poll error:", err.message);
182
+ }
183
+ } finally {
184
+ try { await client.logout(); } catch { /* ignore */ }
185
+ }
186
+ }
187
+ }
@@ -333,9 +333,12 @@ export class ChannelManager {
333
333
  const cfg = await loadChannelsConfig();
334
334
 
335
335
  const entries = [
336
- { name: "telegram", AdapterClass: () => import("./telegram.mjs").then(m => m.TelegramAdapter) },
337
- { name: "discord", AdapterClass: () => import("./discord.mjs").then(m => m.DiscordAdapter) },
338
- { name: "slack", AdapterClass: () => import("./slack.mjs").then(m => m.SlackAdapter) },
336
+ { name: "telegram", AdapterClass: () => import("./telegram.mjs").then(m => m.TelegramAdapter) },
337
+ { name: "discord", AdapterClass: () => import("./discord.mjs").then(m => m.DiscordAdapter) },
338
+ { name: "slack", AdapterClass: () => import("./slack.mjs").then(m => m.SlackAdapter) },
339
+ { name: "whatsapp", AdapterClass: () => import("./whatsapp.mjs").then(m => m.WhatsAppAdapter) },
340
+ { name: "signal", AdapterClass: () => import("./signal.mjs").then(m => m.SignalAdapter) },
341
+ { name: "email", AdapterClass: () => import("./email.mjs").then(m => m.EmailAdapter) },
339
342
  ];
340
343
 
341
344
  for (const { name, AdapterClass } of entries) {
@@ -413,6 +416,9 @@ export class ChannelManager {
413
416
  (process.env.WISPY_SLACK_BOT_TOKEN ?? cfg.botToken) &&
414
417
  (process.env.WISPY_SLACK_APP_TOKEN ?? cfg.appToken)
415
418
  );
419
+ if (name === "whatsapp") return true; // No token needed, uses QR
420
+ if (name === "signal") return !!(cfg.number);
421
+ if (name === "email") return !!(cfg.imap?.user && cfg.smtp?.user);
416
422
  return false;
417
423
  }
418
424
  }
@@ -460,8 +466,54 @@ export async function channelSetup(channelName) {
460
466
  console.log("\n✅ Slack tokens saved! Run: wispy --slack");
461
467
  }
462
468
 
469
+ else if (channelName === "whatsapp") {
470
+ console.log("WhatsApp requires: npm install whatsapp-web.js qrcode-terminal");
471
+ console.log("(Check peer dependency instructions)\n");
472
+ const raw = (await ask(" Allowed phone numbers (comma-separated, e.g. +821012345678): ")).trim();
473
+ const allowedNumbers = raw ? raw.split(",").map(n => n.trim()).filter(Boolean) : [];
474
+ cfg.whatsapp = { ...(cfg.whatsapp ?? {}), enabled: true, allowedNumbers };
475
+ await saveChannelsConfig(cfg);
476
+ console.log("\n✅ WhatsApp config saved! Run: wispy --serve (QR code will appear)");
477
+ console.log(" Make sure whatsapp-web.js is installed first.");
478
+ }
479
+
480
+ else if (channelName === "signal") {
481
+ console.log("Signal requires signal-cli installed: https://github.com/AsamK/signal-cli");
482
+ console.log("Register first: signal-cli -a +NUMBER register && signal-cli -a +NUMBER verify CODE\n");
483
+ const number = (await ask(" Your Signal number (e.g. +821012345678): ")).trim();
484
+ if (!number) { rl.close(); return; }
485
+ const raw = (await ask(" Allowed sender numbers (comma-separated, empty = all): ")).trim();
486
+ const allowedNumbers = raw ? raw.split(",").map(n => n.trim()).filter(Boolean) : [];
487
+ const signalCliPath = (await ask(" signal-cli path [signal-cli]: ")).trim() || "signal-cli";
488
+ cfg.signal = { ...(cfg.signal ?? {}), enabled: true, number, allowedNumbers, signalCliPath };
489
+ await saveChannelsConfig(cfg);
490
+ console.log("\n✅ Signal config saved! Run: wispy --serve");
491
+ }
492
+
493
+ else if (channelName === "email") {
494
+ console.log("Email requires: npm install nodemailer imapflow\n");
495
+ const imapHost = (await ask(" IMAP host (e.g. imap.gmail.com): ")).trim();
496
+ const imapPort = parseInt((await ask(" IMAP port [993]: ")).trim() || "993", 10);
497
+ const smtpHost = (await ask(" SMTP host (e.g. smtp.gmail.com): ")).trim();
498
+ const smtpPort = parseInt((await ask(" SMTP port [587]: ")).trim() || "587", 10);
499
+ const user = (await ask(" Email address: ")).trim();
500
+ const password = (await ask(" Email password (use App Password for Gmail): ")).trim();
501
+ const raw = (await ask(" Allowed senders (comma-separated, empty = all): ")).trim();
502
+ const allowedSenders = raw ? raw.split(",").map(s => s.trim()).filter(Boolean) : [];
503
+ if (!imapHost || !smtpHost || !user || !password) { console.log("❌ Required fields missing."); rl.close(); return; }
504
+ cfg.email = {
505
+ ...(cfg.email ?? {}), enabled: true,
506
+ imap: { host: imapHost, port: imapPort, user, password },
507
+ smtp: { host: smtpHost, port: smtpPort, user, password },
508
+ allowedSenders,
509
+ };
510
+ await saveChannelsConfig(cfg);
511
+ console.log("\n✅ Email config saved! Run: wispy --serve");
512
+ console.log(" Make sure nodemailer and imapflow are installed first.");
513
+ }
514
+
463
515
  else {
464
- console.log(`Unknown channel: ${channelName}. Use: telegram, discord, slack`);
516
+ console.log(`Unknown channel: ${channelName}. Use: telegram, discord, slack, whatsapp, signal, email`);
465
517
  }
466
518
 
467
519
  rl.close();
@@ -469,16 +521,21 @@ export async function channelSetup(channelName) {
469
521
 
470
522
  export async function channelList() {
471
523
  const cfg = await loadChannelsConfig();
472
- const channels = ["telegram", "discord", "slack"];
524
+ const channels = ["telegram", "discord", "slack", "whatsapp", "signal", "email"];
473
525
 
474
526
  console.log("\n🌿 Configured channels:\n");
475
527
  for (const name of channels) {
476
528
  const c = cfg[name];
477
- if (!c) { console.log(` ○ ${name.padEnd(10)} not configured`); continue; }
529
+ if (!c) { console.log(` ○ ${name.padEnd(12)} not configured`); continue; }
478
530
  const enabled = c.enabled !== false;
479
- const hasToken = name === "slack" ? !!(c.botToken && c.appToken) : !!c.token;
480
- const status = !hasToken ? "no token" : enabled ? "✅ enabled" : "disabled";
481
- console.log(` ${enabled && hasToken ? "" : "○"} ${name.padEnd(10)} ${status}`);
531
+ let hasToken = false;
532
+ if (name === "slack") hasToken = !!(c.botToken && c.appToken);
533
+ else if (name === "whatsapp") hasToken = true;
534
+ else if (name === "signal") hasToken = !!c.number;
535
+ else if (name === "email") hasToken = !!(c.imap?.user && c.smtp?.user);
536
+ else hasToken = !!c.token;
537
+ const status = !hasToken ? "no config" : enabled ? "✅ enabled" : "disabled";
538
+ console.log(` ${enabled && hasToken ? "●" : "○"} ${name.padEnd(12)} ${status}`);
482
539
  }
483
540
  console.log(`\nConfig file: ${CHANNELS_CONFIG}\n`);
484
541
  }