@imenam/mcp-messenger 1.0.3 → 1.0.5
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 +61 -13
- package/dist/src/cli/watch.js +4 -2
- package/dist/src/client/messenger-client.d.ts +26 -4
- package/dist/src/client/messenger-client.js +17 -4
- package/dist/src/core/store.d.ts +22 -3
- package/dist/src/core/store.js +201 -13
- package/dist/src/core/types.d.ts +63 -0
- package/dist/src/mcp/tools.js +126 -18
- package/dist/src/server/app.js +46 -6
- package/dist/src/server/ui/app.js +27 -7
- package/dist/src/server/ui/styles.css +20 -1
- package/dist/src/server/watchers.d.ts +10 -0
- package/dist/src/server/watchers.js +69 -0
- 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;
|
|
@@ -67,8 +115,23 @@ export interface ListMessagesOptions {
|
|
|
67
115
|
limit?: number;
|
|
68
116
|
offset?: number;
|
|
69
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Présence d'un veilleur (`--watch`) sur une boîte, telle que la voit le serveur.
|
|
120
|
+
*
|
|
121
|
+
* `armed: false` veut dire que personne n'écoute : les messages arriveront en boîte,
|
|
122
|
+
* mais aucun agent n'en sera averti tant qu'il ne regarde pas de lui-même.
|
|
123
|
+
*/
|
|
124
|
+
export interface WatcherStatus {
|
|
125
|
+
armed: boolean;
|
|
126
|
+
/** Depuis quand ce veilleur est en place, ou null s'il n'y en a pas. */
|
|
127
|
+
since: string | null;
|
|
128
|
+
/** Dernière attente observée, y compris pour un veilleur qui vient de disparaître. */
|
|
129
|
+
last_seen: string | null;
|
|
130
|
+
}
|
|
70
131
|
export interface MailboxStats {
|
|
71
132
|
agent_id: string;
|
|
72
133
|
unread_total: number;
|
|
73
134
|
folders: FolderInfo[];
|
|
135
|
+
/** Absent des réponses du store : ajouté par le serveur, qui seul voit les connexions. */
|
|
136
|
+
watcher?: WatcherStatus;
|
|
74
137
|
}
|
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,14 +269,23 @@ 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}.`);
|
|
242
282
|
}
|
|
243
|
-
lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`,
|
|
283
|
+
lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`,
|
|
284
|
+
// Sans veilleur, un message arrive en boîte sans que personne n'en soit averti.
|
|
285
|
+
// Le dire ici est le seul moyen pour l'agent de s'en apercevoir.
|
|
286
|
+
stats.watcher?.armed
|
|
287
|
+
? `Notifications: armed — incoming messages will interrupt you (watcher since ${stats.watcher.since}).`
|
|
288
|
+
: "Notifications: NOT armed — no incoming message will reach you. Call enable_notifications.", `Unread (excluding trash): ${stats.unread_total}`, "Folders:", ...folders);
|
|
244
289
|
return lines.join("\n");
|
|
245
290
|
}
|
|
246
291
|
case "attach_to_mailbox": {
|
|
@@ -268,10 +313,36 @@ export async function callTool(client, name, rawArgs) {
|
|
|
268
313
|
.filter(Boolean)
|
|
269
314
|
.join("\n");
|
|
270
315
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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");
|
|
275
346
|
}
|
|
276
347
|
case "list_agents": {
|
|
277
348
|
const agents = await client.listAgents();
|
|
@@ -283,7 +354,8 @@ export async function callTool(client, name, rawArgs) {
|
|
|
283
354
|
.filter(Boolean)
|
|
284
355
|
.join(", ");
|
|
285
356
|
const title = agent.display_name ? `${agent.id} — ${agent.display_name}` : agent.id;
|
|
286
|
-
|
|
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";
|
|
287
359
|
return [`• ${title}${flags ? ` (${flags})` : ""}`, ` ${agent.description ?? "no description"}`, ` ${seen}`].join("\n");
|
|
288
360
|
})
|
|
289
361
|
.join("\n");
|
|
@@ -297,12 +369,17 @@ export async function callTool(client, name, rawArgs) {
|
|
|
297
369
|
body: input.body,
|
|
298
370
|
priority: input.priority,
|
|
299
371
|
reply_to: input.reply_to ?? null,
|
|
372
|
+
thread_title: input.thread_title ?? null,
|
|
373
|
+
thread_description: input.thread_description ?? null,
|
|
300
374
|
});
|
|
301
375
|
const lines = [
|
|
302
376
|
`Message sent: ${result.message_id}`,
|
|
303
|
-
`Thread: ${result.thread_id}`,
|
|
377
|
+
`Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
|
|
304
378
|
`Delivered to: ${result.delivered_to.join(", ")}`,
|
|
305
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
|
+
}
|
|
306
383
|
if (result.unknown_recipients.length > 0) {
|
|
307
384
|
lines.push(`Warning — never connected to this server, the message will wait in their mailbox: ${result.unknown_recipients.join(", ")}`);
|
|
308
385
|
}
|
|
@@ -323,8 +400,14 @@ export async function callTool(client, name, rawArgs) {
|
|
|
323
400
|
}
|
|
324
401
|
case "get_thread": {
|
|
325
402
|
const input = schemas.get_thread.parse(args);
|
|
326
|
-
const messages = await client.getThread(input.thread_id);
|
|
327
|
-
|
|
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");
|
|
328
411
|
}
|
|
329
412
|
case "mark_messages": {
|
|
330
413
|
const input = schemas.mark_messages.parse(args);
|
|
@@ -371,6 +454,15 @@ export async function callTool(client, name, rawArgs) {
|
|
|
371
454
|
writeHandoff({ mailbox, serverUrl: client.serverUrl, token: client.tokenForHandoff });
|
|
372
455
|
const stats = await client.stats().catch(() => null);
|
|
373
456
|
const pending = stats?.folders.find((f) => f.name === "inbox")?.unread ?? 0;
|
|
457
|
+
// Un veilleur déjà en place suffit : en armer un second doublerait chaque
|
|
458
|
+
// notification sans rien apporter.
|
|
459
|
+
if (stats?.watcher?.armed) {
|
|
460
|
+
return [
|
|
461
|
+
`Notifications are already live on mailbox \`${mailbox}\` (watcher armed since ${stats.watcher.since}).`,
|
|
462
|
+
"Nothing to do: keep working, incoming messages will interrupt you on their own.",
|
|
463
|
+
"Do not arm a second watcher — you would get every message twice.",
|
|
464
|
+
].join("\n");
|
|
465
|
+
}
|
|
374
466
|
const lines = [
|
|
375
467
|
`Notifications enabled for mailbox \`${mailbox}\`.`,
|
|
376
468
|
"",
|
|
@@ -410,6 +502,22 @@ function asArray(value) {
|
|
|
410
502
|
return [];
|
|
411
503
|
return Array.isArray(value) ? value : [value];
|
|
412
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
|
+
}
|
|
413
521
|
function formatSummary(message) {
|
|
414
522
|
const flags = [message.read ? " " : "•", message.starred ? "★" : " "].join("");
|
|
415
523
|
const priority = message.priority === "normal" ? "" : ` [${message.priority}]`;
|
|
@@ -417,7 +525,7 @@ function formatSummary(message) {
|
|
|
417
525
|
`${flags} ${message.subject}${priority}`,
|
|
418
526
|
` from ${message.from} to ${message.to.join(", ") || "—"}${message.cc.length ? ` (cc ${message.cc.join(", ")})` : ""} · ${message.created_at}`,
|
|
419
527
|
` ${message.preview}`,
|
|
420
|
-
` id ${message.message_id} · thread ${message.thread_id}`,
|
|
528
|
+
` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
|
|
421
529
|
].join("\n");
|
|
422
530
|
}
|
|
423
531
|
function formatMessage(message) {
|
|
@@ -429,7 +537,7 @@ function formatMessage(message) {
|
|
|
429
537
|
`Date: ${message.created_at}`,
|
|
430
538
|
`Priority: ${message.priority}`,
|
|
431
539
|
`Folder: ${message.folder}${message.read ? "" : " (unread)"}${message.starred ? " ★" : ""}`,
|
|
432
|
-
`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}` : ""}`,
|
|
433
541
|
]
|
|
434
542
|
.filter(Boolean)
|
|
435
543
|
.join("\n");
|
package/dist/src/server/app.js
CHANGED
|
@@ -8,6 +8,7 @@ import { rateLimit } from "express-rate-limit";
|
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { MessengerError, normalizeAgentId } from "../core/store.js";
|
|
10
10
|
import { PRIORITIES } from "../core/types.js";
|
|
11
|
+
import { WatcherRegistry } from "./watchers.js";
|
|
11
12
|
import { APP_VERSION } from "../version.js";
|
|
12
13
|
import { logger } from "../logger.js";
|
|
13
14
|
const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
|
|
@@ -26,6 +27,22 @@ const sendSchema = z.object({
|
|
|
26
27
|
body: z.string().min(1).max(200_000),
|
|
27
28
|
priority: z.enum(PRIORITIES).optional(),
|
|
28
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(),
|
|
29
46
|
});
|
|
30
47
|
const listQuerySchema = z.object({
|
|
31
48
|
folder: z.string().trim().min(1).optional(),
|
|
@@ -51,6 +68,9 @@ const folderSchema = z.object({ name: z.string().trim().min(1).max(40) });
|
|
|
51
68
|
const waitQuerySchema = z.object({
|
|
52
69
|
timeout_seconds: z.coerce.number().int().min(1).optional(),
|
|
53
70
|
folder: z.string().trim().min(1).optional(),
|
|
71
|
+
// Distingue le veilleur d'un `wait_for_message` ordinaire : les deux tiennent la même
|
|
72
|
+
// attente longue, mais seul le premier prouve que l'agent est joignable en continu.
|
|
73
|
+
watcher: z.enum(["true", "1"]).optional(),
|
|
54
74
|
});
|
|
55
75
|
const adminListSchema = z.object({
|
|
56
76
|
search: z.string().trim().min(1).optional(),
|
|
@@ -61,6 +81,8 @@ const adminListSchema = z.object({
|
|
|
61
81
|
});
|
|
62
82
|
export function createApp({ store, config }) {
|
|
63
83
|
const app = express();
|
|
84
|
+
// Présence des veilleurs : état de connexion, donc en mémoire du processus.
|
|
85
|
+
const watchers = new WatcherRegistry();
|
|
64
86
|
app.disable("x-powered-by");
|
|
65
87
|
app.set("trust proxy", false);
|
|
66
88
|
app.use(helmet({
|
|
@@ -136,7 +158,7 @@ export function createApp({ store, config }) {
|
|
|
136
158
|
res.json({ agent });
|
|
137
159
|
});
|
|
138
160
|
api.get("/mailbox", (req, res) => {
|
|
139
|
-
res.json(store.stats(req.agentId));
|
|
161
|
+
res.json({ ...store.stats(req.agentId), watcher: watchers.status(req.agentId) });
|
|
140
162
|
});
|
|
141
163
|
api.post("/messages", (req, res) => {
|
|
142
164
|
const input = sendSchema.parse(req.body ?? {});
|
|
@@ -148,6 +170,8 @@ export function createApp({ store, config }) {
|
|
|
148
170
|
body: input.body,
|
|
149
171
|
priority: input.priority,
|
|
150
172
|
reply_to: input.reply_to ?? null,
|
|
173
|
+
thread_title: input.thread_title ?? null,
|
|
174
|
+
thread_description: input.thread_description ?? null,
|
|
151
175
|
});
|
|
152
176
|
logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to.join(", ")}`);
|
|
153
177
|
res.status(201).json(result);
|
|
@@ -173,8 +197,18 @@ export function createApp({ store, config }) {
|
|
|
173
197
|
const markRead = req.query.mark_read !== "false";
|
|
174
198
|
res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
|
|
175
199
|
});
|
|
200
|
+
api.get("/threads", (req, res) => {
|
|
201
|
+
const query = listThreadsQuerySchema.parse(req.query);
|
|
202
|
+
res.json({ threads: store.listThreads(req.agentId, query) });
|
|
203
|
+
});
|
|
176
204
|
api.get("/threads/:id", (req, res) => {
|
|
177
|
-
|
|
205
|
+
const threadId = String(req.params.id);
|
|
206
|
+
const messages = store.getThread(req.agentId, threadId);
|
|
207
|
+
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
|
|
208
|
+
});
|
|
209
|
+
api.patch("/threads/:id", (req, res) => {
|
|
210
|
+
const input = threadLabelSchema.parse(req.body ?? {});
|
|
211
|
+
res.json({ thread: store.setThreadLabel(req.agentId, String(req.params.id), input) });
|
|
178
212
|
});
|
|
179
213
|
api.get("/folders", (req, res) => {
|
|
180
214
|
res.json({ folders: store.listFolders(req.agentId) });
|
|
@@ -190,16 +224,20 @@ export function createApp({ store, config }) {
|
|
|
190
224
|
// l'appelant ; les non-lus déjà présents sont ignorés (sinon l'outil rendrait la main
|
|
191
225
|
// immédiatement tant que la boîte n'est pas vidée).
|
|
192
226
|
api.get("/wait", (req, res) => {
|
|
193
|
-
const { timeout_seconds, folder } = waitQuerySchema.parse(req.query);
|
|
227
|
+
const { timeout_seconds, folder, watcher } = waitQuerySchema.parse(req.query);
|
|
194
228
|
const agentId = req.agentId;
|
|
195
229
|
const timeoutSeconds = Math.min(timeout_seconds ?? 30, config.maxWaitSeconds);
|
|
196
230
|
if (folder && !store.listFolders(agentId).some((f) => f.name === folder)) {
|
|
197
231
|
throw new MessengerError(`unknown folder: ${folder}`, 404);
|
|
198
232
|
}
|
|
233
|
+
// Le relâchement doit couvrir les trois sorties possibles : message, expiration,
|
|
234
|
+
// client parti. Un compteur qui ne redescend pas ferait croire à un veilleur éternel.
|
|
235
|
+
const releaseWatcher = watcher ? watchers.begin(agentId) : () => { };
|
|
199
236
|
const done = (payload) => {
|
|
200
237
|
clearTimeout(timer);
|
|
201
238
|
store.off("message", onMessage);
|
|
202
239
|
req.off("close", onClose);
|
|
240
|
+
releaseWatcher();
|
|
203
241
|
if (!res.headersSent)
|
|
204
242
|
res.json(payload);
|
|
205
243
|
};
|
|
@@ -215,6 +253,7 @@ export function createApp({ store, config }) {
|
|
|
215
253
|
const onClose = () => {
|
|
216
254
|
clearTimeout(timer);
|
|
217
255
|
store.off("message", onMessage);
|
|
256
|
+
releaseWatcher();
|
|
218
257
|
};
|
|
219
258
|
const timer = setTimeout(() => done({ timed_out: true, message: null }), timeoutSeconds * 1000);
|
|
220
259
|
store.on("message", onMessage);
|
|
@@ -235,10 +274,11 @@ export function createApp({ store, config }) {
|
|
|
235
274
|
res.json(store.getMessageAdmin(String(req.params.id)));
|
|
236
275
|
});
|
|
237
276
|
admin.get("/threads/:id", (req, res) => {
|
|
238
|
-
const
|
|
277
|
+
const threadId = String(req.params.id);
|
|
278
|
+
const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
|
|
239
279
|
if (messages.length === 0)
|
|
240
|
-
throw new MessengerError(`thread not found: ${
|
|
241
|
-
res.json({ thread_id:
|
|
280
|
+
throw new MessengerError(`thread not found: ${threadId}`, 404);
|
|
281
|
+
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
|
|
242
282
|
});
|
|
243
283
|
app.use("/admin", admin);
|
|
244
284
|
// --- Interface web --------------------------------------------------------
|
|
@@ -14,6 +14,7 @@ const state = {
|
|
|
14
14
|
search: "",
|
|
15
15
|
selectedMessageId: null,
|
|
16
16
|
threadId: null,
|
|
17
|
+
threadTitle: null,
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
const el = {
|
|
@@ -71,6 +72,12 @@ function formatDate(iso) {
|
|
|
71
72
|
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** Le titre suit l'id : le bandeau de filtre doit rester lisible, pas afficher un id nu. */
|
|
76
|
+
function setThreadFilter(threadId, title) {
|
|
77
|
+
state.threadId = threadId;
|
|
78
|
+
state.threadTitle = title;
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
function renderAgents(overview) {
|
|
75
82
|
el.serverStats.textContent =
|
|
76
83
|
overview.messages + " message(s) · " + overview.threads + " fil(s) · " + overview.agents.length + " agent(s)";
|
|
@@ -79,14 +86,20 @@ function renderAgents(overview) {
|
|
|
79
86
|
|
|
80
87
|
for (const agent of overview.agents) {
|
|
81
88
|
const item = node("li", state.agent === agent.id ? "selected" : "");
|
|
82
|
-
|
|
89
|
+
// Le nom d'abord : l'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
|
|
90
|
+
item.appendChild(node("div", "name", agent.display_name || "sans nom"));
|
|
91
|
+
item.appendChild(node("div", "id", agent.id));
|
|
83
92
|
item.appendChild(
|
|
84
93
|
node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
|
|
85
94
|
);
|
|
95
|
+
// La liste est classée sur cette date : sans elle, l'ordre paraîtrait arbitraire.
|
|
96
|
+
item.appendChild(
|
|
97
|
+
node("div", "muted", agent.last_activity_at ? "activité " + formatDate(agent.last_activity_at) : "aucune activité")
|
|
98
|
+
);
|
|
86
99
|
if (!agent.registered) item.appendChild(node("div", "muted", "jamais connecté"));
|
|
87
100
|
item.addEventListener("click", () => {
|
|
88
101
|
state.agent = state.agent === agent.id ? null : agent.id;
|
|
89
|
-
|
|
102
|
+
setThreadFilter(null, null);
|
|
90
103
|
refresh();
|
|
91
104
|
});
|
|
92
105
|
el.agents.appendChild(item);
|
|
@@ -96,7 +109,7 @@ function renderAgents(overview) {
|
|
|
96
109
|
function renderMessages(messages) {
|
|
97
110
|
el.filterLabel.textContent = [
|
|
98
111
|
state.agent ? "agent : " + state.agent : null,
|
|
99
|
-
state.threadId ? "fil : " + state.threadId : null,
|
|
112
|
+
state.threadId ? "fil : " + (state.threadTitle || state.threadId) : null,
|
|
100
113
|
]
|
|
101
114
|
.filter(Boolean)
|
|
102
115
|
.join(" · ");
|
|
@@ -115,6 +128,8 @@ function renderMessages(messages) {
|
|
|
115
128
|
subject.appendChild(badge);
|
|
116
129
|
}
|
|
117
130
|
item.appendChild(subject);
|
|
131
|
+
// Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
|
|
132
|
+
if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
|
|
118
133
|
item.appendChild(
|
|
119
134
|
node("div", "route", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
|
|
120
135
|
);
|
|
@@ -154,7 +169,7 @@ function renderDetail(message) {
|
|
|
154
169
|
"\nid " +
|
|
155
170
|
message.message_id +
|
|
156
171
|
" · fil " +
|
|
157
|
-
message.thread_id
|
|
172
|
+
(message.thread_title ? message.thread_title + " (" + message.thread_id + ")" : message.thread_id)
|
|
158
173
|
)
|
|
159
174
|
);
|
|
160
175
|
|
|
@@ -192,11 +207,16 @@ async function showThread(threadId) {
|
|
|
192
207
|
try {
|
|
193
208
|
const data = await api("/admin/threads/" + encodeURIComponent(threadId));
|
|
194
209
|
const container = node("div", "detail");
|
|
195
|
-
|
|
210
|
+
const title = (data.thread && data.thread.title) || null;
|
|
211
|
+
container.appendChild(node("h3", null, (title || "Fil " + threadId) + " — " + data.messages.length + " message(s)"));
|
|
212
|
+
if (title) container.appendChild(node("div", "meta", "fil " + threadId));
|
|
213
|
+
if (data.thread && data.thread.description) {
|
|
214
|
+
container.appendChild(node("div", "thread-description", data.thread.description));
|
|
215
|
+
}
|
|
196
216
|
|
|
197
217
|
const filterButton = node("button", null, "Filtrer la liste sur ce fil");
|
|
198
218
|
filterButton.addEventListener("click", () => {
|
|
199
|
-
|
|
219
|
+
setThreadFilter(threadId, title);
|
|
200
220
|
refresh({ keepDetail: true });
|
|
201
221
|
});
|
|
202
222
|
container.appendChild(filterButton);
|
|
@@ -246,7 +266,7 @@ async function refresh(options) {
|
|
|
246
266
|
el.refresh.addEventListener("click", () => refresh({ keepDetail: true }));
|
|
247
267
|
el.clearFilters.addEventListener("click", () => {
|
|
248
268
|
state.agent = null;
|
|
249
|
-
|
|
269
|
+
setThreadFilter(null, null);
|
|
250
270
|
state.search = "";
|
|
251
271
|
el.search.value = "";
|
|
252
272
|
refresh({ keepDetail: true });
|
|
@@ -180,8 +180,15 @@ ul {
|
|
|
180
180
|
outline: 1px solid var(--accent);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
.agents .
|
|
183
|
+
.agents .name {
|
|
184
184
|
font-weight: 600;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* L'adresse reste lisible pour être recopiée, mais ne prend plus le pas sur le nom. */
|
|
188
|
+
.agents .id {
|
|
189
|
+
color: var(--muted);
|
|
190
|
+
font-size: 11px;
|
|
191
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
185
192
|
word-break: break-all;
|
|
186
193
|
}
|
|
187
194
|
|
|
@@ -216,6 +223,18 @@ ul {
|
|
|
216
223
|
font-weight: 600;
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
/* Nom du fil : au-dessus de l'expéditeur, en accent, pour rattacher le message d'un coup d'œil. */
|
|
227
|
+
.messages .thread-name {
|
|
228
|
+
color: var(--accent);
|
|
229
|
+
font-size: 12px;
|
|
230
|
+
margin-top: 2px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.detail .thread-description {
|
|
234
|
+
color: var(--muted);
|
|
235
|
+
margin: 6px 0 10px;
|
|
236
|
+
}
|
|
237
|
+
|
|
219
238
|
.messages .route {
|
|
220
239
|
color: var(--muted);
|
|
221
240
|
font-size: 12px;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WatcherStatus } from "../core/types.js";
|
|
2
|
+
export declare class WatcherRegistry {
|
|
3
|
+
private readonly now;
|
|
4
|
+
private readonly presence;
|
|
5
|
+
/** L'horloge est injectable : le délai de grâce se teste sans faire attendre la suite. */
|
|
6
|
+
constructor(now?: () => number);
|
|
7
|
+
/** Déclare une attente de veilleur. La fonction rendue la clôt, et doit toujours être appelée. */
|
|
8
|
+
begin(agentId: string): () => void;
|
|
9
|
+
status(agentId: string): WatcherStatus;
|
|
10
|
+
}
|