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.
- package/bin/wispy.mjs +89 -0
- package/core/deploy.mjs +51 -0
- package/core/engine.mjs +121 -0
- package/core/index.mjs +3 -0
- package/core/migrate.mjs +357 -0
- package/core/skills.mjs +339 -0
- package/core/user-model.mjs +302 -0
- package/lib/channels/email.mjs +187 -0
- package/lib/channels/index.mjs +66 -9
- package/lib/channels/signal.mjs +151 -0
- package/lib/channels/whatsapp.mjs +141 -0
- package/lib/wispy-repl.mjs +102 -24
- package/lib/wispy-tui.mjs +964 -380
- package/package.json +18 -2
|
@@ -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
|
+
}
|
package/lib/channels/index.mjs
CHANGED
|
@@ -333,9 +333,12 @@ export class ChannelManager {
|
|
|
333
333
|
const cfg = await loadChannelsConfig();
|
|
334
334
|
|
|
335
335
|
const entries = [
|
|
336
|
-
{ name: "telegram",
|
|
337
|
-
{ name: "discord",
|
|
338
|
-
{ name: "slack",
|
|
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(
|
|
529
|
+
if (!c) { console.log(` ○ ${name.padEnd(12)} not configured`); continue; }
|
|
478
530
|
const enabled = c.enabled !== false;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
}
|