@imenam/mcp-messenger 1.0.4 → 1.0.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 +60 -22
- package/dist/src/cli/generate-token.d.ts +2 -2
- package/dist/src/cli/generate-token.js +2 -2
- package/dist/src/cli/setup-mcp.js +1 -1
- package/dist/src/cli/watch.js +3 -1
- package/dist/src/client/messenger-client.d.ts +18 -3
- package/dist/src/client/messenger-client.js +10 -2
- package/dist/src/core/store.d.ts +22 -3
- package/dist/src/core/store.js +201 -13
- package/dist/src/core/types.d.ts +48 -0
- package/dist/src/index.js +1 -1
- package/dist/src/mcp/tools.js +111 -17
- package/dist/src/server/app.js +99 -58
- package/dist/src/server/console-session.d.ts +30 -0
- package/dist/src/server/console-session.js +85 -0
- package/dist/src/server/start.js +1 -2
- package/dist/src/server/ui/app.js +318 -275
- package/dist/src/server/ui/index.html +5 -4
- package/dist/src/server/ui/styles.css +21 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +2 -1
package/dist/src/core/types.d.ts
CHANGED
|
@@ -15,6 +15,15 @@ export interface Agent {
|
|
|
15
15
|
/** false pour un agent créé implicitement parce qu'un message lui a été adressé. */
|
|
16
16
|
registered: boolean;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Entrée d'annuaire : la fiche de l'agent, plus le moment où sa boîte a vécu pour la
|
|
20
|
+
* dernière fois. C'est ce moment qui classe l'annuaire, pas l'identifiant : une adresse
|
|
21
|
+
* opaque ne dit rien à qui lit la console.
|
|
22
|
+
*/
|
|
23
|
+
export interface AgentDirectoryEntry extends Agent {
|
|
24
|
+
/** Plus récent de la dernière connexion et de la dernière livraison ; null si aucune. */
|
|
25
|
+
last_activity_at: string | null;
|
|
26
|
+
}
|
|
18
27
|
export interface SendMessageInput {
|
|
19
28
|
from: string;
|
|
20
29
|
to: string[];
|
|
@@ -24,10 +33,39 @@ export interface SendMessageInput {
|
|
|
24
33
|
priority?: Priority;
|
|
25
34
|
/** Id d'un message existant : la réponse hérite de son fil de discussion. */
|
|
26
35
|
reply_to?: string | null;
|
|
36
|
+
/** Nom lisible donné au fil. Sur une réponse, renomme le fil existant. */
|
|
37
|
+
thread_title?: string | null;
|
|
38
|
+
thread_description?: string | null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Intitulé d'un fil de discussion.
|
|
42
|
+
*
|
|
43
|
+
* Le titre est unique et partagé : tous les participants parlent du même sujet, ils
|
|
44
|
+
* doivent donc lire le même nom. Le dernier qui renomme a le dernier mot ; `named_by`
|
|
45
|
+
* garde la trace de qui c'était.
|
|
46
|
+
*/
|
|
47
|
+
export interface ThreadInfo {
|
|
48
|
+
thread_id: string;
|
|
49
|
+
title: string | null;
|
|
50
|
+
description: string | null;
|
|
51
|
+
/** Agent ayant posé le nom courant, ou null tant que le fil n'a pas été nommé. */
|
|
52
|
+
named_by: string | null;
|
|
53
|
+
created_at: string;
|
|
54
|
+
updated_at: string | null;
|
|
55
|
+
}
|
|
56
|
+
export interface ThreadSummary extends ThreadInfo {
|
|
57
|
+
/** Sujet du message d'ouverture : intitulé de repli tant que le fil n'est pas nommé. */
|
|
58
|
+
subject: string;
|
|
59
|
+
messages: number;
|
|
60
|
+
unread: number;
|
|
61
|
+
participants: string[];
|
|
62
|
+
last_message_at: string;
|
|
27
63
|
}
|
|
28
64
|
export interface SendMessageResult {
|
|
29
65
|
message_id: string;
|
|
30
66
|
thread_id: string;
|
|
67
|
+
/** Titre du fil après envoi, null s'il n'a jamais été nommé. */
|
|
68
|
+
thread_title: string | null;
|
|
31
69
|
created_at: string;
|
|
32
70
|
delivered_to: string[];
|
|
33
71
|
/** Destinataires jamais enregistrés sur ce serveur — le message les attend malgré tout. */
|
|
@@ -36,6 +74,8 @@ export interface SendMessageResult {
|
|
|
36
74
|
export interface MessageSummary {
|
|
37
75
|
message_id: string;
|
|
38
76
|
thread_id: string;
|
|
77
|
+
/** Titre du fil, null tant que personne ne l'a nommé — le `subject` fait alors office. */
|
|
78
|
+
thread_title: string | null;
|
|
39
79
|
from: string;
|
|
40
80
|
to: string[];
|
|
41
81
|
cc: string[];
|
|
@@ -58,6 +98,14 @@ export interface FolderInfo {
|
|
|
58
98
|
total: number;
|
|
59
99
|
unread: number;
|
|
60
100
|
}
|
|
101
|
+
export interface ListThreadsOptions {
|
|
102
|
+
/** Ne garder que les fils ayant au moins un message non lu. */
|
|
103
|
+
unread_only?: boolean;
|
|
104
|
+
/** Texte libre confronté au titre, à la description et au sujet d'ouverture. */
|
|
105
|
+
search?: string;
|
|
106
|
+
limit?: number;
|
|
107
|
+
offset?: number;
|
|
108
|
+
}
|
|
61
109
|
export interface ListMessagesOptions {
|
|
62
110
|
folder?: string;
|
|
63
111
|
unread_only?: boolean;
|
package/dist/src/index.js
CHANGED
|
@@ -43,7 +43,7 @@ async function main() {
|
|
|
43
43
|
"Ce jeton doit être identique aux trois endroits suivants :",
|
|
44
44
|
" 1. le serveur : MESSENGER_TOKEN",
|
|
45
45
|
" 2. chaque agent : MESSENGER_TOKEN dans son .mcp.json",
|
|
46
|
-
" 3. la console web : http://127.0.0.1:3600/ui
|
|
46
|
+
" 3. la console web : http://127.0.0.1:3600/ui/ (à saisir dans le formulaire)",
|
|
47
47
|
"",
|
|
48
48
|
].join("\n"));
|
|
49
49
|
return;
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -25,21 +25,37 @@ export const TOOLS = [
|
|
|
25
25
|
},
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
|
-
name: "
|
|
29
|
-
description: "Give this mailbox a
|
|
28
|
+
name: "set_label",
|
|
29
|
+
description: "Give a plain-language name to this mailbox, or to a thread. Ids are opaque: a name is what makes a mailbox or a conversation recognisable later, both for the other agents and for the user reading the web console. Without thread_id it names this mailbox ('Développement des tests unitaires du parser'); with thread_id it names that thread ('Le test échoue sur un bug applicatif'). A thread has a single shared name: renaming it changes it for every participant, and the last caller wins.",
|
|
30
30
|
inputSchema: {
|
|
31
31
|
type: "object",
|
|
32
32
|
properties: {
|
|
33
|
-
label: { type: "string", description: "Short name,
|
|
34
|
-
description: { type: "string", description: "
|
|
33
|
+
label: { type: "string", description: "Short name, one line. Say what the work is about, not who does it." },
|
|
34
|
+
description: { type: "string", description: "Optional longer explanation of the mailbox or the thread." },
|
|
35
|
+
thread_id: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "Thread to name. Omit to name this mailbox instead. You must be a participant of the thread.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "list_threads",
|
|
45
|
+
description: "List the conversations visible from this mailbox, most recently active first, with their name, participants and unread count. This is the readable map of the ongoing work: prefer it over list_messages when you want to know what is being discussed rather than what arrived last. A thread with no name falls back to the subject of its opening message — name it with set_label.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
unread_only: { type: "boolean", description: "Keep only threads with unread messages." },
|
|
50
|
+
search: { type: "string", description: "Free text matched against thread name, description and subject." },
|
|
51
|
+
limit: { type: "number", description: "1-100, defaults to 25." },
|
|
35
52
|
},
|
|
36
|
-
required: ["label"],
|
|
37
53
|
additionalProperties: false,
|
|
38
54
|
},
|
|
39
55
|
},
|
|
40
56
|
{
|
|
41
57
|
name: "list_agents",
|
|
42
|
-
description: "List the agents known to the mailbox server, with their
|
|
58
|
+
description: "List the agents known to the mailbox server, most recently active first, with their name and description. Use it to find the address of the agent you want to write to.",
|
|
43
59
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
44
60
|
},
|
|
45
61
|
{
|
|
@@ -59,6 +75,11 @@ export const TOOLS = [
|
|
|
59
75
|
body: { type: "string", description: "Message body, plain text or markdown." },
|
|
60
76
|
priority: { type: "string", enum: [...PRIORITIES], description: "Defaults to normal." },
|
|
61
77
|
reply_to: { type: "string", description: "Id of the message being answered; the reply joins its thread." },
|
|
78
|
+
thread_title: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "Plain-language name of the conversation, e.g. 'Résolution de l'échec du test de parsing'. Name the thread when you open it — you already know what it is about. On a reply it renames the thread for everyone, which is what you want when the discussion has drifted.",
|
|
81
|
+
},
|
|
82
|
+
thread_description: { type: "string", description: "Optional longer description of the thread." },
|
|
62
83
|
},
|
|
63
84
|
required: ["to", "subject", "body"],
|
|
64
85
|
additionalProperties: false,
|
|
@@ -195,6 +216,8 @@ const schemas = {
|
|
|
195
216
|
body: z.string().min(1),
|
|
196
217
|
priority: z.enum(PRIORITIES).optional(),
|
|
197
218
|
reply_to: z.string().optional(),
|
|
219
|
+
thread_title: z.string().trim().min(1).max(200).optional(),
|
|
220
|
+
thread_description: z.string().trim().min(1).max(2000).optional(),
|
|
198
221
|
}),
|
|
199
222
|
list_messages: z.object({
|
|
200
223
|
folder: z.string().optional(),
|
|
@@ -215,7 +238,20 @@ const schemas = {
|
|
|
215
238
|
move_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), folder: z.string().min(1) }),
|
|
216
239
|
delete_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), permanent: z.boolean().optional() }),
|
|
217
240
|
attach_to_mailbox: z.object({ mailbox_id: z.string().trim().min(1).optional() }),
|
|
218
|
-
|
|
241
|
+
set_label: z
|
|
242
|
+
.object({
|
|
243
|
+
label: z.string().trim().min(1).max(200).optional(),
|
|
244
|
+
description: z.string().trim().min(1).max(2000).optional(),
|
|
245
|
+
thread_id: z.string().trim().min(1).optional(),
|
|
246
|
+
})
|
|
247
|
+
.refine((v) => v.label !== undefined || v.description !== undefined, {
|
|
248
|
+
message: "provide `label` and/or `description`",
|
|
249
|
+
}),
|
|
250
|
+
list_threads: z.object({
|
|
251
|
+
unread_only: z.boolean().optional(),
|
|
252
|
+
search: z.string().trim().min(1).optional(),
|
|
253
|
+
limit: z.number().int().optional(),
|
|
254
|
+
}),
|
|
219
255
|
create_folder: z.object({ name: z.string().min(1) }),
|
|
220
256
|
delete_folder: z.object({ name: z.string().min(1) }),
|
|
221
257
|
wait_for_message: z.object({ timeout_seconds: z.number().int().optional(), folder: z.string().optional() }),
|
|
@@ -233,9 +269,13 @@ export async function callTool(client, name, rawArgs) {
|
|
|
233
269
|
const stats = await client.stats();
|
|
234
270
|
const health = await client.health().catch(() => null);
|
|
235
271
|
const folders = stats.folders.map((f) => ` ${f.name.padEnd(12)} ${f.total} message(s), ${f.unread} unread`);
|
|
272
|
+
const self = await client.getAgent(client.agentId).catch(() => null);
|
|
236
273
|
const lines = [
|
|
237
274
|
`Mailbox address: ${client.agentId}`,
|
|
238
275
|
" ↳ give this address to another agent so it can write to you.",
|
|
276
|
+
self?.display_name
|
|
277
|
+
? `Mailbox name: ${self.display_name}`
|
|
278
|
+
: "Mailbox name: none — call set_label so the user can tell this session apart from the others.",
|
|
239
279
|
];
|
|
240
280
|
if (client.isAttachedElsewhere) {
|
|
241
281
|
lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
|
|
@@ -273,10 +313,36 @@ export async function callTool(client, name, rawArgs) {
|
|
|
273
313
|
.filter(Boolean)
|
|
274
314
|
.join("\n");
|
|
275
315
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
316
|
+
// `set_mailbox_label` n'est plus publié, mais reste accepté : une session en cours
|
|
317
|
+
// peut l'avoir appris avant la mise à jour du serveur.
|
|
318
|
+
case "set_mailbox_label":
|
|
319
|
+
case "set_label": {
|
|
320
|
+
const input = schemas.set_label.parse(args);
|
|
321
|
+
if (input.thread_id) {
|
|
322
|
+
const thread = await client.setThreadLabel(input.thread_id, {
|
|
323
|
+
title: input.label ?? null,
|
|
324
|
+
description: input.description ?? null,
|
|
325
|
+
});
|
|
326
|
+
return [
|
|
327
|
+
`Thread ${thread.thread_id} is now named "${thread.title}".`,
|
|
328
|
+
thread.description ? `Description: ${thread.description}` : null,
|
|
329
|
+
"Every participant of the thread sees this name.",
|
|
330
|
+
]
|
|
331
|
+
.filter(Boolean)
|
|
332
|
+
.join("\n");
|
|
333
|
+
}
|
|
334
|
+
const agent = await client.updateProfile({
|
|
335
|
+
display_name: input.label ?? null,
|
|
336
|
+
description: input.description ?? null,
|
|
337
|
+
});
|
|
338
|
+
return `Mailbox ${agent.id} named "${agent.display_name}".`;
|
|
339
|
+
}
|
|
340
|
+
case "list_threads": {
|
|
341
|
+
const input = schemas.list_threads.parse(args);
|
|
342
|
+
const threads = await client.listThreads(input);
|
|
343
|
+
if (threads.length === 0)
|
|
344
|
+
return "No thread in this mailbox matching these filters.";
|
|
345
|
+
return [`${threads.length} thread(s):`, "", ...threads.map(formatThread)].join("\n");
|
|
280
346
|
}
|
|
281
347
|
case "list_agents": {
|
|
282
348
|
const agents = await client.listAgents();
|
|
@@ -288,7 +354,8 @@ export async function callTool(client, name, rawArgs) {
|
|
|
288
354
|
.filter(Boolean)
|
|
289
355
|
.join(", ");
|
|
290
356
|
const title = agent.display_name ? `${agent.id} — ${agent.display_name}` : agent.id;
|
|
291
|
-
|
|
357
|
+
// L'annuaire est classé par cette date : l'afficher rend l'ordre lisible.
|
|
358
|
+
const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
|
|
292
359
|
return [`• ${title}${flags ? ` (${flags})` : ""}`, ` ${agent.description ?? "no description"}`, ` ${seen}`].join("\n");
|
|
293
360
|
})
|
|
294
361
|
.join("\n");
|
|
@@ -302,12 +369,17 @@ export async function callTool(client, name, rawArgs) {
|
|
|
302
369
|
body: input.body,
|
|
303
370
|
priority: input.priority,
|
|
304
371
|
reply_to: input.reply_to ?? null,
|
|
372
|
+
thread_title: input.thread_title ?? null,
|
|
373
|
+
thread_description: input.thread_description ?? null,
|
|
305
374
|
});
|
|
306
375
|
const lines = [
|
|
307
376
|
`Message sent: ${result.message_id}`,
|
|
308
|
-
`Thread: ${result.thread_id}`,
|
|
377
|
+
`Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
|
|
309
378
|
`Delivered to: ${result.delivered_to.join(", ")}`,
|
|
310
379
|
];
|
|
380
|
+
if (!result.thread_title && !input.reply_to) {
|
|
381
|
+
lines.push("This thread has no name yet — set_label with its thread_id makes it readable for everyone.");
|
|
382
|
+
}
|
|
311
383
|
if (result.unknown_recipients.length > 0) {
|
|
312
384
|
lines.push(`Warning — never connected to this server, the message will wait in their mailbox: ${result.unknown_recipients.join(", ")}`);
|
|
313
385
|
}
|
|
@@ -328,8 +400,14 @@ export async function callTool(client, name, rawArgs) {
|
|
|
328
400
|
}
|
|
329
401
|
case "get_thread": {
|
|
330
402
|
const input = schemas.get_thread.parse(args);
|
|
331
|
-
const messages = await client.getThread(input.thread_id);
|
|
332
|
-
|
|
403
|
+
const { thread, messages } = await client.getThread(input.thread_id);
|
|
404
|
+
const header = [
|
|
405
|
+
`Thread ${formatThreadRef(thread?.title ?? null, input.thread_id)} — ${messages.length} message(s):`,
|
|
406
|
+
thread?.description ? `Description: ${thread.description}` : null,
|
|
407
|
+
]
|
|
408
|
+
.filter(Boolean)
|
|
409
|
+
.join("\n");
|
|
410
|
+
return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
|
|
333
411
|
}
|
|
334
412
|
case "mark_messages": {
|
|
335
413
|
const input = schemas.mark_messages.parse(args);
|
|
@@ -424,6 +502,22 @@ function asArray(value) {
|
|
|
424
502
|
return [];
|
|
425
503
|
return Array.isArray(value) ? value : [value];
|
|
426
504
|
}
|
|
505
|
+
/** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
|
|
506
|
+
function formatThreadRef(title, threadId) {
|
|
507
|
+
return title ? `${title} (${threadId})` : threadId;
|
|
508
|
+
}
|
|
509
|
+
function formatThread(thread) {
|
|
510
|
+
// Sans nom, le sujet d'ouverture reste le seul repère : mieux vaut l'afficher que l'id nu.
|
|
511
|
+
const name = thread.title ?? `${thread.subject} (unnamed)`;
|
|
512
|
+
return [
|
|
513
|
+
`${thread.unread > 0 ? "•" : " "} ${name}`,
|
|
514
|
+
thread.description ? ` ${thread.description}` : null,
|
|
515
|
+
` ${thread.messages} message(s), ${thread.unread} unread · with ${thread.participants.join(", ")}`,
|
|
516
|
+
` last activity ${thread.last_message_at} · thread ${thread.thread_id}`,
|
|
517
|
+
]
|
|
518
|
+
.filter(Boolean)
|
|
519
|
+
.join("\n");
|
|
520
|
+
}
|
|
427
521
|
function formatSummary(message) {
|
|
428
522
|
const flags = [message.read ? " " : "•", message.starred ? "★" : " "].join("");
|
|
429
523
|
const priority = message.priority === "normal" ? "" : ` [${message.priority}]`;
|
|
@@ -431,7 +525,7 @@ function formatSummary(message) {
|
|
|
431
525
|
`${flags} ${message.subject}${priority}`,
|
|
432
526
|
` from ${message.from} to ${message.to.join(", ") || "—"}${message.cc.length ? ` (cc ${message.cc.join(", ")})` : ""} · ${message.created_at}`,
|
|
433
527
|
` ${message.preview}`,
|
|
434
|
-
` id ${message.message_id} · thread ${message.thread_id}`,
|
|
528
|
+
` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
|
|
435
529
|
].join("\n");
|
|
436
530
|
}
|
|
437
531
|
function formatMessage(message) {
|
|
@@ -443,7 +537,7 @@ function formatMessage(message) {
|
|
|
443
537
|
`Date: ${message.created_at}`,
|
|
444
538
|
`Priority: ${message.priority}`,
|
|
445
539
|
`Folder: ${message.folder}${message.read ? "" : " (unread)"}${message.starred ? " ★" : ""}`,
|
|
446
|
-
`Id: ${message.message_id} · Thread: ${message.thread_id}${message.in_reply_to ? ` · reply to ${message.in_reply_to}` : ""}`,
|
|
540
|
+
`Id: ${message.message_id} · Thread: ${formatThreadRef(message.thread_title, message.thread_id)}${message.in_reply_to ? ` · reply to ${message.in_reply_to}` : ""}`,
|
|
447
541
|
]
|
|
448
542
|
.filter(Boolean)
|
|
449
543
|
.join("\n");
|
package/dist/src/server/app.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import fs from "node:fs";
|
|
3
2
|
import helmet from "helmet";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -9,6 +8,7 @@ import { z } from "zod";
|
|
|
9
8
|
import { MessengerError, normalizeAgentId } from "../core/store.js";
|
|
10
9
|
import { PRIORITIES } from "../core/types.js";
|
|
11
10
|
import { WatcherRegistry } from "./watchers.js";
|
|
11
|
+
import { CONSOLE_COOKIE, ConsoleSessions, consoleSessionId } from "./console-session.js";
|
|
12
12
|
import { APP_VERSION } from "../version.js";
|
|
13
13
|
import { logger } from "../logger.js";
|
|
14
14
|
const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
|
|
@@ -27,6 +27,22 @@ const sendSchema = z.object({
|
|
|
27
27
|
body: z.string().min(1).max(200_000),
|
|
28
28
|
priority: z.enum(PRIORITIES).optional(),
|
|
29
29
|
reply_to: z.string().trim().min(1).nullish(),
|
|
30
|
+
thread_title: z.string().trim().min(1).max(200).nullish(),
|
|
31
|
+
thread_description: z.string().trim().min(1).max(2000).nullish(),
|
|
32
|
+
});
|
|
33
|
+
const threadLabelSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
title: z.string().trim().min(1).max(200).nullish(),
|
|
36
|
+
description: z.string().trim().min(1).max(2000).nullish(),
|
|
37
|
+
})
|
|
38
|
+
.refine((v) => v.title != null || v.description != null, {
|
|
39
|
+
message: "provide `title` and/or `description`",
|
|
40
|
+
});
|
|
41
|
+
const listThreadsQuerySchema = z.object({
|
|
42
|
+
unread_only: z.coerce.boolean().optional(),
|
|
43
|
+
search: z.string().trim().min(1).optional(),
|
|
44
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
45
|
+
offset: z.coerce.number().int().min(0).optional(),
|
|
30
46
|
});
|
|
31
47
|
const listQuerySchema = z.object({
|
|
32
48
|
folder: z.string().trim().min(1).optional(),
|
|
@@ -56,6 +72,7 @@ const waitQuerySchema = z.object({
|
|
|
56
72
|
// attente longue, mais seul le premier prouve que l'agent est joignable en continu.
|
|
57
73
|
watcher: z.enum(["true", "1"]).optional(),
|
|
58
74
|
});
|
|
75
|
+
const loginSchema = z.object({ token: z.string().min(1).max(1024) });
|
|
59
76
|
const adminListSchema = z.object({
|
|
60
77
|
search: z.string().trim().min(1).optional(),
|
|
61
78
|
agent: z.string().trim().min(1).optional(),
|
|
@@ -90,8 +107,65 @@ export function createApp({ store, config }) {
|
|
|
90
107
|
standardHeaders: "draft-8",
|
|
91
108
|
legacyHeaders: false,
|
|
92
109
|
}));
|
|
93
|
-
//
|
|
94
|
-
//
|
|
110
|
+
// --- Console web ----------------------------------------------------------
|
|
111
|
+
// La page et ses fichiers ne contiennent aucun secret : ils sont servis à tous. Ce sont
|
|
112
|
+
// les données (`/admin`) qui sont protégées, par une session ouverte contre le jeton
|
|
113
|
+
// admin. Voir `console-session.ts` pour le raisonnement.
|
|
114
|
+
const sessions = new ConsoleSessions();
|
|
115
|
+
const cookieOptions = (req) => ({
|
|
116
|
+
httpOnly: true,
|
|
117
|
+
sameSite: "strict",
|
|
118
|
+
// `req.secure` n'est vrai derrière un proxy TLS que si `trust proxy` est configuré.
|
|
119
|
+
secure: req.secure,
|
|
120
|
+
path: "/",
|
|
121
|
+
});
|
|
122
|
+
// Limite propre au formulaire : le jeton fait 256 bits, mais autant fermer la porte.
|
|
123
|
+
const loginLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 10, standardHeaders: "draft-8", legacyHeaders: false });
|
|
124
|
+
app.post("/ui/login", loginLimiter, (req, res) => {
|
|
125
|
+
const input = loginSchema.parse(req.body ?? {});
|
|
126
|
+
if (!safeEqual(input.token, config.adminToken)) {
|
|
127
|
+
res.status(401).json({ error: "invalid admin token" });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
res.cookie(CONSOLE_COOKIE, sessions.create(), { ...cookieOptions(req), maxAge: sessions.ttlMs });
|
|
131
|
+
res.status(204).end();
|
|
132
|
+
});
|
|
133
|
+
app.post("/ui/logout", (req, res) => {
|
|
134
|
+
sessions.revoke(consoleSessionId(req));
|
|
135
|
+
res.clearCookie(CONSOLE_COOKIE, cookieOptions(req));
|
|
136
|
+
res.status(204).end();
|
|
137
|
+
});
|
|
138
|
+
// `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
|
|
139
|
+
// sans laisser un navigateur servir l'ancienne version depuis son cache.
|
|
140
|
+
app.use("/ui", express.static(UI_DIR, { index: "index.html", maxAge: 0, etag: true }));
|
|
141
|
+
app.get("/", (_req, res) => {
|
|
142
|
+
res.redirect("/ui/");
|
|
143
|
+
});
|
|
144
|
+
// --- Supervision (lecture seule) -----------------------------------------
|
|
145
|
+
// Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
|
|
146
|
+
// console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
|
|
147
|
+
const admin = express.Router();
|
|
148
|
+
admin.use(authenticateAdmin(config, sessions));
|
|
149
|
+
admin.get("/overview", (_req, res) => {
|
|
150
|
+
res.json(store.overview());
|
|
151
|
+
});
|
|
152
|
+
admin.get("/messages", (req, res) => {
|
|
153
|
+
const query = adminListSchema.parse(req.query);
|
|
154
|
+
res.json({ messages: store.listAllMessages(query) });
|
|
155
|
+
});
|
|
156
|
+
admin.get("/messages/:id", (req, res) => {
|
|
157
|
+
res.json(store.getMessageAdmin(String(req.params.id)));
|
|
158
|
+
});
|
|
159
|
+
admin.get("/threads/:id", (req, res) => {
|
|
160
|
+
const threadId = String(req.params.id);
|
|
161
|
+
const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
|
|
162
|
+
if (messages.length === 0)
|
|
163
|
+
throw new MessengerError(`thread not found: ${threadId}`, 404);
|
|
164
|
+
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
|
|
165
|
+
});
|
|
166
|
+
app.use("/admin", admin);
|
|
167
|
+
// Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
|
|
168
|
+
// contrôle par route (identité de l'agent) vient ensuite.
|
|
95
169
|
app.use(requireAnyToken(config));
|
|
96
170
|
// --- État du service ------------------------------------------------------
|
|
97
171
|
app.get("/health", (_req, res) => {
|
|
@@ -154,6 +228,8 @@ export function createApp({ store, config }) {
|
|
|
154
228
|
body: input.body,
|
|
155
229
|
priority: input.priority,
|
|
156
230
|
reply_to: input.reply_to ?? null,
|
|
231
|
+
thread_title: input.thread_title ?? null,
|
|
232
|
+
thread_description: input.thread_description ?? null,
|
|
157
233
|
});
|
|
158
234
|
logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to.join(", ")}`);
|
|
159
235
|
res.status(201).json(result);
|
|
@@ -179,8 +255,18 @@ export function createApp({ store, config }) {
|
|
|
179
255
|
const markRead = req.query.mark_read !== "false";
|
|
180
256
|
res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
|
|
181
257
|
});
|
|
258
|
+
api.get("/threads", (req, res) => {
|
|
259
|
+
const query = listThreadsQuerySchema.parse(req.query);
|
|
260
|
+
res.json({ threads: store.listThreads(req.agentId, query) });
|
|
261
|
+
});
|
|
182
262
|
api.get("/threads/:id", (req, res) => {
|
|
183
|
-
|
|
263
|
+
const threadId = String(req.params.id);
|
|
264
|
+
const messages = store.getThread(req.agentId, threadId);
|
|
265
|
+
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
|
|
266
|
+
});
|
|
267
|
+
api.patch("/threads/:id", (req, res) => {
|
|
268
|
+
const input = threadLabelSchema.parse(req.body ?? {});
|
|
269
|
+
res.json({ thread: store.setThreadLabel(req.agentId, String(req.params.id), input) });
|
|
184
270
|
});
|
|
185
271
|
api.get("/folders", (req, res) => {
|
|
186
272
|
res.json({ folders: store.listFolders(req.agentId) });
|
|
@@ -232,52 +318,6 @@ export function createApp({ store, config }) {
|
|
|
232
318
|
req.once("close", onClose);
|
|
233
319
|
});
|
|
234
320
|
app.use("/api", api);
|
|
235
|
-
// --- Supervision (lecture seule) -----------------------------------------
|
|
236
|
-
const admin = express.Router();
|
|
237
|
-
admin.use(authenticateAdmin(config));
|
|
238
|
-
admin.get("/overview", (_req, res) => {
|
|
239
|
-
res.json(store.overview());
|
|
240
|
-
});
|
|
241
|
-
admin.get("/messages", (req, res) => {
|
|
242
|
-
const query = adminListSchema.parse(req.query);
|
|
243
|
-
res.json({ messages: store.listAllMessages(query) });
|
|
244
|
-
});
|
|
245
|
-
admin.get("/messages/:id", (req, res) => {
|
|
246
|
-
res.json(store.getMessageAdmin(String(req.params.id)));
|
|
247
|
-
});
|
|
248
|
-
admin.get("/threads/:id", (req, res) => {
|
|
249
|
-
const messages = store.listAllMessages({ thread_id: String(req.params.id), limit: 100 });
|
|
250
|
-
if (messages.length === 0)
|
|
251
|
-
throw new MessengerError(`thread not found: ${req.params.id}`, 404);
|
|
252
|
-
res.json({ thread_id: req.params.id, messages: messages.slice().reverse() });
|
|
253
|
-
});
|
|
254
|
-
app.use("/admin", admin);
|
|
255
|
-
// --- Interface web --------------------------------------------------------
|
|
256
|
-
// Un navigateur ne peut pas poser d'en-tête sur une navigation : la console s'ouvre
|
|
257
|
-
// donc avec `?token=`. La page est servie avec ce jeton propagé sur ses ressources,
|
|
258
|
-
// sans quoi le CSS et le script seraient refusés par la porte d'entrée.
|
|
259
|
-
app.get(["/ui", "/ui/", "/ui/index.html"], (req, res, next) => {
|
|
260
|
-
const token = presentedToken(req);
|
|
261
|
-
if (!token) {
|
|
262
|
-
next();
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
fs.readFile(path.join(UI_DIR, "index.html"), "utf-8", (error, html) => {
|
|
266
|
-
if (error) {
|
|
267
|
-
next(error);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
const suffix = `?token=${encodeURIComponent(token)}`;
|
|
271
|
-
res.type("html").send(html.replaceAll("./styles.css", `./styles.css${suffix}`).replaceAll("./app.js", `./app.js${suffix}`));
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
// `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
|
|
275
|
-
// sans laisser un navigateur servir l'ancienne version depuis son cache.
|
|
276
|
-
app.use("/ui", express.static(UI_DIR, { index: false, maxAge: 0, etag: true }));
|
|
277
|
-
app.get("/", (req, res) => {
|
|
278
|
-
const token = presentedToken(req);
|
|
279
|
-
res.redirect(token ? `/ui/?token=${encodeURIComponent(token)}` : "/ui/");
|
|
280
|
-
});
|
|
281
321
|
// --- Erreurs --------------------------------------------------------------
|
|
282
322
|
app.use((_req, res) => {
|
|
283
323
|
res.status(404).json({ error: "not found" });
|
|
@@ -350,8 +390,13 @@ function authenticateAgent(config) {
|
|
|
350
390
|
next();
|
|
351
391
|
};
|
|
352
392
|
}
|
|
353
|
-
|
|
393
|
+
/** Session de console (navigateur) ou jeton admin (script, `curl`). */
|
|
394
|
+
function authenticateAdmin(config, sessions) {
|
|
354
395
|
return (req, res, next) => {
|
|
396
|
+
if (sessions.has(consoleSessionId(req))) {
|
|
397
|
+
next();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
355
400
|
const presented = presentedToken(req);
|
|
356
401
|
if (!presented || !safeEqual(presented, config.adminToken)) {
|
|
357
402
|
res.status(401).json({ error: "invalid or missing admin token" });
|
|
@@ -361,19 +406,15 @@ function authenticateAdmin(config) {
|
|
|
361
406
|
};
|
|
362
407
|
}
|
|
363
408
|
/**
|
|
364
|
-
* Jeton présenté par l'appelant
|
|
365
|
-
* en
|
|
366
|
-
* navigation navigateur vers la console, qui ne peut pas poser d'en-tête.
|
|
409
|
+
* Jeton présenté par l'appelant : en-tête `Authorization`, sinon `X-Messenger-Token`.
|
|
410
|
+
* Jamais l'URL : un jeton en query string finit dans les journaux d'accès du proxy.
|
|
367
411
|
*/
|
|
368
412
|
function presentedToken(req) {
|
|
369
413
|
const header = req.header("authorization");
|
|
370
414
|
if (header?.toLowerCase().startsWith("bearer "))
|
|
371
415
|
return header.slice(7).trim() || null;
|
|
372
416
|
const alt = req.header("x-messenger-token");
|
|
373
|
-
|
|
374
|
-
return alt.trim();
|
|
375
|
-
const query = req.query.token;
|
|
376
|
-
return typeof query === "string" && query.trim() ? query.trim() : null;
|
|
417
|
+
return alt?.trim() ? alt.trim() : null;
|
|
377
418
|
}
|
|
378
419
|
/** Comparaison à temps constant : évite de laisser fuiter le jeton octet par octet. */
|
|
379
420
|
function safeEqual(a, b) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Request } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Sessions de la console web.
|
|
4
|
+
*
|
|
5
|
+
* Le navigateur ne peut pas poser d'en-tête sur une navigation, et faire transiter le
|
|
6
|
+
* jeton dans l'URL le dépose dans l'historique et les journaux d'accès du proxy. La
|
|
7
|
+
* console s'ouvre donc sans jeton, le demande une fois dans un formulaire, et reçoit en
|
|
8
|
+
* retour un identifiant de session opaque dans un cookie `HttpOnly`.
|
|
9
|
+
*
|
|
10
|
+
* Le cookie ne porte jamais le jeton lui-même : sa fuite ne donne qu'une session, à durée
|
|
11
|
+
* limitée, que le serveur oublie au redémarrage. Il n'est honoré que sur `/admin` et
|
|
12
|
+
* `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
|
|
13
|
+
* agir un agent à travers le navigateur de l'administrateur.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CONSOLE_COOKIE = "messenger_console";
|
|
16
|
+
export declare class ConsoleSessions {
|
|
17
|
+
readonly ttlMs: number;
|
|
18
|
+
private readonly now;
|
|
19
|
+
private readonly sessions;
|
|
20
|
+
constructor(ttlMs?: number, now?: () => number);
|
|
21
|
+
/** Ouvre une session et rend son identifiant, seul contenu du cookie. */
|
|
22
|
+
create(): string;
|
|
23
|
+
/** Vrai si l'identifiant désigne une session encore valide. */
|
|
24
|
+
has(id: string | null): boolean;
|
|
25
|
+
revoke(id: string | null): void;
|
|
26
|
+
get size(): number;
|
|
27
|
+
private prune;
|
|
28
|
+
}
|
|
29
|
+
/** Identifiant de session porté par la requête, s'il y en a un. */
|
|
30
|
+
export declare function consoleSessionId(req: Request): string | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Sessions de la console web.
|
|
4
|
+
*
|
|
5
|
+
* Le navigateur ne peut pas poser d'en-tête sur une navigation, et faire transiter le
|
|
6
|
+
* jeton dans l'URL le dépose dans l'historique et les journaux d'accès du proxy. La
|
|
7
|
+
* console s'ouvre donc sans jeton, le demande une fois dans un formulaire, et reçoit en
|
|
8
|
+
* retour un identifiant de session opaque dans un cookie `HttpOnly`.
|
|
9
|
+
*
|
|
10
|
+
* Le cookie ne porte jamais le jeton lui-même : sa fuite ne donne qu'une session, à durée
|
|
11
|
+
* limitée, que le serveur oublie au redémarrage. Il n'est honoré que sur `/admin` et
|
|
12
|
+
* `/ui` : l'API des agents reste sur le Bearer, un site tiers ne peut donc pas faire
|
|
13
|
+
* agir un agent à travers le navigateur de l'administrateur.
|
|
14
|
+
*/
|
|
15
|
+
export const CONSOLE_COOKIE = "messenger_console";
|
|
16
|
+
/** Durée de vie d'une session : assez pour une journée de supervision, pas plus. */
|
|
17
|
+
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
18
|
+
export class ConsoleSessions {
|
|
19
|
+
ttlMs;
|
|
20
|
+
now;
|
|
21
|
+
sessions = new Map();
|
|
22
|
+
constructor(ttlMs = SESSION_TTL_MS, now = Date.now) {
|
|
23
|
+
this.ttlMs = ttlMs;
|
|
24
|
+
this.now = now;
|
|
25
|
+
}
|
|
26
|
+
/** Ouvre une session et rend son identifiant, seul contenu du cookie. */
|
|
27
|
+
create() {
|
|
28
|
+
this.prune();
|
|
29
|
+
const id = randomBytes(32).toString("base64url");
|
|
30
|
+
this.sessions.set(id, { expiresAt: this.now() + this.ttlMs });
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
/** Vrai si l'identifiant désigne une session encore valide. */
|
|
34
|
+
has(id) {
|
|
35
|
+
if (!id)
|
|
36
|
+
return false;
|
|
37
|
+
// Parcours à temps constant : la Map compare ses clés en temps variable, ce qui
|
|
38
|
+
// laisserait deviner un identifiant octet par octet.
|
|
39
|
+
let match = null;
|
|
40
|
+
const presented = Buffer.from(id);
|
|
41
|
+
for (const [key, session] of this.sessions) {
|
|
42
|
+
const candidate = Buffer.from(key);
|
|
43
|
+
if (candidate.length === presented.length && timingSafeEqual(candidate, presented))
|
|
44
|
+
match = session;
|
|
45
|
+
}
|
|
46
|
+
if (!match)
|
|
47
|
+
return false;
|
|
48
|
+
if (match.expiresAt <= this.now()) {
|
|
49
|
+
this.sessions.delete(id);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
revoke(id) {
|
|
55
|
+
if (id)
|
|
56
|
+
this.sessions.delete(id);
|
|
57
|
+
}
|
|
58
|
+
get size() {
|
|
59
|
+
this.prune();
|
|
60
|
+
return this.sessions.size;
|
|
61
|
+
}
|
|
62
|
+
prune() {
|
|
63
|
+
const now = this.now();
|
|
64
|
+
for (const [id, session] of this.sessions) {
|
|
65
|
+
if (session.expiresAt <= now)
|
|
66
|
+
this.sessions.delete(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Identifiant de session porté par la requête, s'il y en a un. */
|
|
71
|
+
export function consoleSessionId(req) {
|
|
72
|
+
const header = req.headers.cookie;
|
|
73
|
+
if (!header)
|
|
74
|
+
return null;
|
|
75
|
+
for (const part of header.split(";")) {
|
|
76
|
+
const separator = part.indexOf("=");
|
|
77
|
+
if (separator < 0)
|
|
78
|
+
continue;
|
|
79
|
+
if (part.slice(0, separator).trim() !== CONSOLE_COOKIE)
|
|
80
|
+
continue;
|
|
81
|
+
const value = part.slice(separator + 1).trim();
|
|
82
|
+
return value ? decodeURIComponent(value) : null;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|