@imenam/mcp-messenger 1.0.16 → 1.0.18
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 +16 -0
- package/README.md +85 -10
- package/dist/src/cli/setup-mcp.js +1 -0
- package/dist/src/cli/watch.js +5 -0
- package/dist/src/config.d.ts +31 -0
- package/dist/src/config.js +40 -0
- package/dist/src/core/store.d.ts +25 -0
- package/dist/src/core/store.js +58 -1
- package/dist/src/mcp/mailbox-registry.d.ts +81 -0
- package/dist/src/mcp/mailbox-registry.js +0 -0
- package/dist/src/mcp/resume-state.d.ts +34 -0
- package/dist/src/mcp/resume-state.js +14 -0
- package/dist/src/mcp/start.d.ts +25 -5
- package/dist/src/mcp/start.js +120 -9
- package/dist/src/mcp/tools.js +37 -0
- package/dist/src/server/app.js +48 -1
- package/dist/src/server/ui/app.js +181 -1
- package/dist/src/server/ui/index.html +48 -1
- package/dist/src/server/ui/styles.css +1560 -1409
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/dist/src/mcp/start.js
CHANGED
|
@@ -7,6 +7,8 @@ import { removeOwnHandoffs } from "../cli/watch-handoff.js";
|
|
|
7
7
|
import { newId } from "../core/ids.js";
|
|
8
8
|
import { logger } from "../logger.js";
|
|
9
9
|
import { APP_VERSION } from "../version.js";
|
|
10
|
+
import { HEARTBEAT_MS, claim, heartbeat, orphanEntries, projectFingerprint, purge, releaseOwn, sessionKeyDigest, } from "./mailbox-registry.js";
|
|
11
|
+
import { setResumeState } from "./resume-state.js";
|
|
10
12
|
import { TOOLS, callTool } from "./tools.js";
|
|
11
13
|
/**
|
|
12
14
|
* Ce que l'hôte reçoit à la poignée de main, avant le moindre appel d'outil.
|
|
@@ -29,6 +31,11 @@ Who you are, and who the others are — read this before writing to anyone:
|
|
|
29
31
|
- One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
|
|
30
32
|
- Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
|
|
31
33
|
|
|
34
|
+
Resuming earlier work:
|
|
35
|
+
- A resumed session usually keeps the mailbox it had: the address, the threads and the unread messages come back on their own. \`whoami\` says so when it happens.
|
|
36
|
+
- When it cannot be decided automatically, \`whoami\` lists the mailboxes left by a stopped session of this project. If you are continuing that work, call \`attach_to_mailbox\` with the right address.
|
|
37
|
+
- If a \`box_…\` address appears earlier in your own context, from a previous part of this conversation, that is your mailbox from before: attach to it rather than writing from a fresh one, so your correspondents keep reaching you at the address they know.
|
|
38
|
+
|
|
32
39
|
Writing:
|
|
33
40
|
- One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
|
|
34
41
|
- Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
|
|
@@ -55,17 +62,61 @@ export function createMcpServer(client) {
|
|
|
55
62
|
/**
|
|
56
63
|
* Donne son adresse à la session.
|
|
57
64
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
65
|
+
* Trois chemins, du plus certain au plus approximatif :
|
|
66
|
+
*
|
|
67
|
+
* 1. `MESSENGER_AGENT_ID` fixe l'adresse, partagée par toutes les sessions de cette
|
|
68
|
+
* configuration. Rien à chercher.
|
|
69
|
+
* 2. Le registre local rend à une session reprise la boîte qu'elle tenait : identique si
|
|
70
|
+
* l'hôte a redonné le même identifiant de session, sinon la seule boîte du projet
|
|
71
|
+
* qu'aucune session vivante ne tient plus et qui a réellement servi.
|
|
72
|
+
* 3. À défaut, une boîte neuve, comme avant. Les boîtes orphelines non reprises sont
|
|
73
|
+
* gardées de côté : `whoami` les propose, l'agent en rattache une s'il reconnaît la
|
|
74
|
+
* sienne dans son contexte.
|
|
75
|
+
*
|
|
76
|
+
* Une boîte tenue par une session vivante n'est jamais reprise : c'est ce qui distingue
|
|
77
|
+
* ce mécanisme d'une adresse partagée, et ce qui protège deux sessions ouvertes en
|
|
78
|
+
* parallèle sur le même projet.
|
|
62
79
|
*/
|
|
63
|
-
export async function resolveIdentity(client, config) {
|
|
80
|
+
export async function resolveIdentity(client, config, options = {}) {
|
|
81
|
+
const keySource = config.sessionKey ? config.sessionKeySource : null;
|
|
64
82
|
if (config.agentId) {
|
|
65
83
|
client.setAgentId(config.agentId, { own: true });
|
|
66
84
|
await client.register();
|
|
67
|
-
|
|
85
|
+
const identity = { mailbox: config.agentId, resumed: null, candidates: [] };
|
|
86
|
+
setResumeState(client, { resumed: null, candidates: [], keySource });
|
|
87
|
+
return identity;
|
|
88
|
+
}
|
|
89
|
+
const fingerprint = projectFingerprint({
|
|
90
|
+
cwd: options.cwd ?? process.cwd(),
|
|
91
|
+
agentName: config.agentName,
|
|
92
|
+
serverUrl: config.serverUrl,
|
|
93
|
+
});
|
|
94
|
+
const sessionKey = config.sessionKey ? sessionKeyDigest(config.sessionKey) : null;
|
|
95
|
+
purge();
|
|
96
|
+
const found = config.resume === "off" ? [] : await inspectOrphans(config, orphanEntries(fingerprint));
|
|
97
|
+
const exact = sessionKey ? found.find((c) => c.entry.session_key === sessionKey) : undefined;
|
|
98
|
+
// Une seule boîte laissée par une session arrêtée, et elle a servi : l'ambiguïté
|
|
99
|
+
// n'existe pas. Plusieurs, ou une boîte vide, et c'est à l'agent de choisir.
|
|
100
|
+
const sole = found.length === 1 && found[0].candidate.messages > 0 ? found[0] : undefined;
|
|
101
|
+
const taken = config.resume === "auto" ? (exact ?? sole) : exact;
|
|
102
|
+
if (taken) {
|
|
103
|
+
client.setAgentId(taken.candidate.mailbox, { own: true });
|
|
104
|
+
await client.register();
|
|
105
|
+
claim({ mailbox: taken.candidate.mailbox, fingerprint, sessionKey });
|
|
106
|
+
const resumed = taken === exact ? "session-key" : "orphan";
|
|
107
|
+
const candidates = found.filter((c) => c !== taken).map((c) => c.candidate);
|
|
108
|
+
setResumeState(client, { resumed, candidates, keySource });
|
|
109
|
+
return { mailbox: taken.candidate.mailbox, resumed, candidates };
|
|
68
110
|
}
|
|
111
|
+
const mailbox = await allocateMailbox(client);
|
|
112
|
+
claim({ mailbox, fingerprint, sessionKey });
|
|
113
|
+
// Une boîte vide n'apprend rien à personne : seules celles qui ont du courrier valent
|
|
114
|
+
// la peine d'être proposées.
|
|
115
|
+
const candidates = found.map((c) => c.candidate).filter((c) => c.messages > 0);
|
|
116
|
+
setResumeState(client, { resumed: null, candidates, keySource });
|
|
117
|
+
return { mailbox, resumed: null, candidates };
|
|
118
|
+
}
|
|
119
|
+
async function allocateMailbox(client) {
|
|
69
120
|
// `fresh` fait échouer l'enregistrement si l'adresse existe déjà : la collision, même
|
|
70
121
|
// improbable sur 64 bits d'aléa, ne peut pas faire atterrir deux sessions au même endroit.
|
|
71
122
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
@@ -83,6 +134,57 @@ export async function resolveIdentity(client, config) {
|
|
|
83
134
|
}
|
|
84
135
|
throw new Error("could not allocate a mailbox: too many id collisions");
|
|
85
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Ce que le journal dit du démarrage.
|
|
139
|
+
*
|
|
140
|
+
* C'est le seul endroit d'où l'on peut vérifier que l'hôte transmet bien un identifiant
|
|
141
|
+
* de session, et lequel : sans cette ligne, une reprise qui n'a pas lieu ne s'explique pas.
|
|
142
|
+
*/
|
|
143
|
+
function describeIdentity(identity, config) {
|
|
144
|
+
const key = config.sessionKeySource ? `session key from ${config.sessionKeySource}` : "no host session key";
|
|
145
|
+
const how = identity.resumed === "session-key"
|
|
146
|
+
? "resumed from the previous session (same host session id)"
|
|
147
|
+
: identity.resumed === "orphan"
|
|
148
|
+
? "resumed the only mailbox left by a stopped session of this project"
|
|
149
|
+
: "new mailbox";
|
|
150
|
+
const waiting = identity.candidates.length > 0 ? `, ${identity.candidates.length} resumable mailbox(es)` : "";
|
|
151
|
+
return ` — ${how} (${key})${waiting}`;
|
|
152
|
+
}
|
|
153
|
+
/** Nombre de boîtes orphelines examinées : au-delà, le démarrage coûterait plus qu'il ne rend. */
|
|
154
|
+
const MAX_CANDIDATES = 5;
|
|
155
|
+
/**
|
|
156
|
+
* Confronte les entrées du registre au serveur : une boîte peut avoir disparu (base
|
|
157
|
+
* réinitialisée), et une boîte vide n'a rien à reprendre. Un serveur injoignable ne fait
|
|
158
|
+
* pas échouer le démarrage, il rend simplement la reprise impossible pour cette fois.
|
|
159
|
+
*/
|
|
160
|
+
async function inspectOrphans(config, entries) {
|
|
161
|
+
// Client jetable : la session n'a pas encore d'adresse, et celle-ci ne doit pas bouger
|
|
162
|
+
// au gré de l'inspection.
|
|
163
|
+
const probe = new MessengerClient(config);
|
|
164
|
+
const found = [];
|
|
165
|
+
for (const entry of entries.slice(0, MAX_CANDIDATES)) {
|
|
166
|
+
probe.setAgentId(entry.mailbox);
|
|
167
|
+
try {
|
|
168
|
+
const agent = await probe.getAgent(entry.mailbox);
|
|
169
|
+
const stats = await probe.stats();
|
|
170
|
+
found.push({
|
|
171
|
+
entry,
|
|
172
|
+
candidate: {
|
|
173
|
+
mailbox: entry.mailbox,
|
|
174
|
+
agent_name: agent.agent_name,
|
|
175
|
+
mailbox_name: agent.display_name,
|
|
176
|
+
messages: stats.folders.reduce((total, folder) => total + folder.total, 0),
|
|
177
|
+
unread: stats.unread_total,
|
|
178
|
+
last_activity: agent.last_seen_at,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
logger.info(`mailbox ${entry.mailbox} cannot be resumed: ${error instanceof Error ? error.message : error}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
86
188
|
/** Point d'entrée du mode MCP (mode par défaut, sans argument). */
|
|
87
189
|
export async function runMcpMode() {
|
|
88
190
|
const config = loadAgentConfig();
|
|
@@ -90,18 +192,27 @@ export async function runMcpMode() {
|
|
|
90
192
|
// Un serveur momentanément injoignable ne doit pas empêcher la session MCP de démarrer :
|
|
91
193
|
// l'agent verra l'erreur au premier outil appelé, et pourra réessayer.
|
|
92
194
|
try {
|
|
93
|
-
const
|
|
94
|
-
logger.info(`mailbox \`${
|
|
195
|
+
const identity = await resolveIdentity(client, config);
|
|
196
|
+
logger.info(`mailbox \`${identity.mailbox}\` ready on ${config.serverUrl}${describeIdentity(identity, config)}`);
|
|
95
197
|
}
|
|
96
198
|
catch (error) {
|
|
97
199
|
logger.warn(`could not reach ${config.serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
98
200
|
}
|
|
201
|
+
// Tant que ce battement continue, la boîte de cette session n'est reprise par personne.
|
|
202
|
+
// `unref` : c'est une trace, pas une raison de garder le processus en vie.
|
|
203
|
+
setInterval(heartbeat, HEARTBEAT_MS).unref();
|
|
99
204
|
// Le relais vers le veilleur porte le jeton de la session : il ne survit pas à la
|
|
100
205
|
// session. Un arrêt brutal le laisse en place, la purge par ancienneté le rattrape.
|
|
101
|
-
|
|
206
|
+
// L'entrée du registre suit le même sort, à ceci près qu'elle est close et non effacée :
|
|
207
|
+
// c'est elle qui rendra sa boîte à la session suivante.
|
|
208
|
+
process.on("exit", () => {
|
|
209
|
+
removeOwnHandoffs();
|
|
210
|
+
releaseOwn();
|
|
211
|
+
});
|
|
102
212
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
103
213
|
process.on(signal, () => {
|
|
104
214
|
removeOwnHandoffs();
|
|
215
|
+
releaseOwn();
|
|
105
216
|
process.exit(0);
|
|
106
217
|
});
|
|
107
218
|
}
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { writeHandoff } from "../cli/watch-handoff.js";
|
|
3
3
|
import { PRIORITIES, SYSTEM_FOLDERS } from "../core/types.js";
|
|
4
|
+
import { rebind } from "./mailbox-registry.js";
|
|
5
|
+
import { forgetCandidate, getResumeState } from "./resume-state.js";
|
|
4
6
|
/** Nom publié : l'agent recopie la commande, il n'a pas à connaître de chemin local. */
|
|
5
7
|
const PACKAGE_NAME = "@imenam/mcp-messenger";
|
|
6
8
|
const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
|
|
@@ -335,6 +337,7 @@ export async function callTool(client, name, rawArgs) {
|
|
|
335
337
|
if (client.isAttachedElsewhere) {
|
|
336
338
|
lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
|
|
337
339
|
}
|
|
340
|
+
lines.push(...resumeLines(client));
|
|
338
341
|
lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`,
|
|
339
342
|
// Sans veilleur, un message arrive en boîte sans que personne n'en soit averti.
|
|
340
343
|
// Le dire ici est le seul moyen pour l'agent de s'en apercevoir.
|
|
@@ -350,12 +353,17 @@ export async function callTool(client, name, rawArgs) {
|
|
|
350
353
|
if (!own)
|
|
351
354
|
throw new Error("this session has no mailbox of its own to return to");
|
|
352
355
|
client.setAgentId(own);
|
|
356
|
+
rebind(own);
|
|
353
357
|
return `Back to this session's own mailbox: ${own}`;
|
|
354
358
|
}
|
|
355
359
|
// Vérifie que la boîte existe avant de basculer : une faute de frappe créerait
|
|
356
360
|
// silencieusement une boîte vide, et l'agent croirait avoir repris le travail.
|
|
357
361
|
const target = await client.getAgent(input.mailbox_id);
|
|
358
362
|
client.setAgentId(target.id);
|
|
363
|
+
// La session travaille désormais cette boîte : c'est elle que sa prochaine reprise
|
|
364
|
+
// doit retrouver, et elle n'a plus à être proposée.
|
|
365
|
+
rebind(target.id);
|
|
366
|
+
forgetCandidate(client, target.id);
|
|
359
367
|
const attachedStats = await client.stats();
|
|
360
368
|
return [
|
|
361
369
|
`Attached to mailbox ${target.id}${formatAgentNames(target) ? ` (${formatAgentNames(target)})` : ""}.`,
|
|
@@ -678,6 +686,35 @@ async function renameThread(client, threadId, name, description) {
|
|
|
678
686
|
.filter(Boolean)
|
|
679
687
|
.join("\n");
|
|
680
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Ce que `whoami` dit des sessions précédentes.
|
|
691
|
+
*
|
|
692
|
+
* Deux choses, et seulement quand elles ont eu lieu : que cette boîte vient d'une session
|
|
693
|
+
* arrêtée — l'agent doit savoir d'où sortent des fils qu'il n'a pas ouverts — et quelles
|
|
694
|
+
* boîtes du projet attendent encore quelqu'un.
|
|
695
|
+
*/
|
|
696
|
+
function resumeLines(client) {
|
|
697
|
+
const state = getResumeState(client);
|
|
698
|
+
if (!state)
|
|
699
|
+
return [];
|
|
700
|
+
const lines = [];
|
|
701
|
+
if (state.resumed === "session-key") {
|
|
702
|
+
lines.push("Resumed: this is the mailbox of the session you are continuing (matched on the host session id).");
|
|
703
|
+
}
|
|
704
|
+
else if (state.resumed === "orphan") {
|
|
705
|
+
lines.push("Resumed: this mailbox was left by a stopped session of this project, and no live session held it.", " ↳ its threads and unread messages are not yours from this conversation; read before you answer.");
|
|
706
|
+
}
|
|
707
|
+
if (state.candidates.length > 0) {
|
|
708
|
+
lines.push("Mailboxes left by a stopped session of this project:");
|
|
709
|
+
for (const candidate of state.candidates) {
|
|
710
|
+
const name = candidate.mailbox_name ?? "unnamed mailbox";
|
|
711
|
+
const seen = candidate.last_activity ? ` · last activity ${candidate.last_activity}` : "";
|
|
712
|
+
lines.push(` ${candidate.mailbox} — ${name} · ${candidate.messages} message(s), ${candidate.unread} unread${seen}`);
|
|
713
|
+
}
|
|
714
|
+
lines.push(" ↳ if you are resuming that work, call attach_to_mailbox with its address.");
|
|
715
|
+
}
|
|
716
|
+
return lines;
|
|
717
|
+
}
|
|
681
718
|
/** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
|
|
682
719
|
function formatThreadRef(title, threadId) {
|
|
683
720
|
return title ? `${title} (${threadId})` : threadId;
|
package/dist/src/server/app.js
CHANGED
|
@@ -89,6 +89,24 @@ const adminThreadsSchema = z.object({
|
|
|
89
89
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
90
90
|
offset: z.coerce.number().int().min(0).optional(),
|
|
91
91
|
});
|
|
92
|
+
/**
|
|
93
|
+
* Date de coupure d'une purge.
|
|
94
|
+
*
|
|
95
|
+
* Un jour (`2026-08-01`) autant qu'un instant (`2026-08-01T09:00:00.000Z`) : la console
|
|
96
|
+
* envoie ce que le champ de date du navigateur produit, un jour nu, qui est déjà un
|
|
97
|
+
* horodatage ISO valide et se compare tel quel aux dates stockées.
|
|
98
|
+
*/
|
|
99
|
+
const isoDate = z
|
|
100
|
+
.string()
|
|
101
|
+
.trim()
|
|
102
|
+
.regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+Z?)?$/, "expected a date such as 2026-08-01")
|
|
103
|
+
.refine((value) => !Number.isNaN(Date.parse(value)), "not a real date");
|
|
104
|
+
const staleSchema = z.object({ before: isoDate });
|
|
105
|
+
const purgeSchema = z.object({
|
|
106
|
+
before: isoDate,
|
|
107
|
+
/** Les adresses vues dans l'aperçu : on ne supprime jamais autre chose que ce qui a été montré. */
|
|
108
|
+
mailboxes: z.array(z.string().trim().min(1)).min(1).max(500),
|
|
109
|
+
});
|
|
92
110
|
const adminListSchema = z.object({
|
|
93
111
|
search: z.string().trim().min(1).optional(),
|
|
94
112
|
agent: z.string().trim().min(1).optional(),
|
|
@@ -157,7 +175,7 @@ export function createApp({ store, config }) {
|
|
|
157
175
|
app.get("/", (_req, res) => {
|
|
158
176
|
res.redirect("/ui/");
|
|
159
177
|
});
|
|
160
|
-
// --- Supervision (lecture
|
|
178
|
+
// --- Supervision (lecture, sauf la purge des boîtes inactives) -----------
|
|
161
179
|
// Montée avant la porte d'entrée : son contrôle est au moins aussi strict (session de
|
|
162
180
|
// console ou jeton admin), et la session par cookie n'est reconnue nulle part ailleurs.
|
|
163
181
|
const admin = express.Router();
|
|
@@ -197,6 +215,35 @@ export function createApp({ store, config }) {
|
|
|
197
215
|
throw new MessengerError(`thread not found: ${threadId}`, 404);
|
|
198
216
|
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
|
|
199
217
|
});
|
|
218
|
+
/**
|
|
219
|
+
* Boîtes sans activité depuis une date : ce que la purge emporterait.
|
|
220
|
+
*
|
|
221
|
+
* Toujours consultée avant de supprimer — la console montre la liste, et c'est sur cette
|
|
222
|
+
* liste que porte la suppression. Une purge ne se décide pas sur un compte.
|
|
223
|
+
*/
|
|
224
|
+
admin.get("/mailboxes/stale", (req, res) => {
|
|
225
|
+
const { before } = staleSchema.parse(req.query);
|
|
226
|
+
res.json({ before, mailboxes: store.staleMailboxes(before) });
|
|
227
|
+
});
|
|
228
|
+
/**
|
|
229
|
+
* La seule route de la console qui écrit, et elle détruit.
|
|
230
|
+
*
|
|
231
|
+
* Deux garde-fous, parce qu'une purge ne se rattrape pas : la date est redemandée avec
|
|
232
|
+
* les adresses, et le serveur refuse toute adresse qui ne serait pas, au moment où il
|
|
233
|
+
* répond, sans activité depuis cette date. Une boîte redevenue vivante entre l'aperçu
|
|
234
|
+
* et la confirmation est donc épargnée, et le refus est nominatif.
|
|
235
|
+
*/
|
|
236
|
+
admin.post("/mailboxes/purge", (req, res) => {
|
|
237
|
+
const input = purgeSchema.parse(req.body ?? {});
|
|
238
|
+
const stale = new Set(store.staleMailboxes(input.before).map((mailbox) => mailbox.id));
|
|
239
|
+
const active = input.mailboxes.filter((id) => !stale.has(id));
|
|
240
|
+
if (active.length > 0) {
|
|
241
|
+
throw new MessengerError(`these mailboxes are no longer inactive since ${input.before}, nothing was deleted: ${active.join(", ")}`, 409);
|
|
242
|
+
}
|
|
243
|
+
const removed = store.deleteMailboxes(input.mailboxes);
|
|
244
|
+
logger.warn(`admin purge: ${removed.mailboxes} mailbox(es) deleted, inactive since ${input.before}`);
|
|
245
|
+
res.json({ before: input.before, ...removed });
|
|
246
|
+
});
|
|
200
247
|
app.use("/admin", admin);
|
|
201
248
|
// Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
|
|
202
249
|
// contrôle par route (identité de l'agent) vient ensuite.
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// Console de supervision :
|
|
1
|
+
// Console de supervision : les routes /admin du serveur, en lecture — à une exception
|
|
2
|
+
// près, la purge des boîtes inactives, seule commande de cette page qui écrit, et qui
|
|
3
|
+
// détruit. Elle est donc tenue à part : un aperçu, puis une confirmation, et rien ne part
|
|
4
|
+
// que le serveur n'ait redemandé sa date.
|
|
2
5
|
//
|
|
3
6
|
// Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
|
|
4
7
|
// jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
|
|
@@ -77,6 +80,14 @@ const el = {
|
|
|
77
80
|
threads: document.getElementById("threads"),
|
|
78
81
|
reader: document.getElementById("reader"),
|
|
79
82
|
sheet: document.getElementById("sheet"),
|
|
83
|
+
purge: document.getElementById("purge"),
|
|
84
|
+
purgeOpen: document.getElementById("purge-open"),
|
|
85
|
+
purgeClose: document.getElementById("purge-close"),
|
|
86
|
+
purgeCancel: document.getElementById("purge-cancel"),
|
|
87
|
+
purgeBefore: document.getElementById("purge-before"),
|
|
88
|
+
purgePreview: document.getElementById("purge-preview"),
|
|
89
|
+
purgeResult: document.getElementById("purge-result"),
|
|
90
|
+
purgeConfirm: document.getElementById("purge-confirm"),
|
|
80
91
|
};
|
|
81
92
|
|
|
82
93
|
// --- Fabrique de noeuds ----------------------------------------------------
|
|
@@ -235,6 +246,12 @@ function formatShort(iso) {
|
|
|
235
246
|
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
|
|
236
247
|
}
|
|
237
248
|
|
|
249
|
+
/** Jour seul, sans heure : une purge se raisonne en jours, pas en minutes. */
|
|
250
|
+
function formatDay(iso) {
|
|
251
|
+
const date = new Date(iso);
|
|
252
|
+
return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleDateString();
|
|
253
|
+
}
|
|
254
|
+
|
|
238
255
|
function plural(count, singular, pluralForm) {
|
|
239
256
|
return `${count} ${count > 1 ? pluralForm || `${singular}s` : singular}`;
|
|
240
257
|
}
|
|
@@ -258,6 +275,23 @@ async function api(path, params) {
|
|
|
258
275
|
return response.json();
|
|
259
276
|
}
|
|
260
277
|
|
|
278
|
+
/** Envoi d'une action à la console. La seule qui existe supprime : elle passe par ici. */
|
|
279
|
+
async function post(path, body) {
|
|
280
|
+
const response = await fetch(path, {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
283
|
+
credentials: "same-origin",
|
|
284
|
+
body: JSON.stringify(body),
|
|
285
|
+
});
|
|
286
|
+
if (response.status === 401) {
|
|
287
|
+
setLoggedIn(false);
|
|
288
|
+
throw new Error("unauthorized");
|
|
289
|
+
}
|
|
290
|
+
const payload = await response.json().catch(() => ({}));
|
|
291
|
+
if (!response.ok) throw new Error(payload.error || response.statusText);
|
|
292
|
+
return payload;
|
|
293
|
+
}
|
|
294
|
+
|
|
261
295
|
function setLoggedIn(loggedIn) {
|
|
262
296
|
el.login.hidden = loggedIn;
|
|
263
297
|
el.console.hidden = !loggedIn;
|
|
@@ -906,14 +940,160 @@ async function refresh(options) {
|
|
|
906
940
|
}
|
|
907
941
|
}
|
|
908
942
|
|
|
943
|
+
// --- Purge des boîtes inactives --------------------------------------------
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Ce que la fenêtre de purge a sous la main : la date de l'aperçu, les boîtes que le
|
|
947
|
+
* serveur a désignées, celles qui restent cochées, et si la suppression a déjà été
|
|
948
|
+
* demandée une fois. Le second clic est ce qui la déclenche.
|
|
949
|
+
*/
|
|
950
|
+
const purge = { before: null, mailboxes: [], selected: new Set(), armed: false };
|
|
951
|
+
|
|
952
|
+
function openPurge() {
|
|
953
|
+
const month = new Date(Date.now() - 30 * 86_400_000);
|
|
954
|
+
el.purgeBefore.value = month.toISOString().slice(0, 10);
|
|
955
|
+
el.purgeBefore.max = new Date().toISOString().slice(0, 10);
|
|
956
|
+
resetPurge();
|
|
957
|
+
el.purge.showModal();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Toute modification remet la confirmation à zéro : on ne supprime que ce qu'on vient de voir. */
|
|
961
|
+
function resetPurge() {
|
|
962
|
+
purge.before = null;
|
|
963
|
+
purge.mailboxes = [];
|
|
964
|
+
purge.selected = new Set();
|
|
965
|
+
purge.armed = false;
|
|
966
|
+
el.purgeResult.replaceChildren();
|
|
967
|
+
el.purgeConfirm.disabled = true;
|
|
968
|
+
el.purgeConfirm.textContent = "Supprimer";
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async function previewPurge() {
|
|
972
|
+
const before = el.purgeBefore.value;
|
|
973
|
+
if (!before) return;
|
|
974
|
+
resetPurge();
|
|
975
|
+
try {
|
|
976
|
+
const { mailboxes } = await api("/admin/mailboxes/stale", { before });
|
|
977
|
+
purge.before = before;
|
|
978
|
+
purge.mailboxes = mailboxes;
|
|
979
|
+
purge.selected = new Set(mailboxes.map((mailbox) => mailbox.id));
|
|
980
|
+
renderPurgePreview();
|
|
981
|
+
} catch (error) {
|
|
982
|
+
el.purgeResult.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function renderPurgePreview() {
|
|
987
|
+
const container = document.createDocumentFragment();
|
|
988
|
+
|
|
989
|
+
if (purge.mailboxes.length === 0) {
|
|
990
|
+
container.appendChild(node("p", "purge-empty", `Aucune boîte sans activité depuis le ${formatDay(purge.before)}.`));
|
|
991
|
+
el.purgeResult.replaceChildren(container);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
container.appendChild(
|
|
996
|
+
node("p", "purge-count", `${plural(purge.mailboxes.length, "boîte")} sans activité depuis le ${formatDay(purge.before)}.`)
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
const list = node("ul", "purge-list");
|
|
1000
|
+
for (const mailbox of purge.mailboxes) {
|
|
1001
|
+
const item = node("li", "purge-item");
|
|
1002
|
+
const label = node("label", "purge-line");
|
|
1003
|
+
|
|
1004
|
+
const box = document.createElement("input");
|
|
1005
|
+
box.type = "checkbox";
|
|
1006
|
+
box.checked = true;
|
|
1007
|
+
box.addEventListener("change", () => {
|
|
1008
|
+
if (box.checked) purge.selected.add(mailbox.id);
|
|
1009
|
+
else purge.selected.delete(mailbox.id);
|
|
1010
|
+
// Décocher change ce qui sera supprimé : la confirmation déjà armée ne vaut plus.
|
|
1011
|
+
purge.armed = false;
|
|
1012
|
+
updatePurgeButton();
|
|
1013
|
+
});
|
|
1014
|
+
label.appendChild(box);
|
|
1015
|
+
|
|
1016
|
+
const text = node("span", "purge-text");
|
|
1017
|
+
text.appendChild(node("span", "purge-name", mailbox.display_name || mailbox.agent_name || "boîte sans nom"));
|
|
1018
|
+
text.appendChild(node("span", "mono muted", mailbox.id));
|
|
1019
|
+
const activity = mailbox.last_activity_at ? `dernière activité ${formatDay(mailbox.last_activity_at)}` : "jamais utilisée";
|
|
1020
|
+
text.appendChild(node("span", "purge-meta", `${plural(mailbox.messages, "message")} · ${activity}`));
|
|
1021
|
+
label.appendChild(text);
|
|
1022
|
+
|
|
1023
|
+
item.appendChild(label);
|
|
1024
|
+
list.appendChild(item);
|
|
1025
|
+
}
|
|
1026
|
+
container.appendChild(list);
|
|
1027
|
+
|
|
1028
|
+
el.purgeResult.replaceChildren(container);
|
|
1029
|
+
updatePurgeButton();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function updatePurgeButton() {
|
|
1033
|
+
const count = purge.selected.size;
|
|
1034
|
+
el.purgeConfirm.disabled = count === 0;
|
|
1035
|
+
el.purgeConfirm.textContent = purge.armed
|
|
1036
|
+
? `Confirmer : supprimer ${plural(count, "boîte")}`
|
|
1037
|
+
: `Supprimer ${plural(count, "boîte")}`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function confirmPurge() {
|
|
1041
|
+
if (purge.selected.size === 0 || !purge.before) return;
|
|
1042
|
+
|
|
1043
|
+
// Premier clic : la suppression est armée, pas faite. Rien ne part sans un second clic.
|
|
1044
|
+
if (!purge.armed) {
|
|
1045
|
+
purge.armed = true;
|
|
1046
|
+
updatePurgeButton();
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
el.purgeConfirm.disabled = true;
|
|
1051
|
+
try {
|
|
1052
|
+
const removed = await post("/admin/mailboxes/purge", {
|
|
1053
|
+
before: purge.before,
|
|
1054
|
+
mailboxes: [...purge.selected],
|
|
1055
|
+
});
|
|
1056
|
+
resetPurge();
|
|
1057
|
+
el.purgeResult.replaceChildren(
|
|
1058
|
+
node(
|
|
1059
|
+
"p",
|
|
1060
|
+
"purge-done",
|
|
1061
|
+
`${plural(removed.mailboxes, "boîte")} supprimée(s), ${plural(removed.messages, "message")} et ${plural(removed.threads, "fil")} avec.`
|
|
1062
|
+
)
|
|
1063
|
+
);
|
|
1064
|
+
// La console montre encore ce qui n'existe plus : elle se relit entièrement.
|
|
1065
|
+
state.threadId = null;
|
|
1066
|
+
state.messageId = null;
|
|
1067
|
+
el.sheet.hidden = true;
|
|
1068
|
+
el.console.classList.remove("has-sheet");
|
|
1069
|
+
await refresh();
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
purge.armed = false;
|
|
1072
|
+
updatePurgeButton();
|
|
1073
|
+
el.purgeResult.appendChild(node("p", "purge-error", `Erreur : ${error.message}`));
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
909
1077
|
// --- Écouteurs -------------------------------------------------------------
|
|
910
1078
|
|
|
1079
|
+
el.purgeOpen.addEventListener("click", openPurge);
|
|
1080
|
+
el.purgeClose.addEventListener("click", () => el.purge.close());
|
|
1081
|
+
el.purgeCancel.addEventListener("click", () => el.purge.close());
|
|
1082
|
+
el.purgeBefore.addEventListener("change", resetPurge);
|
|
1083
|
+
el.purgePreview.addEventListener("click", previewPurge);
|
|
1084
|
+
el.purgeConfirm.addEventListener("click", confirmPurge);
|
|
1085
|
+
|
|
911
1086
|
el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
|
|
912
1087
|
|
|
1088
|
+
// Le bouton reste en place une fois la colonne repliée : il est le seul moyen de la
|
|
1089
|
+
// rouvrir. Seul ce qu'il annonce change.
|
|
913
1090
|
el.collapse.addEventListener("click", () => {
|
|
914
1091
|
state.collapsed = !state.collapsed;
|
|
915
1092
|
el.console.classList.toggle("is-collapsed", state.collapsed);
|
|
1093
|
+
const label = state.collapsed ? "Déplier la liste des agents" : "Replier la liste des agents";
|
|
916
1094
|
el.collapse.title = state.collapsed ? "Déplier" : "Replier";
|
|
1095
|
+
el.collapse.setAttribute("aria-label", label);
|
|
1096
|
+
el.collapse.setAttribute("aria-expanded", String(!state.collapsed));
|
|
917
1097
|
});
|
|
918
1098
|
|
|
919
1099
|
el.chips.addEventListener("click", (event) => {
|
|
@@ -42,10 +42,57 @@
|
|
|
42
42
|
<path d="M3 21v-5h5" />
|
|
43
43
|
</svg>
|
|
44
44
|
</button>
|
|
45
|
+
<button type="button" id="purge-open" class="btn btn--icon" title="Purger les boîtes inactives" aria-label="Purger les boîtes inactives">
|
|
46
|
+
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
47
|
+
<path d="M3 6h18" />
|
|
48
|
+
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
|
|
49
|
+
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
50
|
+
</svg>
|
|
51
|
+
</button>
|
|
45
52
|
<button type="button" id="logout" class="btn btn--link">Se déconnecter</button>
|
|
46
53
|
</div>
|
|
47
54
|
</header>
|
|
48
55
|
|
|
56
|
+
<!--
|
|
57
|
+
Purge des boîtes inactives : la seule action de la console qui écrit, et elle
|
|
58
|
+
détruit. Elle se fait donc en deux temps, l'aperçu puis la confirmation, et ne porte
|
|
59
|
+
jamais que sur les boîtes affichées.
|
|
60
|
+
-->
|
|
61
|
+
<dialog class="modal" id="purge">
|
|
62
|
+
<div class="modal-card">
|
|
63
|
+
<div class="modal-head">
|
|
64
|
+
<h2>Purger les boîtes inactives</h2>
|
|
65
|
+
<button type="button" class="btn btn--icon btn--ghost" id="purge-close" aria-label="Fermer">
|
|
66
|
+
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
67
|
+
<path d="M18 6 6 18" />
|
|
68
|
+
<path d="m6 6 12 12" />
|
|
69
|
+
</svg>
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
<p class="modal-sub">
|
|
73
|
+
Une boîte est inactive quand plus rien ne l'a touchée depuis la date choisie : ni connexion, ni message reçu
|
|
74
|
+
ou envoyé. Une boîte créée après cette date est laissée tranquille, même vide. La suppression est définitive
|
|
75
|
+
et emporte les messages que plus personne ne pourrait lire ; ce que le correspondant a reçu de son côté reste
|
|
76
|
+
intact.
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
<div class="purge-controls">
|
|
80
|
+
<label class="field">
|
|
81
|
+
<span class="field-label">Sans activité depuis le</span>
|
|
82
|
+
<span class="field-input"><input type="date" id="purge-before" /></span>
|
|
83
|
+
</label>
|
|
84
|
+
<button type="button" class="btn" id="purge-preview">Prévisualiser</button>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="purge-result" id="purge-result"></div>
|
|
88
|
+
|
|
89
|
+
<div class="modal-foot">
|
|
90
|
+
<button type="button" class="btn btn--link" id="purge-cancel">Fermer</button>
|
|
91
|
+
<button type="button" class="btn btn--danger" id="purge-confirm" disabled>Supprimer</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</dialog>
|
|
95
|
+
|
|
49
96
|
<!-- Écran de connexion : une carte seule, sans les panneaux vides derrière elle. -->
|
|
50
97
|
<main class="login" id="login" hidden>
|
|
51
98
|
<form class="card" id="login-form">
|
|
@@ -85,7 +132,7 @@
|
|
|
85
132
|
<aside class="agents" id="agents-pane">
|
|
86
133
|
<div class="agents-head">
|
|
87
134
|
<span class="section-label">Agents</span>
|
|
88
|
-
<button type="button" class="btn btn--icon btn--ghost" id="collapse" title="Replier" aria-label="Replier la liste des agents">
|
|
135
|
+
<button type="button" class="btn btn--icon btn--ghost" id="collapse" title="Replier" aria-label="Replier la liste des agents" aria-expanded="true">
|
|
89
136
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
90
137
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
91
138
|
<path d="M9 3v18" />
|