@imenam/mcp-messenger 1.0.5 → 1.0.7
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 +6 -3
- package/README.md +63 -30
- 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 +2 -2
- package/dist/src/cli/watch.js +2 -2
- package/dist/src/client/messenger-client.d.ts +2 -3
- package/dist/src/client/messenger-client.js +3 -3
- package/dist/src/config.d.ts +3 -2
- package/dist/src/config.js +2 -2
- package/dist/src/core/store.d.ts +23 -8
- package/dist/src/core/store.js +65 -42
- package/dist/src/core/types.d.ts +19 -9
- package/dist/src/core/types.js +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/mcp/tools.js +102 -61
- package/dist/src/server/app.js +88 -72
- 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 +50 -25
- package/dist/src/server/ui/index.html +5 -4
- package/dist/src/server/ui/styles.css +6 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/dist/src/core/types.d.ts
CHANGED
|
@@ -3,11 +3,22 @@ export declare const SYSTEM_FOLDERS: readonly ["inbox", "sent", "archive", "tras
|
|
|
3
3
|
export type SystemFolder = (typeof SYSTEM_FOLDERS)[number];
|
|
4
4
|
export declare const PRIORITIES: readonly ["low", "normal", "high"];
|
|
5
5
|
export type Priority = (typeof PRIORITIES)[number];
|
|
6
|
-
/** Rôle d'un agent vis-à-vis d'un message : destinataire
|
|
7
|
-
export declare const DELIVERY_ROLES: readonly ["to", "
|
|
6
|
+
/** Rôle d'un agent vis-à-vis d'un message : destinataire ou expéditeur. */
|
|
7
|
+
export declare const DELIVERY_ROLES: readonly ["to", "sender"];
|
|
8
8
|
export type DeliveryRole = (typeof DELIVERY_ROLES)[number];
|
|
9
|
+
/**
|
|
10
|
+
* Une boîte porte deux identités qui ne bougent pas au même rythme :
|
|
11
|
+
* - l'agent (`agent_name`, `agent_description`) : d'où il vient, typiquement le projet.
|
|
12
|
+
* Posé par la configuration à l'enregistrement, stable pour toute la vie de la boîte.
|
|
13
|
+
* - la boîte (`display_name`, `description`) : de quoi cette session s'occupe. Vide au
|
|
14
|
+
* départ, posé par l'agent lui-même (`rename_mailbox`), et libre de changer.
|
|
15
|
+
* Le nom de l'agent seul ne distingue pas deux sessions du même projet ; le nom de la boîte
|
|
16
|
+
* seul ne dit pas où l'agent travaille. La console et l'annuaire montrent les deux.
|
|
17
|
+
*/
|
|
9
18
|
export interface Agent {
|
|
10
19
|
id: string;
|
|
20
|
+
agent_name: string | null;
|
|
21
|
+
agent_description: string | null;
|
|
11
22
|
display_name: string | null;
|
|
12
23
|
description: string | null;
|
|
13
24
|
created_at: string;
|
|
@@ -26,8 +37,8 @@ export interface AgentDirectoryEntry extends Agent {
|
|
|
26
37
|
}
|
|
27
38
|
export interface SendMessageInput {
|
|
28
39
|
from: string;
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
/** Un échange lie exactement deux boîtes : un seul destinataire par message. */
|
|
41
|
+
to: string;
|
|
31
42
|
subject: string;
|
|
32
43
|
body: string;
|
|
33
44
|
priority?: Priority;
|
|
@@ -67,9 +78,9 @@ export interface SendMessageResult {
|
|
|
67
78
|
/** Titre du fil après envoi, null s'il n'a jamais été nommé. */
|
|
68
79
|
thread_title: string | null;
|
|
69
80
|
created_at: string;
|
|
70
|
-
delivered_to: string
|
|
71
|
-
/**
|
|
72
|
-
|
|
81
|
+
delivered_to: string;
|
|
82
|
+
/** Destinataire jamais enregistré sur ce serveur — le message l'attend malgré tout. */
|
|
83
|
+
unknown_recipient: boolean;
|
|
73
84
|
}
|
|
74
85
|
export interface MessageSummary {
|
|
75
86
|
message_id: string;
|
|
@@ -77,8 +88,7 @@ export interface MessageSummary {
|
|
|
77
88
|
/** Titre du fil, null tant que personne ne l'a nommé — le `subject` fait alors office. */
|
|
78
89
|
thread_title: string | null;
|
|
79
90
|
from: string;
|
|
80
|
-
to: string
|
|
81
|
-
cc: string[];
|
|
91
|
+
to: string;
|
|
82
92
|
subject: string;
|
|
83
93
|
preview: string;
|
|
84
94
|
priority: Priority;
|
package/dist/src/core/types.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Dossiers présents pour tout agent : ils ne peuvent être ni créés ni supprimés. */
|
|
2
2
|
export const SYSTEM_FOLDERS = ["inbox", "sent", "archive", "trash"];
|
|
3
3
|
export const PRIORITIES = ["low", "normal", "high"];
|
|
4
|
-
/** Rôle d'un agent vis-à-vis d'un message : destinataire
|
|
5
|
-
export const DELIVERY_ROLES = ["to", "
|
|
4
|
+
/** Rôle d'un agent vis-à-vis d'un message : destinataire ou expéditeur. */
|
|
5
|
+
export const DELIVERY_ROLES = ["to", "sender"];
|
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,24 +25,37 @@ export const TOOLS = [
|
|
|
25
25
|
},
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
|
-
name: "
|
|
29
|
-
description: "Give a plain-language name to this mailbox, or
|
|
28
|
+
name: "rename_mailbox",
|
|
29
|
+
description: "Give a plain-language name to this mailbox, or change it: what this session is working on, e.g. 'Développement des tests unitaires du parser'. Addresses are opaque: the name is what makes this session recognisable later, both for the other agents (list_agents) and for the user reading the web console. It does not touch the agent name, which says where the agent comes from (its project) and is set by the configuration.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
name: { 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 what this mailbox is for." },
|
|
35
|
+
},
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "rename_thread",
|
|
41
|
+
description: "Give a plain-language name to a thread, or change it, e.g. '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. Use it when a thread was opened without a name, or when the discussion has drifted away from its original name.",
|
|
30
42
|
inputSchema: {
|
|
31
43
|
type: "object",
|
|
32
44
|
properties: {
|
|
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
45
|
thread_id: {
|
|
36
46
|
type: "string",
|
|
37
|
-
description: "Thread to
|
|
47
|
+
description: "Thread to rename. You must be a participant of the thread.",
|
|
38
48
|
},
|
|
49
|
+
name: { type: "string", description: "Short name, one line. Say what the thread is about." },
|
|
50
|
+
description: { type: "string", description: "Optional longer explanation of the thread." },
|
|
39
51
|
},
|
|
52
|
+
required: ["thread_id"],
|
|
40
53
|
additionalProperties: false,
|
|
41
54
|
},
|
|
42
55
|
},
|
|
43
56
|
{
|
|
44
57
|
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
|
|
58
|
+
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 rename_thread.",
|
|
46
59
|
inputSchema: {
|
|
47
60
|
type: "object",
|
|
48
61
|
properties: {
|
|
@@ -55,29 +68,23 @@ export const TOOLS = [
|
|
|
55
68
|
},
|
|
56
69
|
{
|
|
57
70
|
name: "list_agents",
|
|
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.",
|
|
71
|
+
description: "List the other agents known to the mailbox server, most recently active first, with their name and description. Your own mailbox is never listed — you cannot write to yourself. Use it to find the address of the agent you want to write to.",
|
|
59
72
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
60
73
|
},
|
|
61
74
|
{
|
|
62
75
|
name: "send_message",
|
|
63
|
-
description: "Send a message to one
|
|
76
|
+
description: "Send a message to one agent, identified by its mailbox address. A message has a single recipient: to reach several agents, send each one its own message. Set reply_to to answer an existing message and keep the thread together.",
|
|
64
77
|
inputSchema: {
|
|
65
78
|
type: "object",
|
|
66
79
|
properties: {
|
|
67
|
-
to: {
|
|
68
|
-
type: "array",
|
|
69
|
-
items: { type: "string" },
|
|
70
|
-
minItems: 1,
|
|
71
|
-
description: "Recipient agent ids (mailbox addresses).",
|
|
72
|
-
},
|
|
73
|
-
cc: { type: "array", items: { type: "string" }, description: "Agents to keep in copy." },
|
|
80
|
+
to: { type: "string", description: "Recipient agent id (mailbox address). Exactly one recipient." },
|
|
74
81
|
subject: { type: "string", description: "Short subject line." },
|
|
75
82
|
body: { type: "string", description: "Message body, plain text or markdown." },
|
|
76
83
|
priority: { type: "string", enum: [...PRIORITIES], description: "Defaults to normal." },
|
|
77
84
|
reply_to: { type: "string", description: "Id of the message being answered; the reply joins its thread." },
|
|
78
|
-
|
|
85
|
+
thread_name: {
|
|
79
86
|
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
|
|
87
|
+
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 (same effect as rename_thread).",
|
|
81
88
|
},
|
|
82
89
|
thread_description: { type: "string", description: "Optional longer description of the thread." },
|
|
83
90
|
},
|
|
@@ -210,13 +217,12 @@ export const TOOLS = [
|
|
|
210
217
|
];
|
|
211
218
|
const schemas = {
|
|
212
219
|
send_message: z.object({
|
|
213
|
-
to: z.
|
|
214
|
-
cc: z.union([z.string(), z.array(z.string())]).optional(),
|
|
220
|
+
to: z.string().min(1),
|
|
215
221
|
subject: z.string().min(1),
|
|
216
222
|
body: z.string().min(1),
|
|
217
223
|
priority: z.enum(PRIORITIES).optional(),
|
|
218
224
|
reply_to: z.string().optional(),
|
|
219
|
-
|
|
225
|
+
thread_name: z.string().trim().min(1).max(200).optional(),
|
|
220
226
|
thread_description: z.string().trim().min(1).max(2000).optional(),
|
|
221
227
|
}),
|
|
222
228
|
list_messages: z.object({
|
|
@@ -238,6 +244,24 @@ const schemas = {
|
|
|
238
244
|
move_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), folder: z.string().min(1) }),
|
|
239
245
|
delete_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), permanent: z.boolean().optional() }),
|
|
240
246
|
attach_to_mailbox: z.object({ mailbox_id: z.string().trim().min(1).optional() }),
|
|
247
|
+
rename_mailbox: z
|
|
248
|
+
.object({
|
|
249
|
+
name: z.string().trim().min(1).max(200).optional(),
|
|
250
|
+
description: z.string().trim().min(1).max(2000).optional(),
|
|
251
|
+
})
|
|
252
|
+
.refine((v) => v.name !== undefined || v.description !== undefined, {
|
|
253
|
+
message: "provide `name` and/or `description`",
|
|
254
|
+
}),
|
|
255
|
+
rename_thread: z
|
|
256
|
+
.object({
|
|
257
|
+
thread_id: z.string().trim().min(1),
|
|
258
|
+
name: z.string().trim().min(1).max(200).optional(),
|
|
259
|
+
description: z.string().trim().min(1).max(2000).optional(),
|
|
260
|
+
})
|
|
261
|
+
.refine((v) => v.name !== undefined || v.description !== undefined, {
|
|
262
|
+
message: "provide `name` and/or `description`",
|
|
263
|
+
}),
|
|
264
|
+
// Ancien outil `set_label` : une seule entrée pour la boîte et le fil, `label` au lieu de `name`.
|
|
241
265
|
set_label: z
|
|
242
266
|
.object({
|
|
243
267
|
label: z.string().trim().min(1).max(200).optional(),
|
|
@@ -273,9 +297,10 @@ export async function callTool(client, name, rawArgs) {
|
|
|
273
297
|
const lines = [
|
|
274
298
|
`Mailbox address: ${client.agentId}`,
|
|
275
299
|
" ↳ give this address to another agent so it can write to you.",
|
|
300
|
+
`Agent: ${self?.agent_name ?? "unnamed (set MESSENGER_AGENT_NAME in the MCP configuration)"}`,
|
|
276
301
|
self?.display_name
|
|
277
302
|
? `Mailbox name: ${self.display_name}`
|
|
278
|
-
: "Mailbox name: none — call
|
|
303
|
+
: "Mailbox name: none — call rename_mailbox so the user can tell this session apart from the others.",
|
|
279
304
|
];
|
|
280
305
|
if (client.isAttachedElsewhere) {
|
|
281
306
|
lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
|
|
@@ -303,8 +328,9 @@ export async function callTool(client, name, rawArgs) {
|
|
|
303
328
|
client.setAgentId(target.id);
|
|
304
329
|
const attachedStats = await client.stats();
|
|
305
330
|
return [
|
|
306
|
-
`Attached to mailbox ${target.id}${target
|
|
307
|
-
target.
|
|
331
|
+
`Attached to mailbox ${target.id}${formatAgentNames(target) ? ` (${formatAgentNames(target)})` : ""}.`,
|
|
332
|
+
target.agent_description ? `Agent: ${target.agent_description}` : null,
|
|
333
|
+
target.description ? `Mailbox: ${target.description}` : null,
|
|
308
334
|
`Unread (excluding trash): ${attachedStats.unread_total}`,
|
|
309
335
|
client.sessionAgentId && client.sessionAgentId !== target.id
|
|
310
336
|
? `This session's own mailbox remains ${client.sessionAgentId} — attach_to_mailbox with no argument goes back to it.`
|
|
@@ -313,29 +339,24 @@ export async function callTool(client, name, rawArgs) {
|
|
|
313
339
|
.filter(Boolean)
|
|
314
340
|
.join("\n");
|
|
315
341
|
}
|
|
316
|
-
|
|
317
|
-
|
|
342
|
+
case "rename_mailbox": {
|
|
343
|
+
const input = schemas.rename_mailbox.parse(args);
|
|
344
|
+
return renameMailbox(client, input.name ?? null, input.description ?? null);
|
|
345
|
+
}
|
|
346
|
+
case "rename_thread": {
|
|
347
|
+
const input = schemas.rename_thread.parse(args);
|
|
348
|
+
return renameThread(client, input.thread_id, input.name ?? null, input.description ?? null);
|
|
349
|
+
}
|
|
350
|
+
// `set_label` et `set_mailbox_label` ne sont plus publiés, mais restent acceptés : une
|
|
351
|
+
// session en cours peut les avoir appris avant la mise à jour du serveur. Le mot « label »
|
|
352
|
+
// faisait penser à une étiquette, alors qu'il s'agissait du nom.
|
|
318
353
|
case "set_mailbox_label":
|
|
319
354
|
case "set_label": {
|
|
320
355
|
const input = schemas.set_label.parse(args);
|
|
321
356
|
if (input.thread_id) {
|
|
322
|
-
|
|
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");
|
|
357
|
+
return renameThread(client, input.thread_id, input.label ?? null, input.description ?? null);
|
|
333
358
|
}
|
|
334
|
-
|
|
335
|
-
display_name: input.label ?? null,
|
|
336
|
-
description: input.description ?? null,
|
|
337
|
-
});
|
|
338
|
-
return `Mailbox ${agent.id} named "${agent.display_name}".`;
|
|
359
|
+
return renameMailbox(client, input.label ?? null, input.description ?? null);
|
|
339
360
|
}
|
|
340
361
|
case "list_threads": {
|
|
341
362
|
const input = schemas.list_threads.parse(args);
|
|
@@ -347,41 +368,44 @@ export async function callTool(client, name, rawArgs) {
|
|
|
347
368
|
case "list_agents": {
|
|
348
369
|
const agents = await client.listAgents();
|
|
349
370
|
if (agents.length === 0)
|
|
350
|
-
return "No agent
|
|
371
|
+
return "No other agent on this server yet.";
|
|
351
372
|
return agents
|
|
352
373
|
.map((agent) => {
|
|
353
|
-
const flags =
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const title = agent.display_name ? `${agent.id} — ${agent.display_name}` : agent.id;
|
|
374
|
+
const flags = agent.registered ? "" : "never connected";
|
|
375
|
+
const names = formatAgentNames(agent);
|
|
376
|
+
const title = names ? `${agent.id} — ${names}` : agent.id;
|
|
357
377
|
// L'annuaire est classé par cette date : l'afficher rend l'ordre lisible.
|
|
358
378
|
const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
|
|
359
|
-
return [
|
|
379
|
+
return [
|
|
380
|
+
`• ${title}${flags ? ` (${flags})` : ""}`,
|
|
381
|
+
` ${agent.agent_description ?? agent.description ?? "no description"}`,
|
|
382
|
+
` ${seen}`,
|
|
383
|
+
].join("\n");
|
|
360
384
|
})
|
|
361
385
|
.join("\n");
|
|
362
386
|
}
|
|
363
387
|
case "send_message": {
|
|
364
388
|
const input = schemas.send_message.parse(args);
|
|
365
389
|
const result = await client.sendMessage({
|
|
366
|
-
to:
|
|
367
|
-
cc: asArray(input.cc),
|
|
390
|
+
to: input.to,
|
|
368
391
|
subject: input.subject,
|
|
369
392
|
body: input.body,
|
|
370
393
|
priority: input.priority,
|
|
371
394
|
reply_to: input.reply_to ?? null,
|
|
372
|
-
|
|
395
|
+
// Le protocole HTTP et le stockage gardent `thread_title` ; seul l'outil dit `thread_name`.
|
|
396
|
+
thread_title: input.thread_name ?? null,
|
|
373
397
|
thread_description: input.thread_description ?? null,
|
|
374
398
|
});
|
|
375
399
|
const lines = [
|
|
376
400
|
`Message sent: ${result.message_id}`,
|
|
377
401
|
`Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
|
|
378
|
-
`Delivered to: ${result.delivered_to
|
|
402
|
+
`Delivered to: ${result.delivered_to}`,
|
|
379
403
|
];
|
|
380
404
|
if (!result.thread_title && !input.reply_to) {
|
|
381
|
-
lines.push("This thread has no name yet —
|
|
405
|
+
lines.push("This thread has no name yet — rename_thread with its thread_id makes it readable for everyone.");
|
|
382
406
|
}
|
|
383
|
-
if (result.
|
|
384
|
-
lines.push(`Warning — never connected to this server, the message will wait in their mailbox
|
|
407
|
+
if (result.unknown_recipient) {
|
|
408
|
+
lines.push(`Warning — ${result.delivered_to} has never connected to this server, the message will wait in their mailbox.`);
|
|
385
409
|
}
|
|
386
410
|
return lines.join("\n");
|
|
387
411
|
}
|
|
@@ -497,10 +521,28 @@ export async function callTool(client, name, rawArgs) {
|
|
|
497
521
|
throw new Error(`Unknown tool: ${name}`);
|
|
498
522
|
}
|
|
499
523
|
}
|
|
500
|
-
function
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
524
|
+
async function renameMailbox(client, name, description) {
|
|
525
|
+
const agent = await client.updateProfile({ display_name: name, description });
|
|
526
|
+
return [
|
|
527
|
+
`Mailbox ${agent.id} named "${agent.display_name}".`,
|
|
528
|
+
agent.agent_name ? `The agent name stays "${agent.agent_name}"; both are shown together.` : null,
|
|
529
|
+
]
|
|
530
|
+
.filter(Boolean)
|
|
531
|
+
.join("\n");
|
|
532
|
+
}
|
|
533
|
+
/** `Agent · Boîte` : l'agent dit d'où l'on écrit, la boîte de quoi il est question. */
|
|
534
|
+
function formatAgentNames(agent) {
|
|
535
|
+
return [agent.agent_name, agent.display_name].filter(Boolean).join(" · ");
|
|
536
|
+
}
|
|
537
|
+
async function renameThread(client, threadId, name, description) {
|
|
538
|
+
const thread = await client.setThreadLabel(threadId, { title: name, description });
|
|
539
|
+
return [
|
|
540
|
+
`Thread ${thread.thread_id} is now named "${thread.title}".`,
|
|
541
|
+
thread.description ? `Description: ${thread.description}` : null,
|
|
542
|
+
"Every participant of the thread sees this name.",
|
|
543
|
+
]
|
|
544
|
+
.filter(Boolean)
|
|
545
|
+
.join("\n");
|
|
504
546
|
}
|
|
505
547
|
/** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
|
|
506
548
|
function formatThreadRef(title, threadId) {
|
|
@@ -523,7 +565,7 @@ function formatSummary(message) {
|
|
|
523
565
|
const priority = message.priority === "normal" ? "" : ` [${message.priority}]`;
|
|
524
566
|
return [
|
|
525
567
|
`${flags} ${message.subject}${priority}`,
|
|
526
|
-
` from ${message.from} to ${message.to
|
|
568
|
+
` from ${message.from} to ${message.to || "—"} · ${message.created_at}`,
|
|
527
569
|
` ${message.preview}`,
|
|
528
570
|
` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
|
|
529
571
|
].join("\n");
|
|
@@ -532,8 +574,7 @@ function formatMessage(message) {
|
|
|
532
574
|
const header = [
|
|
533
575
|
`Subject: ${message.subject}`,
|
|
534
576
|
`From: ${message.from}`,
|
|
535
|
-
`To: ${message.to
|
|
536
|
-
message.cc.length ? `Cc: ${message.cc.join(", ")}` : null,
|
|
577
|
+
`To: ${message.to || "—"}`,
|
|
537
578
|
`Date: ${message.created_at}`,
|
|
538
579
|
`Priority: ${message.priority}`,
|
|
539
580
|
`Folder: ${message.folder}${message.read ? "" : " (unread)"}${message.starred ? " ★" : ""}`,
|
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,20 +8,26 @@ 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");
|
|
15
15
|
const agentIdSchema = z.string().trim().min(1).max(128);
|
|
16
16
|
const idListSchema = z.array(z.string().trim().min(1)).min(1).max(200);
|
|
17
|
-
|
|
17
|
+
/** Nom et description de la boîte : ce que l'agent pose lui-même sur sa session. */
|
|
18
|
+
const mailboxProfileSchema = z.object({
|
|
18
19
|
display_name: z.string().trim().max(200).nullish(),
|
|
19
20
|
description: z.string().trim().max(2000).nullish(),
|
|
21
|
+
});
|
|
22
|
+
const registerSchema = mailboxProfileSchema.extend({
|
|
23
|
+
/** Identité de l'agent (le projet, typiquement) : vient de la configuration, pas de l'agent. */
|
|
24
|
+
agent_name: z.string().trim().max(200).nullish(),
|
|
25
|
+
agent_description: z.string().trim().max(2000).nullish(),
|
|
20
26
|
/** true = l'adresse doit être inconnue du serveur (allocation d'une boîte neuve). */
|
|
21
27
|
fresh: z.boolean().optional(),
|
|
22
28
|
});
|
|
23
29
|
const sendSchema = z.object({
|
|
24
|
-
to:
|
|
25
|
-
cc: z.union([agentIdSchema, z.array(agentIdSchema)]).optional(),
|
|
30
|
+
to: agentIdSchema,
|
|
26
31
|
subject: z.string().min(1).max(500),
|
|
27
32
|
body: z.string().min(1).max(200_000),
|
|
28
33
|
priority: z.enum(PRIORITIES).optional(),
|
|
@@ -72,6 +77,7 @@ const waitQuerySchema = z.object({
|
|
|
72
77
|
// attente longue, mais seul le premier prouve que l'agent est joignable en continu.
|
|
73
78
|
watcher: z.enum(["true", "1"]).optional(),
|
|
74
79
|
});
|
|
80
|
+
const loginSchema = z.object({ token: z.string().min(1).max(1024) });
|
|
75
81
|
const adminListSchema = z.object({
|
|
76
82
|
search: z.string().trim().min(1).optional(),
|
|
77
83
|
agent: z.string().trim().min(1).optional(),
|
|
@@ -106,8 +112,65 @@ export function createApp({ store, config }) {
|
|
|
106
112
|
standardHeaders: "draft-8",
|
|
107
113
|
legacyHeaders: false,
|
|
108
114
|
}));
|
|
109
|
-
//
|
|
110
|
-
//
|
|
115
|
+
// --- Console web ----------------------------------------------------------
|
|
116
|
+
// La page et ses fichiers ne contiennent aucun secret : ils sont servis à tous. Ce sont
|
|
117
|
+
// les données (`/admin`) qui sont protégées, par une session ouverte contre le jeton
|
|
118
|
+
// admin. Voir `console-session.ts` pour le raisonnement.
|
|
119
|
+
const sessions = new ConsoleSessions();
|
|
120
|
+
const cookieOptions = (req) => ({
|
|
121
|
+
httpOnly: true,
|
|
122
|
+
sameSite: "strict",
|
|
123
|
+
// `req.secure` n'est vrai derrière un proxy TLS que si `trust proxy` est configuré.
|
|
124
|
+
secure: req.secure,
|
|
125
|
+
path: "/",
|
|
126
|
+
});
|
|
127
|
+
// Limite propre au formulaire : le jeton fait 256 bits, mais autant fermer la porte.
|
|
128
|
+
const loginLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 10, standardHeaders: "draft-8", legacyHeaders: false });
|
|
129
|
+
app.post("/ui/login", loginLimiter, (req, res) => {
|
|
130
|
+
const input = loginSchema.parse(req.body ?? {});
|
|
131
|
+
if (!safeEqual(input.token, config.adminToken)) {
|
|
132
|
+
res.status(401).json({ error: "invalid admin token" });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
res.cookie(CONSOLE_COOKIE, sessions.create(), { ...cookieOptions(req), maxAge: sessions.ttlMs });
|
|
136
|
+
res.status(204).end();
|
|
137
|
+
});
|
|
138
|
+
app.post("/ui/logout", (req, res) => {
|
|
139
|
+
sessions.revoke(consoleSessionId(req));
|
|
140
|
+
res.clearCookie(CONSOLE_COOKIE, cookieOptions(req));
|
|
141
|
+
res.status(204).end();
|
|
142
|
+
});
|
|
143
|
+
// `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
|
|
144
|
+
// sans laisser un navigateur servir l'ancienne version depuis son cache.
|
|
145
|
+
app.use("/ui", express.static(UI_DIR, { index: "index.html", maxAge: 0, etag: true }));
|
|
146
|
+
app.get("/", (_req, res) => {
|
|
147
|
+
res.redirect("/ui/");
|
|
148
|
+
});
|
|
149
|
+
// --- Supervision (lecture seule) -----------------------------------------
|
|
150
|
+
// Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
|
|
151
|
+
// console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
|
|
152
|
+
const admin = express.Router();
|
|
153
|
+
admin.use(authenticateAdmin(config, sessions));
|
|
154
|
+
admin.get("/overview", (_req, res) => {
|
|
155
|
+
res.json(store.overview());
|
|
156
|
+
});
|
|
157
|
+
admin.get("/messages", (req, res) => {
|
|
158
|
+
const query = adminListSchema.parse(req.query);
|
|
159
|
+
res.json({ messages: store.listAllMessages(query) });
|
|
160
|
+
});
|
|
161
|
+
admin.get("/messages/:id", (req, res) => {
|
|
162
|
+
res.json(store.getMessageAdmin(String(req.params.id)));
|
|
163
|
+
});
|
|
164
|
+
admin.get("/threads/:id", (req, res) => {
|
|
165
|
+
const threadId = String(req.params.id);
|
|
166
|
+
const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
|
|
167
|
+
if (messages.length === 0)
|
|
168
|
+
throw new MessengerError(`thread not found: ${threadId}`, 404);
|
|
169
|
+
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
|
|
170
|
+
});
|
|
171
|
+
app.use("/admin", admin);
|
|
172
|
+
// Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
|
|
173
|
+
// contrôle par route (identité de l'agent) vient ensuite.
|
|
111
174
|
app.use(requireAnyToken(config));
|
|
112
175
|
// --- État du service ------------------------------------------------------
|
|
113
176
|
app.get("/health", (_req, res) => {
|
|
@@ -133,16 +196,21 @@ export function createApp({ store, config }) {
|
|
|
133
196
|
}
|
|
134
197
|
const agent = store.ensureAgent(req.agentId, {
|
|
135
198
|
registered: true,
|
|
199
|
+
agent_name: input.agent_name ?? null,
|
|
200
|
+
agent_description: input.agent_description ?? null,
|
|
201
|
+
// Un client antérieur à la séparation agent / boîte n'envoie que ces deux-là.
|
|
136
202
|
display_name: input.display_name ?? null,
|
|
137
203
|
description: input.description ?? null,
|
|
138
204
|
});
|
|
139
205
|
res.json({ agent });
|
|
140
206
|
});
|
|
141
|
-
|
|
142
|
-
|
|
207
|
+
// L'agent ne se voit pas dans son propre annuaire : sa boîte est la seule interdite d'envoi.
|
|
208
|
+
api.get("/agents", (req, res) => {
|
|
209
|
+
res.json({ agents: store.listAgents(req.agentId) });
|
|
143
210
|
});
|
|
211
|
+
// Ne touche qu'à la boîte : l'identité de l'agent ne se renomme pas depuis une session.
|
|
144
212
|
api.patch("/agents/me", (req, res) => {
|
|
145
|
-
const input =
|
|
213
|
+
const input = mailboxProfileSchema.parse(req.body ?? {});
|
|
146
214
|
res.json({
|
|
147
215
|
agent: store.ensureAgent(req.agentId, {
|
|
148
216
|
registered: true,
|
|
@@ -164,8 +232,7 @@ export function createApp({ store, config }) {
|
|
|
164
232
|
const input = sendSchema.parse(req.body ?? {});
|
|
165
233
|
const result = store.sendMessage({
|
|
166
234
|
from: req.agentId,
|
|
167
|
-
to:
|
|
168
|
-
cc: toArray(input.cc),
|
|
235
|
+
to: input.to,
|
|
169
236
|
subject: input.subject,
|
|
170
237
|
body: input.body,
|
|
171
238
|
priority: input.priority,
|
|
@@ -173,7 +240,7 @@ export function createApp({ store, config }) {
|
|
|
173
240
|
thread_title: input.thread_title ?? null,
|
|
174
241
|
thread_description: input.thread_description ?? null,
|
|
175
242
|
});
|
|
176
|
-
logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to
|
|
243
|
+
logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to}`);
|
|
177
244
|
res.status(201).json(result);
|
|
178
245
|
});
|
|
179
246
|
api.get("/messages", (req, res) => {
|
|
@@ -260,53 +327,6 @@ export function createApp({ store, config }) {
|
|
|
260
327
|
req.once("close", onClose);
|
|
261
328
|
});
|
|
262
329
|
app.use("/api", api);
|
|
263
|
-
// --- Supervision (lecture seule) -----------------------------------------
|
|
264
|
-
const admin = express.Router();
|
|
265
|
-
admin.use(authenticateAdmin(config));
|
|
266
|
-
admin.get("/overview", (_req, res) => {
|
|
267
|
-
res.json(store.overview());
|
|
268
|
-
});
|
|
269
|
-
admin.get("/messages", (req, res) => {
|
|
270
|
-
const query = adminListSchema.parse(req.query);
|
|
271
|
-
res.json({ messages: store.listAllMessages(query) });
|
|
272
|
-
});
|
|
273
|
-
admin.get("/messages/:id", (req, res) => {
|
|
274
|
-
res.json(store.getMessageAdmin(String(req.params.id)));
|
|
275
|
-
});
|
|
276
|
-
admin.get("/threads/:id", (req, res) => {
|
|
277
|
-
const threadId = String(req.params.id);
|
|
278
|
-
const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
|
|
279
|
-
if (messages.length === 0)
|
|
280
|
-
throw new MessengerError(`thread not found: ${threadId}`, 404);
|
|
281
|
-
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
|
|
282
|
-
});
|
|
283
|
-
app.use("/admin", admin);
|
|
284
|
-
// --- Interface web --------------------------------------------------------
|
|
285
|
-
// Un navigateur ne peut pas poser d'en-tête sur une navigation : la console s'ouvre
|
|
286
|
-
// donc avec `?token=`. La page est servie avec ce jeton propagé sur ses ressources,
|
|
287
|
-
// sans quoi le CSS et le script seraient refusés par la porte d'entrée.
|
|
288
|
-
app.get(["/ui", "/ui/", "/ui/index.html"], (req, res, next) => {
|
|
289
|
-
const token = presentedToken(req);
|
|
290
|
-
if (!token) {
|
|
291
|
-
next();
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
fs.readFile(path.join(UI_DIR, "index.html"), "utf-8", (error, html) => {
|
|
295
|
-
if (error) {
|
|
296
|
-
next(error);
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
const suffix = `?token=${encodeURIComponent(token)}`;
|
|
300
|
-
res.type("html").send(html.replaceAll("./styles.css", `./styles.css${suffix}`).replaceAll("./app.js", `./app.js${suffix}`));
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
// `maxAge: 0` + ETag : la console reflète immédiatement une mise à jour du paquet,
|
|
304
|
-
// sans laisser un navigateur servir l'ancienne version depuis son cache.
|
|
305
|
-
app.use("/ui", express.static(UI_DIR, { index: false, maxAge: 0, etag: true }));
|
|
306
|
-
app.get("/", (req, res) => {
|
|
307
|
-
const token = presentedToken(req);
|
|
308
|
-
res.redirect(token ? `/ui/?token=${encodeURIComponent(token)}` : "/ui/");
|
|
309
|
-
});
|
|
310
330
|
// --- Erreurs --------------------------------------------------------------
|
|
311
331
|
app.use((_req, res) => {
|
|
312
332
|
res.status(404).json({ error: "not found" });
|
|
@@ -326,11 +346,6 @@ export function createApp({ store, config }) {
|
|
|
326
346
|
});
|
|
327
347
|
return app;
|
|
328
348
|
}
|
|
329
|
-
function toArray(value) {
|
|
330
|
-
if (value === undefined)
|
|
331
|
-
return [];
|
|
332
|
-
return Array.isArray(value) ? value : [value];
|
|
333
|
-
}
|
|
334
349
|
/**
|
|
335
350
|
* Exige un jeton connu du serveur sur toute requête entrante.
|
|
336
351
|
*
|
|
@@ -379,8 +394,13 @@ function authenticateAgent(config) {
|
|
|
379
394
|
next();
|
|
380
395
|
};
|
|
381
396
|
}
|
|
382
|
-
|
|
397
|
+
/** Session de console (navigateur) ou jeton admin (script, `curl`). */
|
|
398
|
+
function authenticateAdmin(config, sessions) {
|
|
383
399
|
return (req, res, next) => {
|
|
400
|
+
if (sessions.has(consoleSessionId(req))) {
|
|
401
|
+
next();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
384
404
|
const presented = presentedToken(req);
|
|
385
405
|
if (!presented || !safeEqual(presented, config.adminToken)) {
|
|
386
406
|
res.status(401).json({ error: "invalid or missing admin token" });
|
|
@@ -390,19 +410,15 @@ function authenticateAdmin(config) {
|
|
|
390
410
|
};
|
|
391
411
|
}
|
|
392
412
|
/**
|
|
393
|
-
* Jeton présenté par l'appelant
|
|
394
|
-
* en
|
|
395
|
-
* navigation navigateur vers la console, qui ne peut pas poser d'en-tête.
|
|
413
|
+
* Jeton présenté par l'appelant : en-tête `Authorization`, sinon `X-Messenger-Token`.
|
|
414
|
+
* Jamais l'URL : un jeton en query string finit dans les journaux d'accès du proxy.
|
|
396
415
|
*/
|
|
397
416
|
function presentedToken(req) {
|
|
398
417
|
const header = req.header("authorization");
|
|
399
418
|
if (header?.toLowerCase().startsWith("bearer "))
|
|
400
419
|
return header.slice(7).trim() || null;
|
|
401
420
|
const alt = req.header("x-messenger-token");
|
|
402
|
-
|
|
403
|
-
return alt.trim();
|
|
404
|
-
const query = req.query.token;
|
|
405
|
-
return typeof query === "string" && query.trim() ? query.trim() : null;
|
|
421
|
+
return alt?.trim() ? alt.trim() : null;
|
|
406
422
|
}
|
|
407
423
|
/** Comparaison à temps constant : évite de laisser fuiter le jeton octet par octet. */
|
|
408
424
|
function safeEqual(a, b) {
|