@imenam/mcp-messenger 1.0.1
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/.env.example +59 -0
- package/LICENSE +15 -0
- package/README.md +317 -0
- package/dist/src/cli/setup-mcp.d.ts +6 -0
- package/dist/src/cli/setup-mcp.js +105 -0
- package/dist/src/client/messenger-client.d.ts +78 -0
- package/dist/src/client/messenger-client.js +183 -0
- package/dist/src/config.d.ts +36 -0
- package/dist/src/config.js +110 -0
- package/dist/src/core/ids.d.ts +2 -0
- package/dist/src/core/ids.js +5 -0
- package/dist/src/core/store.d.ts +93 -0
- package/dist/src/core/store.js +527 -0
- package/dist/src/core/types.d.ts +74 -0
- package/dist/src/core/types.js +5 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +53 -0
- package/dist/src/logger.d.ts +6 -0
- package/dist/src/logger.js +22 -0
- package/dist/src/mcp/start.d.ts +16 -0
- package/dist/src/mcp/start.js +76 -0
- package/dist/src/mcp/tools.d.ts +14 -0
- package/dist/src/mcp/tools.js +403 -0
- package/dist/src/server/app.d.ts +15 -0
- package/dist/src/server/app.js +375 -0
- package/dist/src/server/start.d.ts +14 -0
- package/dist/src/server/start.js +64 -0
- package/dist/src/server/ui/app.js +275 -0
- package/dist/src/server/ui/index.html +53 -0
- package/dist/src/server/ui/styles.css +303 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +2 -0
- package/package.json +59 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { newId } from "./ids.js";
|
|
6
|
+
import { DELIVERY_ROLES, SYSTEM_FOLDERS, } from "./types.js";
|
|
7
|
+
/** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
|
|
8
|
+
export class MessengerError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status = 400) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.name = "MessengerError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const PREVIEW_LENGTH = 160;
|
|
17
|
+
const MAX_LIMIT = 100;
|
|
18
|
+
const FOLDER_NAME_RE = /^[a-z0-9][a-z0-9 _-]{0,39}$/;
|
|
19
|
+
/**
|
|
20
|
+
* Boîte aux lettres persistée en SQLite.
|
|
21
|
+
*
|
|
22
|
+
* Un message est stocké une seule fois (table `messages`) puis « livré » via une ligne
|
|
23
|
+
* par couple (agent, rôle) dans `deliveries`. C'est cette ligne qui porte l'état propre
|
|
24
|
+
* à chaque agent : dossier, lu/non lu, suivi. Un agent ne voit que ses propres livraisons.
|
|
25
|
+
*/
|
|
26
|
+
export class MessageStore extends EventEmitter {
|
|
27
|
+
db;
|
|
28
|
+
constructor(dbPath) {
|
|
29
|
+
super();
|
|
30
|
+
if (dbPath !== ":memory:") {
|
|
31
|
+
fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
this.db = new Database(dbPath);
|
|
34
|
+
this.db.pragma("foreign_keys = ON");
|
|
35
|
+
if (dbPath !== ":memory:")
|
|
36
|
+
this.db.pragma("journal_mode = WAL");
|
|
37
|
+
this.migrate();
|
|
38
|
+
}
|
|
39
|
+
migrate() {
|
|
40
|
+
this.db.exec(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
display_name TEXT,
|
|
44
|
+
description TEXT,
|
|
45
|
+
created_at TEXT NOT NULL,
|
|
46
|
+
last_seen_at TEXT,
|
|
47
|
+
registered INTEGER NOT NULL DEFAULT 0
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
thread_id TEXT NOT NULL,
|
|
53
|
+
sender_id TEXT NOT NULL,
|
|
54
|
+
subject TEXT NOT NULL,
|
|
55
|
+
body TEXT NOT NULL,
|
|
56
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
57
|
+
in_reply_to TEXT,
|
|
58
|
+
created_at TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
64
|
+
agent_id TEXT NOT NULL,
|
|
65
|
+
role TEXT NOT NULL,
|
|
66
|
+
folder TEXT NOT NULL,
|
|
67
|
+
read_at TEXT,
|
|
68
|
+
starred INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
created_at TEXT NOT NULL,
|
|
70
|
+
UNIQUE (message_id, agent_id, role)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
74
|
+
agent_id TEXT NOT NULL,
|
|
75
|
+
name TEXT NOT NULL,
|
|
76
|
+
created_at TEXT NOT NULL,
|
|
77
|
+
PRIMARY KEY (agent_id, name)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
close() {
|
|
86
|
+
this.db.close();
|
|
87
|
+
}
|
|
88
|
+
// --- Agents -------------------------------------------------------------
|
|
89
|
+
/** Crée la fiche au besoin. `registered: false` = agent connu seulement comme destinataire. */
|
|
90
|
+
ensureAgent(id, opts = {}) {
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
const existing = this.db.prepare("SELECT id FROM agents WHERE id = ?").get(id);
|
|
93
|
+
if (!existing) {
|
|
94
|
+
this.db
|
|
95
|
+
.prepare(`INSERT INTO agents (id, display_name, description, created_at, last_seen_at, registered)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
97
|
+
.run(id, opts.display_name ?? null, opts.description ?? null, now, opts.registered ? now : null, opts.registered ? 1 : 0);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.db
|
|
101
|
+
.prepare(`UPDATE agents
|
|
102
|
+
SET display_name = COALESCE(?, display_name),
|
|
103
|
+
description = COALESCE(?, description),
|
|
104
|
+
registered = MAX(registered, ?),
|
|
105
|
+
last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
|
|
106
|
+
WHERE id = ?`)
|
|
107
|
+
.run(opts.display_name ?? null, opts.description ?? null, opts.registered ? 1 : 0, opts.registered ? 1 : 0, now, id);
|
|
108
|
+
}
|
|
109
|
+
return this.getAgent(id);
|
|
110
|
+
}
|
|
111
|
+
touchAgent(id) {
|
|
112
|
+
this.db.prepare("UPDATE agents SET last_seen_at = ? WHERE id = ?").run(new Date().toISOString(), id);
|
|
113
|
+
}
|
|
114
|
+
getAgent(id) {
|
|
115
|
+
const row = this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
|
|
116
|
+
return row ? { ...row, registered: Boolean(row.registered) } : null;
|
|
117
|
+
}
|
|
118
|
+
listAgents() {
|
|
119
|
+
const rows = this.db.prepare("SELECT * FROM agents ORDER BY id").all();
|
|
120
|
+
return rows.map((r) => ({ ...r, registered: Boolean(r.registered) }));
|
|
121
|
+
}
|
|
122
|
+
// --- Envoi --------------------------------------------------------------
|
|
123
|
+
sendMessage(input) {
|
|
124
|
+
const subject = input.subject.trim();
|
|
125
|
+
if (!subject)
|
|
126
|
+
throw new MessengerError("subject cannot be empty");
|
|
127
|
+
if (!input.body.trim())
|
|
128
|
+
throw new MessengerError("body cannot be empty");
|
|
129
|
+
const from = normalizeAgentId(input.from);
|
|
130
|
+
if (!from)
|
|
131
|
+
throw new MessengerError("sender agent id is required");
|
|
132
|
+
const to = dedupe(input.to.map(normalizeAgentId).filter(Boolean));
|
|
133
|
+
// Un même agent ne peut pas être à la fois destinataire principal et en copie.
|
|
134
|
+
const cc = dedupe((input.cc ?? []).map(normalizeAgentId).filter(Boolean)).filter((id) => !to.includes(id));
|
|
135
|
+
if (to.length === 0)
|
|
136
|
+
throw new MessengerError("at least one recipient is required in `to`");
|
|
137
|
+
let threadId;
|
|
138
|
+
let inReplyTo = null;
|
|
139
|
+
if (input.reply_to) {
|
|
140
|
+
const parent = this.db.prepare("SELECT id, thread_id FROM messages WHERE id = ?").get(input.reply_to);
|
|
141
|
+
if (!parent)
|
|
142
|
+
throw new MessengerError(`message not found: ${input.reply_to}`, 404);
|
|
143
|
+
threadId = parent.thread_id;
|
|
144
|
+
inReplyTo = parent.id;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
threadId = newId("thr");
|
|
148
|
+
}
|
|
149
|
+
const messageId = newId("msg");
|
|
150
|
+
const now = new Date().toISOString();
|
|
151
|
+
const priority = input.priority ?? "normal";
|
|
152
|
+
const unknownRecipients = [...to, ...cc].filter((id) => !this.getAgent(id)?.registered);
|
|
153
|
+
const deliveries = [
|
|
154
|
+
...to.map((agent_id) => ({ agent_id, role: "to", folder: "inbox", read_at: null })),
|
|
155
|
+
...cc.map((agent_id) => ({ agent_id, role: "cc", folder: "inbox", read_at: null })),
|
|
156
|
+
{ agent_id: from, role: "sender", folder: "sent", read_at: now },
|
|
157
|
+
];
|
|
158
|
+
this.db.transaction(() => {
|
|
159
|
+
this.ensureAgent(from);
|
|
160
|
+
// Écrire est le signe d'activité le plus fiable : l'annuaire s'appuie dessus.
|
|
161
|
+
this.touchAgent(from);
|
|
162
|
+
for (const id of [...to, ...cc])
|
|
163
|
+
this.ensureAgent(id);
|
|
164
|
+
this.db
|
|
165
|
+
.prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
|
|
166
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
167
|
+
.run(messageId, threadId, from, subject, input.body, priority, inReplyTo, now);
|
|
168
|
+
const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
|
|
169
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`);
|
|
170
|
+
for (const d of deliveries) {
|
|
171
|
+
insert.run(newId("dlv"), messageId, d.agent_id, d.role, d.folder, d.read_at, now);
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
for (const d of deliveries) {
|
|
175
|
+
this.emit("message", { agent_id: d.agent_id, message_id: messageId, folder: d.folder, role: d.role });
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
message_id: messageId,
|
|
179
|
+
thread_id: threadId,
|
|
180
|
+
created_at: now,
|
|
181
|
+
delivered_to: [...to, ...cc],
|
|
182
|
+
unknown_recipients: unknownRecipients,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
// --- Lecture ------------------------------------------------------------
|
|
186
|
+
listMessages(agentId, opts = {}) {
|
|
187
|
+
const limit = clamp(opts.limit ?? 25, 1, MAX_LIMIT);
|
|
188
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
189
|
+
const where = ["d.agent_id = ?"];
|
|
190
|
+
const params = [agentId];
|
|
191
|
+
if (opts.folder) {
|
|
192
|
+
this.assertFolderExists(agentId, opts.folder);
|
|
193
|
+
where.push("d.folder = ?");
|
|
194
|
+
params.push(opts.folder);
|
|
195
|
+
}
|
|
196
|
+
if (opts.unread_only)
|
|
197
|
+
where.push("d.read_at IS NULL");
|
|
198
|
+
if (opts.thread_id) {
|
|
199
|
+
where.push("m.thread_id = ?");
|
|
200
|
+
params.push(opts.thread_id);
|
|
201
|
+
}
|
|
202
|
+
if (opts.from) {
|
|
203
|
+
where.push("m.sender_id = ?");
|
|
204
|
+
params.push(normalizeAgentId(opts.from));
|
|
205
|
+
}
|
|
206
|
+
if (opts.search) {
|
|
207
|
+
where.push("(m.subject LIKE ? ESCAPE '\\' OR m.body LIKE ? ESCAPE '\\')");
|
|
208
|
+
const like = `%${escapeLike(opts.search)}%`;
|
|
209
|
+
params.push(like, like);
|
|
210
|
+
}
|
|
211
|
+
const rows = this.db
|
|
212
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
|
|
213
|
+
m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
214
|
+
FROM deliveries d
|
|
215
|
+
JOIN messages m ON m.id = d.message_id
|
|
216
|
+
WHERE ${where.join(" AND ")}
|
|
217
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
218
|
+
LIMIT ? OFFSET ?`)
|
|
219
|
+
.all(...params, limit, offset);
|
|
220
|
+
const recipients = this.recipientsFor(rows.map((r) => r.message_id));
|
|
221
|
+
return rows.map((row) => this.toSummary(row, recipients));
|
|
222
|
+
}
|
|
223
|
+
getMessage(agentId, messageId, markRead = false) {
|
|
224
|
+
const row = this.db
|
|
225
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
|
|
226
|
+
m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
227
|
+
FROM deliveries d
|
|
228
|
+
JOIN messages m ON m.id = d.message_id
|
|
229
|
+
WHERE d.agent_id = ? AND d.message_id = ?
|
|
230
|
+
ORDER BY CASE d.role WHEN 'to' THEN 0 WHEN 'cc' THEN 1 ELSE 2 END
|
|
231
|
+
LIMIT 1`)
|
|
232
|
+
.get(agentId, messageId);
|
|
233
|
+
if (!row)
|
|
234
|
+
throw new MessengerError(`message not found in this mailbox: ${messageId}`, 404);
|
|
235
|
+
if (markRead && row.read_at === null) {
|
|
236
|
+
const now = new Date().toISOString();
|
|
237
|
+
this.db
|
|
238
|
+
.prepare("UPDATE deliveries SET read_at = ? WHERE agent_id = ? AND message_id = ? AND read_at IS NULL")
|
|
239
|
+
.run(now, agentId, messageId);
|
|
240
|
+
row.read_at = now;
|
|
241
|
+
}
|
|
242
|
+
const recipients = this.recipientsFor([messageId]);
|
|
243
|
+
return this.toMessage(row, recipients);
|
|
244
|
+
}
|
|
245
|
+
/** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
|
|
246
|
+
getThread(agentId, threadId) {
|
|
247
|
+
const rows = this.db
|
|
248
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
|
|
249
|
+
m.in_reply_to, m.created_at,
|
|
250
|
+
MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
|
|
251
|
+
FROM deliveries d
|
|
252
|
+
JOIN messages m ON m.id = d.message_id
|
|
253
|
+
WHERE d.agent_id = ? AND m.thread_id = ?
|
|
254
|
+
GROUP BY m.id
|
|
255
|
+
ORDER BY m.created_at ASC`)
|
|
256
|
+
.all(agentId, threadId);
|
|
257
|
+
if (rows.length === 0)
|
|
258
|
+
throw new MessengerError(`thread not found in this mailbox: ${threadId}`, 404);
|
|
259
|
+
const recipients = this.recipientsFor(rows.map((r) => r.message_id));
|
|
260
|
+
return rows.map((row) => this.toMessage(row, recipients));
|
|
261
|
+
}
|
|
262
|
+
// --- Organisation -------------------------------------------------------
|
|
263
|
+
markMessages(agentId, messageIds, state) {
|
|
264
|
+
if (state.read === undefined && state.starred === undefined) {
|
|
265
|
+
throw new MessengerError("nothing to update: provide `read` and/or `starred`");
|
|
266
|
+
}
|
|
267
|
+
const sets = [];
|
|
268
|
+
const params = [];
|
|
269
|
+
if (state.read !== undefined) {
|
|
270
|
+
sets.push("read_at = ?");
|
|
271
|
+
params.push(state.read ? new Date().toISOString() : null);
|
|
272
|
+
}
|
|
273
|
+
if (state.starred !== undefined) {
|
|
274
|
+
sets.push("starred = ?");
|
|
275
|
+
params.push(state.starred ? 1 : 0);
|
|
276
|
+
}
|
|
277
|
+
return this.updateDeliveries(agentId, messageIds, sets, params);
|
|
278
|
+
}
|
|
279
|
+
moveMessages(agentId, messageIds, folder) {
|
|
280
|
+
this.assertFolderExists(agentId, folder);
|
|
281
|
+
return this.updateDeliveries(agentId, messageIds, ["folder = ?"], [folder]);
|
|
282
|
+
}
|
|
283
|
+
/** Sans `permanent`, le message part à la corbeille et reste récupérable. */
|
|
284
|
+
deleteMessages(agentId, messageIds, permanent = false) {
|
|
285
|
+
if (!permanent)
|
|
286
|
+
return this.moveMessages(agentId, messageIds, "trash");
|
|
287
|
+
const ids = this.ownedMessageIds(agentId, messageIds);
|
|
288
|
+
if (ids.length === 0)
|
|
289
|
+
return 0;
|
|
290
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
291
|
+
const changes = this.db
|
|
292
|
+
.prepare(`DELETE FROM deliveries WHERE agent_id = ? AND message_id IN (${placeholders})`)
|
|
293
|
+
.run(agentId, ...ids).changes;
|
|
294
|
+
this.dropOrphanMessages();
|
|
295
|
+
return changes;
|
|
296
|
+
}
|
|
297
|
+
listFolders(agentId) {
|
|
298
|
+
const custom = this.db.prepare("SELECT name FROM folders WHERE agent_id = ? ORDER BY name").all(agentId);
|
|
299
|
+
const counts = this.db
|
|
300
|
+
.prepare(`SELECT folder, COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread
|
|
301
|
+
FROM deliveries WHERE agent_id = ? GROUP BY folder`)
|
|
302
|
+
.all(agentId);
|
|
303
|
+
const byName = new Map(counts.map((c) => [c.folder, c]));
|
|
304
|
+
const names = [...SYSTEM_FOLDERS, ...custom.map((c) => c.name)];
|
|
305
|
+
return names.map((name) => ({
|
|
306
|
+
name,
|
|
307
|
+
system: SYSTEM_FOLDERS.includes(name),
|
|
308
|
+
total: byName.get(name)?.total ?? 0,
|
|
309
|
+
unread: byName.get(name)?.unread ?? 0,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
createFolder(agentId, name) {
|
|
313
|
+
const folder = name.trim().toLowerCase();
|
|
314
|
+
if (!FOLDER_NAME_RE.test(folder)) {
|
|
315
|
+
throw new MessengerError("folder name must be 1-40 chars: lowercase letters, digits, space, `-` or `_`");
|
|
316
|
+
}
|
|
317
|
+
if (SYSTEM_FOLDERS.includes(folder)) {
|
|
318
|
+
throw new MessengerError(`${folder} is a system folder and always exists`, 409);
|
|
319
|
+
}
|
|
320
|
+
const exists = this.db.prepare("SELECT 1 FROM folders WHERE agent_id = ? AND name = ?").get(agentId, folder);
|
|
321
|
+
if (exists)
|
|
322
|
+
throw new MessengerError(`folder already exists: ${folder}`, 409);
|
|
323
|
+
this.ensureAgent(agentId);
|
|
324
|
+
this.db
|
|
325
|
+
.prepare("INSERT INTO folders (agent_id, name, created_at) VALUES (?, ?, ?)")
|
|
326
|
+
.run(agentId, folder, new Date().toISOString());
|
|
327
|
+
return { name: folder, system: false, total: 0, unread: 0 };
|
|
328
|
+
}
|
|
329
|
+
/** Les messages du dossier retournent dans `inbox` (ou `sent` pour les copies d'envoi). */
|
|
330
|
+
deleteFolder(agentId, name) {
|
|
331
|
+
const folder = name.trim().toLowerCase();
|
|
332
|
+
if (SYSTEM_FOLDERS.includes(folder)) {
|
|
333
|
+
throw new MessengerError(`system folder cannot be deleted: ${folder}`);
|
|
334
|
+
}
|
|
335
|
+
const exists = this.db.prepare("SELECT 1 FROM folders WHERE agent_id = ? AND name = ?").get(agentId, folder);
|
|
336
|
+
if (!exists)
|
|
337
|
+
throw new MessengerError(`folder not found: ${folder}`, 404);
|
|
338
|
+
const moved = this.db
|
|
339
|
+
.prepare(`UPDATE deliveries SET folder = CASE role WHEN 'sender' THEN 'sent' ELSE 'inbox' END
|
|
340
|
+
WHERE agent_id = ? AND folder = ?`)
|
|
341
|
+
.run(agentId, folder).changes;
|
|
342
|
+
this.db.prepare("DELETE FROM folders WHERE agent_id = ? AND name = ?").run(agentId, folder);
|
|
343
|
+
return moved;
|
|
344
|
+
}
|
|
345
|
+
stats(agentId) {
|
|
346
|
+
const folders = this.listFolders(agentId);
|
|
347
|
+
const unread = this.db
|
|
348
|
+
.prepare("SELECT COUNT(*) AS n FROM deliveries WHERE agent_id = ? AND read_at IS NULL AND folder != 'trash'")
|
|
349
|
+
.get(agentId);
|
|
350
|
+
return { agent_id: agentId, unread_total: unread.n, folders };
|
|
351
|
+
}
|
|
352
|
+
/** Purge les livraisons restées en corbeille au-delà de `days`. Renvoie le nombre supprimé. */
|
|
353
|
+
purgeTrash(days) {
|
|
354
|
+
if (days <= 0)
|
|
355
|
+
return 0;
|
|
356
|
+
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
357
|
+
const removed = this.db.prepare("DELETE FROM deliveries WHERE folder = 'trash' AND created_at < ?").run(cutoff)
|
|
358
|
+
.changes;
|
|
359
|
+
this.dropOrphanMessages();
|
|
360
|
+
return removed;
|
|
361
|
+
}
|
|
362
|
+
// --- Supervision (interface web, lecture seule) --------------------------
|
|
363
|
+
/** Vue globale : tous les messages du serveur, indépendamment des boîtes. */
|
|
364
|
+
listAllMessages(opts = {}) {
|
|
365
|
+
const limit = clamp(opts.limit ?? 50, 1, MAX_LIMIT);
|
|
366
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
367
|
+
const where = ["1 = 1"];
|
|
368
|
+
const params = [];
|
|
369
|
+
if (opts.search) {
|
|
370
|
+
where.push("(m.subject LIKE ? ESCAPE '\\' OR m.body LIKE ? ESCAPE '\\')");
|
|
371
|
+
const like = `%${escapeLike(opts.search)}%`;
|
|
372
|
+
params.push(like, like);
|
|
373
|
+
}
|
|
374
|
+
if (opts.agent) {
|
|
375
|
+
where.push("(m.sender_id = ? OR EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = m.id AND d.agent_id = ?))");
|
|
376
|
+
params.push(opts.agent, opts.agent);
|
|
377
|
+
}
|
|
378
|
+
if (opts.thread_id) {
|
|
379
|
+
where.push("m.thread_id = ?");
|
|
380
|
+
params.push(opts.thread_id);
|
|
381
|
+
}
|
|
382
|
+
const rows = this.db
|
|
383
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
|
|
384
|
+
m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
|
|
385
|
+
FROM messages m
|
|
386
|
+
WHERE ${where.join(" AND ")}
|
|
387
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
388
|
+
LIMIT ? OFFSET ?`)
|
|
389
|
+
.all(...params, limit, offset);
|
|
390
|
+
const recipients = this.recipientsFor(rows.map((r) => r.message_id));
|
|
391
|
+
return rows.map((row) => this.toSummary(row, recipients));
|
|
392
|
+
}
|
|
393
|
+
/** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
|
|
394
|
+
getMessageAdmin(messageId) {
|
|
395
|
+
const row = this.db
|
|
396
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
|
|
397
|
+
m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
|
|
398
|
+
FROM messages m WHERE m.id = ?`)
|
|
399
|
+
.get(messageId);
|
|
400
|
+
if (!row)
|
|
401
|
+
throw new MessengerError(`message not found: ${messageId}`, 404);
|
|
402
|
+
const recipients = this.recipientsFor([messageId]);
|
|
403
|
+
const deliveries = this.db
|
|
404
|
+
.prepare("SELECT agent_id, role, folder, read_at FROM deliveries WHERE message_id = ? ORDER BY role, agent_id")
|
|
405
|
+
.all(messageId);
|
|
406
|
+
return {
|
|
407
|
+
...this.toMessage(row, recipients),
|
|
408
|
+
deliveries: deliveries.map((d) => ({
|
|
409
|
+
agent_id: d.agent_id,
|
|
410
|
+
role: d.role,
|
|
411
|
+
folder: d.folder,
|
|
412
|
+
read: d.read_at !== null,
|
|
413
|
+
})),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
overview() {
|
|
417
|
+
const messages = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
|
|
418
|
+
const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
|
|
419
|
+
const sentByAgent = new Map(this.db.prepare("SELECT sender_id, COUNT(*) AS n FROM messages GROUP BY sender_id").all().map((r) => [r.sender_id, r.n]));
|
|
420
|
+
const receivedByAgent = new Map(this.db
|
|
421
|
+
.prepare(`SELECT agent_id,
|
|
422
|
+
COUNT(*) AS received,
|
|
423
|
+
SUM(CASE WHEN read_at IS NULL AND folder != 'trash' THEN 1 ELSE 0 END) AS unread
|
|
424
|
+
FROM deliveries WHERE role != 'sender' GROUP BY agent_id`)
|
|
425
|
+
.all().map((r) => [r.agent_id, r]));
|
|
426
|
+
const agents = this.listAgents().map((agent) => ({
|
|
427
|
+
...agent,
|
|
428
|
+
sent: sentByAgent.get(agent.id) ?? 0,
|
|
429
|
+
received: receivedByAgent.get(agent.id)?.received ?? 0,
|
|
430
|
+
unread: receivedByAgent.get(agent.id)?.unread ?? 0,
|
|
431
|
+
}));
|
|
432
|
+
return { messages, threads, agents };
|
|
433
|
+
}
|
|
434
|
+
// --- Interne ------------------------------------------------------------
|
|
435
|
+
updateDeliveries(agentId, messageIds, sets, params) {
|
|
436
|
+
const ids = this.ownedMessageIds(agentId, messageIds);
|
|
437
|
+
if (ids.length === 0)
|
|
438
|
+
return 0;
|
|
439
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
440
|
+
return this.db
|
|
441
|
+
.prepare(`UPDATE deliveries SET ${sets.join(", ")} WHERE agent_id = ? AND message_id IN (${placeholders})`)
|
|
442
|
+
.run(...params, agentId, ...ids).changes;
|
|
443
|
+
}
|
|
444
|
+
/** Filtre les ids fournis sur ceux réellement livrés à cet agent (isolation des boîtes). */
|
|
445
|
+
ownedMessageIds(agentId, messageIds) {
|
|
446
|
+
const ids = dedupe(messageIds.map((id) => String(id ?? "").trim()).filter(Boolean));
|
|
447
|
+
if (ids.length === 0)
|
|
448
|
+
throw new MessengerError("at least one message id is required");
|
|
449
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
450
|
+
const rows = this.db
|
|
451
|
+
.prepare(`SELECT DISTINCT message_id FROM deliveries WHERE agent_id = ? AND message_id IN (${placeholders})`)
|
|
452
|
+
.all(agentId, ...ids);
|
|
453
|
+
return rows.map((r) => r.message_id);
|
|
454
|
+
}
|
|
455
|
+
assertFolderExists(agentId, folder) {
|
|
456
|
+
if (SYSTEM_FOLDERS.includes(folder))
|
|
457
|
+
return;
|
|
458
|
+
const row = this.db.prepare("SELECT 1 FROM folders WHERE agent_id = ? AND name = ?").get(agentId, folder);
|
|
459
|
+
if (!row)
|
|
460
|
+
throw new MessengerError(`unknown folder: ${folder}`, 404);
|
|
461
|
+
}
|
|
462
|
+
recipientsFor(messageIds) {
|
|
463
|
+
const map = new Map();
|
|
464
|
+
const ids = dedupe(messageIds);
|
|
465
|
+
if (ids.length === 0)
|
|
466
|
+
return map;
|
|
467
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
468
|
+
const rows = this.db
|
|
469
|
+
.prepare(`SELECT message_id, agent_id, role FROM deliveries
|
|
470
|
+
WHERE message_id IN (${placeholders}) AND role != 'sender'
|
|
471
|
+
ORDER BY agent_id`)
|
|
472
|
+
.all(...ids);
|
|
473
|
+
for (const row of rows) {
|
|
474
|
+
const entry = map.get(row.message_id) ?? { to: [], cc: [] };
|
|
475
|
+
if (row.role === "to")
|
|
476
|
+
entry.to.push(row.agent_id);
|
|
477
|
+
else if (row.role === "cc")
|
|
478
|
+
entry.cc.push(row.agent_id);
|
|
479
|
+
map.set(row.message_id, entry);
|
|
480
|
+
}
|
|
481
|
+
return map;
|
|
482
|
+
}
|
|
483
|
+
toSummary(row, recipients) {
|
|
484
|
+
const rcpt = recipients.get(row.message_id) ?? { to: [], cc: [] };
|
|
485
|
+
return {
|
|
486
|
+
message_id: row.message_id,
|
|
487
|
+
thread_id: row.thread_id,
|
|
488
|
+
from: row.sender_id,
|
|
489
|
+
to: rcpt.to,
|
|
490
|
+
cc: rcpt.cc,
|
|
491
|
+
subject: row.subject,
|
|
492
|
+
preview: preview(row.body),
|
|
493
|
+
priority: row.priority,
|
|
494
|
+
folder: row.folder,
|
|
495
|
+
role: DELIVERY_ROLES.includes(row.role) ? row.role : "to",
|
|
496
|
+
read: row.read_at !== null,
|
|
497
|
+
starred: Boolean(row.starred),
|
|
498
|
+
created_at: row.created_at,
|
|
499
|
+
in_reply_to: row.in_reply_to,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
toMessage(row, recipients) {
|
|
503
|
+
const { preview: _preview, ...summary } = this.toSummary(row, recipients);
|
|
504
|
+
return { ...summary, body: row.body };
|
|
505
|
+
}
|
|
506
|
+
/** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
|
|
507
|
+
dropOrphanMessages() {
|
|
508
|
+
this.db.exec("DELETE FROM messages WHERE NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = messages.id)");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
export function normalizeAgentId(id) {
|
|
512
|
+
return String(id ?? "").trim();
|
|
513
|
+
}
|
|
514
|
+
function dedupe(values) {
|
|
515
|
+
return [...new Set(values)];
|
|
516
|
+
}
|
|
517
|
+
function clamp(value, min, max) {
|
|
518
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
519
|
+
}
|
|
520
|
+
function preview(body) {
|
|
521
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
522
|
+
return flat.length <= PREVIEW_LENGTH ? flat : `${flat.slice(0, PREVIEW_LENGTH - 1)}…`;
|
|
523
|
+
}
|
|
524
|
+
/** Échappe les jokers SQL : la recherche reste littérale, `%` cherche bien un `%`. */
|
|
525
|
+
function escapeLike(value) {
|
|
526
|
+
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
527
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Dossiers présents pour tout agent : ils ne peuvent être ni créés ni supprimés. */
|
|
2
|
+
export declare const SYSTEM_FOLDERS: readonly ["inbox", "sent", "archive", "trash"];
|
|
3
|
+
export type SystemFolder = (typeof SYSTEM_FOLDERS)[number];
|
|
4
|
+
export declare const PRIORITIES: readonly ["low", "normal", "high"];
|
|
5
|
+
export type Priority = (typeof PRIORITIES)[number];
|
|
6
|
+
/** Rôle d'un agent vis-à-vis d'un message : destinataire, copie, ou expéditeur. */
|
|
7
|
+
export declare const DELIVERY_ROLES: readonly ["to", "cc", "sender"];
|
|
8
|
+
export type DeliveryRole = (typeof DELIVERY_ROLES)[number];
|
|
9
|
+
export interface Agent {
|
|
10
|
+
id: string;
|
|
11
|
+
display_name: string | null;
|
|
12
|
+
description: string | null;
|
|
13
|
+
created_at: string;
|
|
14
|
+
last_seen_at: string | null;
|
|
15
|
+
/** false pour un agent créé implicitement parce qu'un message lui a été adressé. */
|
|
16
|
+
registered: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface SendMessageInput {
|
|
19
|
+
from: string;
|
|
20
|
+
to: string[];
|
|
21
|
+
cc?: string[];
|
|
22
|
+
subject: string;
|
|
23
|
+
body: string;
|
|
24
|
+
priority?: Priority;
|
|
25
|
+
/** Id d'un message existant : la réponse hérite de son fil de discussion. */
|
|
26
|
+
reply_to?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface SendMessageResult {
|
|
29
|
+
message_id: string;
|
|
30
|
+
thread_id: string;
|
|
31
|
+
created_at: string;
|
|
32
|
+
delivered_to: string[];
|
|
33
|
+
/** Destinataires jamais enregistrés sur ce serveur — le message les attend malgré tout. */
|
|
34
|
+
unknown_recipients: string[];
|
|
35
|
+
}
|
|
36
|
+
export interface MessageSummary {
|
|
37
|
+
message_id: string;
|
|
38
|
+
thread_id: string;
|
|
39
|
+
from: string;
|
|
40
|
+
to: string[];
|
|
41
|
+
cc: string[];
|
|
42
|
+
subject: string;
|
|
43
|
+
preview: string;
|
|
44
|
+
priority: Priority;
|
|
45
|
+
folder: string;
|
|
46
|
+
role: DeliveryRole;
|
|
47
|
+
read: boolean;
|
|
48
|
+
starred: boolean;
|
|
49
|
+
created_at: string;
|
|
50
|
+
in_reply_to: string | null;
|
|
51
|
+
}
|
|
52
|
+
export interface Message extends Omit<MessageSummary, "preview"> {
|
|
53
|
+
body: string;
|
|
54
|
+
}
|
|
55
|
+
export interface FolderInfo {
|
|
56
|
+
name: string;
|
|
57
|
+
system: boolean;
|
|
58
|
+
total: number;
|
|
59
|
+
unread: number;
|
|
60
|
+
}
|
|
61
|
+
export interface ListMessagesOptions {
|
|
62
|
+
folder?: string;
|
|
63
|
+
unread_only?: boolean;
|
|
64
|
+
thread_id?: string;
|
|
65
|
+
from?: string;
|
|
66
|
+
search?: string;
|
|
67
|
+
limit?: number;
|
|
68
|
+
offset?: number;
|
|
69
|
+
}
|
|
70
|
+
export interface MailboxStats {
|
|
71
|
+
agent_id: string;
|
|
72
|
+
unread_total: number;
|
|
73
|
+
folders: FolderInfo[];
|
|
74
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Dossiers présents pour tout agent : ils ne peuvent être ni créés ni supprimés. */
|
|
2
|
+
export const SYSTEM_FOLDERS = ["inbox", "sent", "archive", "trash"];
|
|
3
|
+
export const PRIORITIES = ["low", "normal", "high"];
|
|
4
|
+
/** Rôle d'un agent vis-à-vis d'un message : destinataire, copie, ou expéditeur. */
|
|
5
|
+
export const DELIVERY_ROLES = ["to", "cc", "sender"];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import { ConfigError } from "./config.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
import { APP_VERSION } from "./version.js";
|
|
6
|
+
const USAGE = `mcp-messenger v${APP_VERSION} — agent-to-agent mailbox
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
mcp-messenger Start in MCP mode (stdio) — the agent-side client.
|
|
10
|
+
mcp-messenger --server Start the mailbox server (HTTP API + web console).
|
|
11
|
+
mcp-messenger --claude-setup-mcp [--force]
|
|
12
|
+
Write the messenger entry into ./.mcp.json.
|
|
13
|
+
mcp-messenger --version Print the version.
|
|
14
|
+
mcp-messenger --help Print this help.
|
|
15
|
+
|
|
16
|
+
MCP mode requires MESSENGER_SERVER_URL and MESSENGER_TOKEN; each session gets its own
|
|
17
|
+
mailbox unless MESSENGER_AGENT_ID pins a fixed address.
|
|
18
|
+
Server mode requires MESSENGER_TOKEN (or MESSENGER_AGENT_TOKENS) and reads MESSENGER_PORT
|
|
19
|
+
and MESSENGER_DB_PATH. It always listens on 127.0.0.1.
|
|
20
|
+
See .env.example for the full list.`;
|
|
21
|
+
async function main() {
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
24
|
+
process.stdout.write(`${USAGE}\n`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
28
|
+
process.stdout.write(`${APP_VERSION}\n`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Traité avant toute initialisation : la commande écrit dans le répertoire de
|
|
32
|
+
// l'utilisateur, elle n'ouvre ni base ni serveur.
|
|
33
|
+
if (args.includes("--claude-setup-mcp")) {
|
|
34
|
+
const { runSetupMcp } = await import("./cli/setup-mcp.js");
|
|
35
|
+
process.exit(runSetupMcp({ cwd: process.cwd(), force: args.includes("--force") }));
|
|
36
|
+
}
|
|
37
|
+
if (args.includes("--server")) {
|
|
38
|
+
const { runServerMode } = await import("./server/start.js");
|
|
39
|
+
await runServerMode();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const { runMcpMode } = await import("./mcp/start.js");
|
|
43
|
+
await runMcpMode();
|
|
44
|
+
}
|
|
45
|
+
main().catch((error) => {
|
|
46
|
+
if (error instanceof ConfigError) {
|
|
47
|
+
// Erreur de configuration : le message suffit, la stack n'aide personne.
|
|
48
|
+
logger.error(error.message);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
logger.error("fatal error", error);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|