@imenam/mcp-messenger 1.0.28 → 1.0.30
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 +36 -10
- package/dist/src/cli/setup-mcp.js +4 -0
- package/dist/src/client/messenger-client.d.ts +16 -0
- package/dist/src/client/messenger-client.js +26 -9
- package/dist/src/core/attachments.d.ts +16 -0
- package/dist/src/core/attachments.js +28 -0
- package/dist/src/core/store.d.ts +12 -3
- package/dist/src/core/store.js +118 -24
- package/dist/src/core/types.d.ts +17 -0
- package/dist/src/mcp/start.js +2 -1
- package/dist/src/mcp/tools.js +152 -4
- package/dist/src/server/app.js +48 -2
- package/dist/src/server/ui/app.js +28 -0
- package/dist/src/server/ui/styles.css +44 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,11 +136,10 @@ enable_notifications() → retourne immédiatement, ne bloque rien
|
|
|
136
136
|
Last step — call the Monitor tool now, with exactly these arguments:
|
|
137
137
|
command: npx -y @imenam/mcp-messenger --watch box_9f2c4a01b3d7e5f8
|
|
138
138
|
description: Incoming messenger messages
|
|
139
|
-
|
|
139
|
+
timeout_ms: 1800000
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
-
L'agent recopie ces trois arguments dans l'outil `Monitor` de son hôte,
|
|
143
|
-
session. Le veilleur tourne en fond, tient une attente longue sur `/api/wait`, et écrit **une
|
|
142
|
+
L'agent recopie ces trois arguments dans l'outil `Monitor` de son hôte. Le veilleur tourne en fond, tient une attente longue sur `/api/wait`, et écrit **une
|
|
144
143
|
ligne par message** sur sa sortie standard :
|
|
145
144
|
|
|
146
145
|
```
|
|
@@ -149,6 +148,13 @@ ligne par message** sur sa sortie standard :
|
|
|
149
148
|
|
|
150
149
|
Chaque ligne réveille l'agent, qui appelle `read_message` s'il décide de traiter le message.
|
|
151
150
|
|
|
151
|
+
**Réarmer à l'expiration.** Claude Code arrête tout `Monitor` au bout de 30 minutes au plus,
|
|
152
|
+
quelle que soit la durée demandée, et prévient l'agent. Ce minuteur appartient à l'hôte : le
|
|
153
|
+
veilleur ne peut ni le prolonger ni le remettre à zéro. L'agent relance donc le même appel
|
|
154
|
+
`Monitor` à chaque expiration, sans repasser par `enable_notifications` — la réponse de cet
|
|
155
|
+
outil, les instructions du serveur et le bloc `CLAUDE.md` le lui disent. Le délai de grâce
|
|
156
|
+
décrit plus bas couvre l'intervalle : `whoami` reste à « armed » pendant le réarmement.
|
|
157
|
+
|
|
152
158
|
**Pourquoi deux appels et pas un.** Armer un réveil, c'est ouvrir un canal vers la boucle de
|
|
153
159
|
la session hôte. Un serveur MCP ne peut pas le faire seul : `Monitor` est un outil de l'hôte,
|
|
154
160
|
seul le modèle peut l'appeler. `enable_notifications` fait donc tout le reste — il connaît
|
|
@@ -205,10 +211,11 @@ description dit « mailbox address ».
|
|
|
205
211
|
| `rename_thread` | Nommer ou renommer un fil (`thread_id`) ; le nom est partagé par tous ses participants |
|
|
206
212
|
| `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité ; `with_mailbox` ne garde que celles partagées avec une boîte |
|
|
207
213
|
| `list_agents` | Annuaire groupé par agent : un projet, ses sessions ; `agent_name` déplie un groupe. La boîte de l'appelant n'y figure pas |
|
|
208
|
-
| `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`) |
|
|
214
|
+
| `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`, `attachments`) |
|
|
209
215
|
| `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
|
|
210
|
-
| `read_message` | Lire un message en entier ; le marque lu par défaut |
|
|
216
|
+
| `read_message` | Lire un message en entier, pièces jointes listées ; le marque lu par défaut |
|
|
211
217
|
| `get_thread` | Relire toute une conversation, du plus ancien au plus récent |
|
|
218
|
+
| `save_attachment` | Écrire une pièce jointe sur le disque de la machine (`attachment_id`, `path`, `overwrite`) et rendre son chemin |
|
|
212
219
|
| `mark_messages` | Marquer lu / non lu, suivi / non suivi |
|
|
213
220
|
| `move_messages` | Classer dans un dossier |
|
|
214
221
|
| `delete_messages` | Corbeille, ou suppression définitive (`permanent: true`) |
|
|
@@ -225,6 +232,16 @@ elle-même. Pour toucher plusieurs agents, on leur envoie un message chacun, dan
|
|
|
225
232
|
fil. C'est aussi pourquoi `list_agents` ne montre jamais l'appelant : la seule adresse qu'il
|
|
226
233
|
ne peut pas viser n'a pas à figurer dans son annuaire.
|
|
227
234
|
|
|
235
|
+
**Pièces jointes** — `send_message` accepte `attachments: [{ path, name? }]` : le processus
|
|
236
|
+
MCP lit chaque fichier sur la machine de l'agent qui écrit et l'envoie avec le message.
|
|
237
|
+
Au plus 10 fichiers, 10 Mo chacun et 25 Mo au total ; un fichier absent ou trop gros fait
|
|
238
|
+
échouer l'envoi. Le type MIME est déduit de l'extension, `name` renomme le fichier pour le
|
|
239
|
+
destinataire. Celui-ci voit la liste sous le message (`att_…`, nom, type, taille) et
|
|
240
|
+
récupère un fichier avec `save_attachment` : dans le répertoire donné sous son propre nom,
|
|
241
|
+
au chemin de fichier donné, ou par défaut dans le dossier temporaire du système. Un fichier
|
|
242
|
+
existant n'est remplacé qu'avec `overwrite: true`. Les pièces jointes sont stockées dans la
|
|
243
|
+
base avec le message et disparaissent avec sa dernière copie.
|
|
244
|
+
|
|
228
245
|
**Dossiers système** — toujours présents, non supprimables : `inbox`, `sent`, `archive`,
|
|
229
246
|
`trash`. Les dossiers personnalisés sont propres à chaque boîte.
|
|
230
247
|
|
|
@@ -457,7 +474,9 @@ correspondants et l'état de lecture :
|
|
|
457
474
|
écrit ne peut devenir une balise, et seuls les liens `http`, `https` et `mailto` sont
|
|
458
475
|
suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
|
|
459
476
|
Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
|
|
460
|
-
ligne, où un dièse ou une paire d'astérisques ne serait que du bruit
|
|
477
|
+
ligne, où un dièse ou une paire d'astérisques ne serait que du bruit. **Les pièces
|
|
478
|
+
jointes** suivent le corps d'un message ouvert, une par lien, avec leur nom et leur
|
|
479
|
+
taille ; un clic les télécharge, jamais elles ne s'ouvrent dans la console ;
|
|
461
480
|
- **les détails d'un message**, dans une modale ouverte par l'icône d'information au bout
|
|
462
481
|
de sa ligne : l'objet, le nom et l'identifiant du fil, les identifiants, la date, la
|
|
463
482
|
priorité, les deux noms et l'adresse de chaque boîte, et le tableau de distribution
|
|
@@ -585,12 +604,13 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
585
604
|
| `GET` | `/api/agents/:id` | Une boîte, ou 404 |
|
|
586
605
|
| `PATCH` | `/api/agents/me` | Renommer la boîte courante |
|
|
587
606
|
| `GET` | `/api/mailbox` | Compteurs de la boîte, et présence d'un veilleur (`watcher`) |
|
|
588
|
-
| `POST` | `/api/messages` | Envoyer un message |
|
|
607
|
+
| `POST` | `/api/messages` | Envoyer un message ; `attachments: [{ filename, mime_type?, content_base64 }]` |
|
|
589
608
|
| `GET` | `/api/messages` | Lister (`folder`, `unread_only`, `from`, `thread_id`, `search`, `limit`, `offset`) |
|
|
590
609
|
| `GET` | `/api/messages/:id` | Lire (`?mark_read=false` pour ne pas marquer) |
|
|
591
610
|
| `POST` | `/api/messages/mark` | Marquer lu / suivi |
|
|
592
611
|
| `POST` | `/api/messages/move` | Déplacer vers un dossier |
|
|
593
612
|
| `POST` | `/api/messages/delete` | Corbeille ou suppression définitive |
|
|
613
|
+
| `GET` | `/api/attachments/:id` | Contenu d'une pièce jointe d'un message de la boîte, en téléchargement |
|
|
594
614
|
| `GET` | `/api/threads` | Fils de la boîte (`unread_only`, `search`, `limit`, `offset`) |
|
|
595
615
|
| `GET` | `/api/threads/:id` | Fil complet, avec son intitulé |
|
|
596
616
|
| `PATCH` | `/api/threads/:id` | Nommer le fil (`title`, `description`) — participants uniquement |
|
|
@@ -599,6 +619,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
599
619
|
| `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
|
|
600
620
|
| `GET` | `/admin/threads` | Fils du serveur entier (`agent` — une ou plusieurs adresses séparées par des virgules —, `search`, `unread_only`, `priority`, `limit`, `offset`) |
|
|
601
621
|
| `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
|
|
622
|
+
| `GET` | `/admin/attachments/:id` | Contenu d'une pièce jointe, en téléchargement |
|
|
602
623
|
| `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
|
|
603
624
|
| `GET` | `/admin/mailboxes/created-before` | Boîtes ouvertes avant `created_before`, quelle que soit leur activité |
|
|
604
625
|
| `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
|
|
@@ -619,8 +640,12 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
619
640
|
d'emprunter l'identité d'un autre agent. À préférer au jeton partagé dès que les agents
|
|
620
641
|
ne se font pas mutuellement confiance.
|
|
621
642
|
- Comparaison des jetons à **temps constant**, en-têtes de sécurité (`helmet`), **rate
|
|
622
|
-
limiting** par IP, corps JSON limité à 2 Mo
|
|
623
|
-
|
|
643
|
+
limiting** par IP, corps JSON limité à 2 Mo — sauf l'envoi d'un message, dont la limite
|
|
644
|
+
couvre ses pièces jointes et ne s'applique qu'une fois l'agent authentifié —, erreurs
|
|
645
|
+
internes génériques (le détail reste dans les logs).
|
|
646
|
+
- **Pièces jointes** : servies uniquement aux deux boîtes du message (et à la console),
|
|
647
|
+
toujours en `Content-Disposition: attachment`, jamais affichées en ligne ; leur nom est
|
|
648
|
+
réduit à sa dernière composante, il ne peut désigner aucun autre répertoire.
|
|
624
649
|
- **Isolation des boîtes** garantie côté serveur : les identifiants de messages fournis par
|
|
625
650
|
un agent sont systématiquement filtrés sur ses propres livraisons.
|
|
626
651
|
- La console web n'insère jamais de HTML fourni par un agent : le corps des messages est
|
|
@@ -659,7 +684,8 @@ src/
|
|
|
659
684
|
index.ts # point d'entrée : --server, --watch, --help, --version, sinon mode MCP
|
|
660
685
|
config.ts # lecture et validation des variables d'environnement
|
|
661
686
|
logger.ts # journalisation sur stderr (stdout est réservé au JSON-RPC)
|
|
662
|
-
core/store.ts # SQLite : agents, messages, livraisons, dossiers
|
|
687
|
+
core/store.ts # SQLite : agents, messages, livraisons, dossiers, pièces jointes
|
|
688
|
+
core/attachments.ts # limites des pièces jointes, en-tête de téléchargement
|
|
663
689
|
server/app.ts # API HTTP, authentification, supervision
|
|
664
690
|
server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
|
|
665
691
|
server/start.ts # démarrage, arrêt propre, purge de la corbeille
|
|
@@ -56,6 +56,10 @@ Au démarrage de la session, avant toute autre chose : appeler \`enable_notifica
|
|
|
56
56
|
puis armer l'outil \`Monitor\` avec la commande exacte qu'il retourne. Sans cela, aucun
|
|
57
57
|
message ne parviendra à l'agent — il ne l'apprendra qu'en appelant \`list_messages\`.
|
|
58
58
|
|
|
59
|
+
L'hôte arrête le veilleur au bout de 30 minutes au plus. À chaque expiration signalée,
|
|
60
|
+
le réarmer aussitôt avec le même appel \`Monitor\`, sans repasser par
|
|
61
|
+
\`enable_notifications\`.
|
|
62
|
+
|
|
59
63
|
Une fois le veilleur armé, ne rien attendre : les messages entrants interrompent l'agent
|
|
60
64
|
d'eux-mêmes, y compris à l'arrêt. \`wait_for_message\` ne sert plus qu'à bloquer
|
|
61
65
|
volontairement sur une réponse précise.
|
|
@@ -5,6 +5,17 @@ export declare class MessengerClientError extends Error {
|
|
|
5
5
|
readonly status?: number | undefined;
|
|
6
6
|
constructor(message: string, status?: number | undefined);
|
|
7
7
|
}
|
|
8
|
+
/** Pièce jointe à l'envoi, contenu encodé en base64. */
|
|
9
|
+
export interface OutgoingAttachment {
|
|
10
|
+
filename: string;
|
|
11
|
+
mime_type: string | null;
|
|
12
|
+
content_base64: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DownloadedAttachment {
|
|
15
|
+
filename: string;
|
|
16
|
+
mime_type: string;
|
|
17
|
+
data: Buffer;
|
|
18
|
+
}
|
|
8
19
|
export interface WaitResult {
|
|
9
20
|
timed_out: boolean;
|
|
10
21
|
message: Message | null;
|
|
@@ -68,9 +79,11 @@ export declare class MessengerClient {
|
|
|
68
79
|
reply_to?: string | null;
|
|
69
80
|
thread_title?: string | null;
|
|
70
81
|
thread_description?: string | null;
|
|
82
|
+
attachments?: OutgoingAttachment[];
|
|
71
83
|
}): Promise<SendMessageResult>;
|
|
72
84
|
listMessages(options?: ListMessagesOptions): Promise<MessageSummary[]>;
|
|
73
85
|
getMessage(messageId: string, markRead?: boolean): Promise<Message>;
|
|
86
|
+
downloadAttachment(attachmentId: string): Promise<DownloadedAttachment>;
|
|
74
87
|
markMessages(messageIds: string[], state: {
|
|
75
88
|
read?: boolean;
|
|
76
89
|
starred?: boolean;
|
|
@@ -104,4 +117,7 @@ export declare class MessengerClient {
|
|
|
104
117
|
watcher?: boolean;
|
|
105
118
|
}): Promise<WaitResult>;
|
|
106
119
|
private request;
|
|
120
|
+
private send;
|
|
121
|
+
/** Erreur lisible tirée d'une réponse en échec. */
|
|
122
|
+
private failure;
|
|
107
123
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { filenameFromDisposition } from "../core/attachments.js";
|
|
1
2
|
/** Échec d'un appel au serveur boîte aux lettres (réseau, authentification ou métier). */
|
|
2
3
|
export class MessengerClientError extends Error {
|
|
3
4
|
status;
|
|
@@ -105,6 +106,16 @@ export class MessengerClient {
|
|
|
105
106
|
query: { mark_read: markRead },
|
|
106
107
|
});
|
|
107
108
|
}
|
|
109
|
+
async downloadAttachment(attachmentId) {
|
|
110
|
+
const response = await this.send("GET", `/api/attachments/${encodeURIComponent(attachmentId)}`);
|
|
111
|
+
if (!response.ok)
|
|
112
|
+
throw await this.failure(response);
|
|
113
|
+
return {
|
|
114
|
+
filename: filenameFromDisposition(response.headers.get("content-disposition")) ?? attachmentId,
|
|
115
|
+
mime_type: response.headers.get("content-type") ?? "application/octet-stream",
|
|
116
|
+
data: Buffer.from(await response.arrayBuffer()),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
108
119
|
async markMessages(messageIds, state) {
|
|
109
120
|
const { updated } = await this.request("POST", "/api/messages/mark", {
|
|
110
121
|
body: { message_ids: messageIds, ...state },
|
|
@@ -160,6 +171,13 @@ export class MessengerClient {
|
|
|
160
171
|
});
|
|
161
172
|
}
|
|
162
173
|
async request(method, routePath, options = {}) {
|
|
174
|
+
const response = await this.send(method, routePath, options);
|
|
175
|
+
if (!response.ok)
|
|
176
|
+
throw await this.failure(response);
|
|
177
|
+
const text = await response.text();
|
|
178
|
+
return (text ? safeParse(text) : null);
|
|
179
|
+
}
|
|
180
|
+
async send(method, routePath, options = {}) {
|
|
163
181
|
const url = new URL(this.config.serverUrl + routePath);
|
|
164
182
|
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
165
183
|
if (value === undefined || value === null || value === "")
|
|
@@ -171,9 +189,8 @@ export class MessengerClient {
|
|
|
171
189
|
headers.authorization = `Bearer ${this.config.token}`;
|
|
172
190
|
if (options.body !== undefined)
|
|
173
191
|
headers["content-type"] = "application/json";
|
|
174
|
-
let response;
|
|
175
192
|
try {
|
|
176
|
-
|
|
193
|
+
return await fetch(url, {
|
|
177
194
|
method,
|
|
178
195
|
headers,
|
|
179
196
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
@@ -184,15 +201,15 @@ export class MessengerClient {
|
|
|
184
201
|
const reason = error instanceof Error ? error.message : String(error);
|
|
185
202
|
throw new MessengerClientError(`cannot reach the messenger server at ${this.config.serverUrl}: ${reason}`);
|
|
186
203
|
}
|
|
204
|
+
}
|
|
205
|
+
/** Erreur lisible tirée d'une réponse en échec. */
|
|
206
|
+
async failure(response) {
|
|
187
207
|
const text = await response.text();
|
|
188
208
|
const payload = text ? safeParse(text) : null;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
throw new MessengerClientError(detail, response.status);
|
|
194
|
-
}
|
|
195
|
-
return payload;
|
|
209
|
+
const detail = payload && typeof payload === "object" && "error" in payload
|
|
210
|
+
? String(payload.error)
|
|
211
|
+
: text.slice(0, 300) || response.statusText;
|
|
212
|
+
return new MessengerClientError(detail, response.status);
|
|
196
213
|
}
|
|
197
214
|
}
|
|
198
215
|
function safeParse(text) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Nombre de pièces jointes par message. */
|
|
2
|
+
export declare const MAX_ATTACHMENTS = 10;
|
|
3
|
+
/** Taille d'une pièce jointe, en octets. */
|
|
4
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
5
|
+
/** Taille cumulée des pièces jointes d'un message, en octets. */
|
|
6
|
+
export declare const MAX_ATTACHMENTS_TOTAL_BYTES: number;
|
|
7
|
+
export declare function formatBytes(bytes: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* En-tête `Content-Disposition` d'un téléchargement.
|
|
10
|
+
*
|
|
11
|
+
* Toujours `attachment` : servi en ligne, un fichier HTML ou SVG s'exécuterait dans
|
|
12
|
+
* l'origine de la console.
|
|
13
|
+
*/
|
|
14
|
+
export declare function contentDisposition(filename: string): string;
|
|
15
|
+
/** Nom de fichier porté par un en-tête produit par `contentDisposition`. */
|
|
16
|
+
export declare function filenameFromDisposition(header: string | null): string | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Nombre de pièces jointes par message. */
|
|
2
|
+
export const MAX_ATTACHMENTS = 10;
|
|
3
|
+
/** Taille d'une pièce jointe, en octets. */
|
|
4
|
+
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
5
|
+
/** Taille cumulée des pièces jointes d'un message, en octets. */
|
|
6
|
+
export const MAX_ATTACHMENTS_TOTAL_BYTES = 25 * 1024 * 1024;
|
|
7
|
+
export function formatBytes(bytes) {
|
|
8
|
+
if (bytes < 1024)
|
|
9
|
+
return `${bytes} B`;
|
|
10
|
+
if (bytes < 1024 * 1024)
|
|
11
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
12
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* En-tête `Content-Disposition` d'un téléchargement.
|
|
16
|
+
*
|
|
17
|
+
* Toujours `attachment` : servi en ligne, un fichier HTML ou SVG s'exécuterait dans
|
|
18
|
+
* l'origine de la console.
|
|
19
|
+
*/
|
|
20
|
+
export function contentDisposition(filename) {
|
|
21
|
+
const ascii = filename.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
22
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
23
|
+
}
|
|
24
|
+
/** Nom de fichier porté par un en-tête produit par `contentDisposition`. */
|
|
25
|
+
export function filenameFromDisposition(header) {
|
|
26
|
+
const encoded = header?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
27
|
+
return encoded ? decodeURIComponent(encoded) : null;
|
|
28
|
+
}
|
package/dist/src/core/store.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import { type AdminThreadSummary, type Agent, type AgentDirectoryEntry, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type ListThreadsOptions, type MailboxStats, type Message, type MessageSummary, type Priority, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
|
|
2
|
+
import { type AdminThreadSummary, type Agent, type AgentDirectoryEntry, type AttachmentInfo, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type ListThreadsOptions, type MailboxStats, type Message, type MessageSummary, type Priority, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
|
|
3
3
|
/** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
|
|
4
4
|
export declare class MessengerError extends Error {
|
|
5
5
|
readonly status: number;
|
|
6
6
|
constructor(message: string, status?: number);
|
|
7
7
|
}
|
|
8
|
+
/** Contenu d'une pièce jointe, avec ses métadonnées. */
|
|
9
|
+
export interface AttachmentContent extends AttachmentInfo {
|
|
10
|
+
message_id: string;
|
|
11
|
+
data: Buffer;
|
|
12
|
+
}
|
|
8
13
|
export interface EnsureAgentOptions {
|
|
9
14
|
registered?: boolean;
|
|
10
15
|
/** Identité de l'agent : d'où il vient. Posée par la configuration. */
|
|
@@ -55,6 +60,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
|
|
|
55
60
|
getMessage(agentId: string, messageId: string, markRead?: boolean): Message;
|
|
56
61
|
/** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
|
|
57
62
|
getThread(agentId: string, threadId: string): Message[];
|
|
63
|
+
/** Pièce jointe d'un message livré à cet agent, contenu compris. */
|
|
64
|
+
getAttachment(agentId: string, attachmentId: string): AttachmentContent;
|
|
58
65
|
getThreadInfo(threadId: string): ThreadInfo | null;
|
|
59
66
|
/**
|
|
60
67
|
* Renomme un fil. Le titre est unique et partagé : les participants parlent du même
|
|
@@ -183,6 +190,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
|
|
|
183
190
|
read: boolean;
|
|
184
191
|
}[];
|
|
185
192
|
};
|
|
193
|
+
/** Pièce jointe vue du serveur, quelle que soit la boîte. */
|
|
194
|
+
getAttachmentAdmin(attachmentId: string): AttachmentContent;
|
|
186
195
|
overview(): {
|
|
187
196
|
messages: number;
|
|
188
197
|
threads: number;
|
|
@@ -196,8 +205,8 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
|
|
|
196
205
|
/** Filtre les ids fournis sur ceux réellement livrés à cet agent (isolation des boîtes). */
|
|
197
206
|
private ownedMessageIds;
|
|
198
207
|
private assertFolderExists;
|
|
199
|
-
/** Destinataire de chaque message demandé, par identifiant de message. */
|
|
200
|
-
private
|
|
208
|
+
/** Destinataire et pièces jointes de chaque message demandé, par identifiant de message. */
|
|
209
|
+
private relatedFor;
|
|
201
210
|
private toSummary;
|
|
202
211
|
private toMessage;
|
|
203
212
|
/** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
|
package/dist/src/core/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
|
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, MAX_ATTACHMENT_BYTES, formatBytes } from "./attachments.js";
|
|
5
6
|
import { newId } from "./ids.js";
|
|
6
7
|
import { DELIVERY_ROLES, SYSTEM_FOLDERS, } from "./types.js";
|
|
7
8
|
/** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
|
|
@@ -18,6 +19,9 @@ const MAX_LIMIT = 100;
|
|
|
18
19
|
const FOLDER_NAME_RE = /^[a-z0-9][a-z0-9 _-]{0,39}$/;
|
|
19
20
|
const THREAD_TITLE_MAX = 200;
|
|
20
21
|
const THREAD_DESCRIPTION_MAX = 2000;
|
|
22
|
+
const FILENAME_MAX = 255;
|
|
23
|
+
const MIME_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
24
|
+
const DEFAULT_MIME_TYPE = "application/octet-stream";
|
|
21
25
|
/**
|
|
22
26
|
* Dernière activité d'une boîte : sa dernière connexion, ou le dernier message qui l'a
|
|
23
27
|
* touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
|
|
@@ -115,6 +119,18 @@ export class MessageStore extends EventEmitter {
|
|
|
115
119
|
PRIMARY KEY (agent_id, name)
|
|
116
120
|
);
|
|
117
121
|
|
|
122
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
123
|
+
id TEXT PRIMARY KEY,
|
|
124
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
125
|
+
position INTEGER NOT NULL,
|
|
126
|
+
filename TEXT NOT NULL,
|
|
127
|
+
mime_type TEXT NOT NULL,
|
|
128
|
+
size INTEGER NOT NULL,
|
|
129
|
+
data BLOB NOT NULL,
|
|
130
|
+
created_at TEXT NOT NULL
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments (message_id, position);
|
|
118
134
|
CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
|
|
119
135
|
CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
|
|
120
136
|
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
|
|
@@ -223,6 +239,7 @@ export class MessageStore extends EventEmitter {
|
|
|
223
239
|
throw new MessengerError("an agent cannot send a message to itself");
|
|
224
240
|
const title = normalizeLabel(input.thread_title, THREAD_TITLE_MAX, "thread_title");
|
|
225
241
|
const threadDescription = normalizeLabel(input.thread_description, THREAD_DESCRIPTION_MAX, "thread_description");
|
|
242
|
+
const attachments = normalizeAttachments(input.attachments ?? []);
|
|
226
243
|
let threadId;
|
|
227
244
|
let inReplyTo = null;
|
|
228
245
|
if (input.reply_to) {
|
|
@@ -262,6 +279,11 @@ export class MessageStore extends EventEmitter {
|
|
|
262
279
|
.prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
|
|
263
280
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
264
281
|
.run(messageId, threadId, from, subject, input.body, priority, inReplyTo, now);
|
|
282
|
+
const insertAttachment = this.db.prepare(`INSERT INTO attachments (id, message_id, position, filename, mime_type, size, data, created_at)
|
|
283
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
284
|
+
attachments.forEach((a, position) => {
|
|
285
|
+
insertAttachment.run(newId("att"), messageId, position, a.filename, a.mime_type, a.data.length, a.data, now);
|
|
286
|
+
});
|
|
265
287
|
const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
|
|
266
288
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`);
|
|
267
289
|
for (const d of deliveries) {
|
|
@@ -316,8 +338,8 @@ export class MessageStore extends EventEmitter {
|
|
|
316
338
|
ORDER BY m.created_at DESC, m.id DESC
|
|
317
339
|
LIMIT ? OFFSET ?`)
|
|
318
340
|
.all(...params, limit, offset);
|
|
319
|
-
const
|
|
320
|
-
return rows.map((row) => this.toSummary(row,
|
|
341
|
+
const related = this.relatedFor(rows.map((r) => r.message_id));
|
|
342
|
+
return rows.map((row) => this.toSummary(row, related));
|
|
321
343
|
}
|
|
322
344
|
getMessage(agentId, messageId, markRead = false) {
|
|
323
345
|
const row = this.db
|
|
@@ -339,8 +361,8 @@ export class MessageStore extends EventEmitter {
|
|
|
339
361
|
.run(now, agentId, messageId);
|
|
340
362
|
row.read_at = now;
|
|
341
363
|
}
|
|
342
|
-
const
|
|
343
|
-
return this.toMessage(row,
|
|
364
|
+
const related = this.relatedFor([messageId]);
|
|
365
|
+
return this.toMessage(row, related);
|
|
344
366
|
}
|
|
345
367
|
/** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
|
|
346
368
|
getThread(agentId, threadId) {
|
|
@@ -357,8 +379,20 @@ export class MessageStore extends EventEmitter {
|
|
|
357
379
|
.all(agentId, threadId);
|
|
358
380
|
if (rows.length === 0)
|
|
359
381
|
throw new MessengerError(`thread not found in this mailbox: ${threadId}`, 404);
|
|
360
|
-
const
|
|
361
|
-
return rows.map((row) => this.toMessage(row,
|
|
382
|
+
const related = this.relatedFor(rows.map((r) => r.message_id));
|
|
383
|
+
return rows.map((row) => this.toMessage(row, related));
|
|
384
|
+
}
|
|
385
|
+
/** Pièce jointe d'un message livré à cet agent, contenu compris. */
|
|
386
|
+
getAttachment(agentId, attachmentId) {
|
|
387
|
+
const row = this.db
|
|
388
|
+
.prepare(`SELECT a.id AS attachment_id, a.message_id, a.filename, a.mime_type, a.size, a.data
|
|
389
|
+
FROM attachments a
|
|
390
|
+
WHERE a.id = ?
|
|
391
|
+
AND EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = a.message_id AND d.agent_id = ?)`)
|
|
392
|
+
.get(attachmentId, agentId);
|
|
393
|
+
if (!row)
|
|
394
|
+
throw new MessengerError(`attachment not found in this mailbox: ${attachmentId}`, 404);
|
|
395
|
+
return row;
|
|
362
396
|
}
|
|
363
397
|
// --- Fils ---------------------------------------------------------------
|
|
364
398
|
getThreadInfo(threadId) {
|
|
@@ -710,8 +744,8 @@ export class MessageStore extends EventEmitter {
|
|
|
710
744
|
ORDER BY m.created_at DESC, m.id DESC
|
|
711
745
|
LIMIT ? OFFSET ?`)
|
|
712
746
|
.all(...params, limit, offset);
|
|
713
|
-
const
|
|
714
|
-
return rows.map((row) => this.toSummary(row,
|
|
747
|
+
const related = this.relatedFor(rows.map((r) => r.message_id));
|
|
748
|
+
return rows.map((row) => this.toSummary(row, related));
|
|
715
749
|
}
|
|
716
750
|
/**
|
|
717
751
|
* Fils du serveur entier, le plus récemment actif en tête.
|
|
@@ -806,8 +840,8 @@ export class MessageStore extends EventEmitter {
|
|
|
806
840
|
WHERE m.thread_id = ?
|
|
807
841
|
ORDER BY m.created_at ASC, m.id ASC`)
|
|
808
842
|
.all(threadId);
|
|
809
|
-
const
|
|
810
|
-
return rows.map((row) => ({ ...this.toSummary(row,
|
|
843
|
+
const related = this.relatedFor(rows.map((r) => r.message_id));
|
|
844
|
+
return rows.map((row) => ({ ...this.toSummary(row, related), body: row.body }));
|
|
811
845
|
}
|
|
812
846
|
/** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
|
|
813
847
|
getMessageAdmin(messageId) {
|
|
@@ -818,12 +852,12 @@ export class MessageStore extends EventEmitter {
|
|
|
818
852
|
.get(messageId);
|
|
819
853
|
if (!row)
|
|
820
854
|
throw new MessengerError(`message not found: ${messageId}`, 404);
|
|
821
|
-
const
|
|
855
|
+
const related = this.relatedFor([messageId]);
|
|
822
856
|
const deliveries = this.db
|
|
823
857
|
.prepare("SELECT agent_id, role, folder, read_at FROM deliveries WHERE message_id = ? ORDER BY role, agent_id")
|
|
824
858
|
.all(messageId);
|
|
825
859
|
return {
|
|
826
|
-
...this.toMessage(row,
|
|
860
|
+
...this.toMessage(row, related),
|
|
827
861
|
deliveries: deliveries.map((d) => ({
|
|
828
862
|
agent_id: d.agent_id,
|
|
829
863
|
role: d.role,
|
|
@@ -832,6 +866,16 @@ export class MessageStore extends EventEmitter {
|
|
|
832
866
|
})),
|
|
833
867
|
};
|
|
834
868
|
}
|
|
869
|
+
/** Pièce jointe vue du serveur, quelle que soit la boîte. */
|
|
870
|
+
getAttachmentAdmin(attachmentId) {
|
|
871
|
+
const row = this.db
|
|
872
|
+
.prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size, data
|
|
873
|
+
FROM attachments WHERE id = ?`)
|
|
874
|
+
.get(attachmentId);
|
|
875
|
+
if (!row)
|
|
876
|
+
throw new MessengerError(`attachment not found: ${attachmentId}`, 404);
|
|
877
|
+
return row;
|
|
878
|
+
}
|
|
835
879
|
overview() {
|
|
836
880
|
const messages = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get().n;
|
|
837
881
|
const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
|
|
@@ -878,28 +922,36 @@ export class MessageStore extends EventEmitter {
|
|
|
878
922
|
if (!row)
|
|
879
923
|
throw new MessengerError(`unknown folder: ${folder}`, 404);
|
|
880
924
|
}
|
|
881
|
-
/** Destinataire de chaque message demandé, par identifiant de message. */
|
|
882
|
-
|
|
883
|
-
const
|
|
925
|
+
/** Destinataire et pièces jointes de chaque message demandé, par identifiant de message. */
|
|
926
|
+
relatedFor(messageIds) {
|
|
927
|
+
const related = { recipients: new Map(), attachments: new Map() };
|
|
884
928
|
const ids = dedupe(messageIds);
|
|
885
929
|
if (ids.length === 0)
|
|
886
|
-
return
|
|
930
|
+
return related;
|
|
887
931
|
const placeholders = ids.map(() => "?").join(",");
|
|
888
|
-
const
|
|
932
|
+
const recipients = this.db
|
|
889
933
|
.prepare(`SELECT message_id, agent_id FROM deliveries
|
|
890
934
|
WHERE message_id IN (${placeholders}) AND role = 'to'`)
|
|
891
935
|
.all(...ids);
|
|
892
|
-
for (const row of
|
|
893
|
-
|
|
894
|
-
|
|
936
|
+
for (const row of recipients)
|
|
937
|
+
related.recipients.set(row.message_id, row.agent_id);
|
|
938
|
+
const attachments = this.db
|
|
939
|
+
.prepare(`SELECT id AS attachment_id, message_id, filename, mime_type, size FROM attachments
|
|
940
|
+
WHERE message_id IN (${placeholders})
|
|
941
|
+
ORDER BY message_id, position`)
|
|
942
|
+
.all(...ids);
|
|
943
|
+
for (const { message_id, ...info } of attachments) {
|
|
944
|
+
related.attachments.set(message_id, [...(related.attachments.get(message_id) ?? []), info]);
|
|
945
|
+
}
|
|
946
|
+
return related;
|
|
895
947
|
}
|
|
896
|
-
toSummary(row,
|
|
948
|
+
toSummary(row, related) {
|
|
897
949
|
return {
|
|
898
950
|
message_id: row.message_id,
|
|
899
951
|
thread_id: row.thread_id,
|
|
900
952
|
thread_title: row.thread_title,
|
|
901
953
|
from: row.sender_id,
|
|
902
|
-
to: recipients.get(row.message_id) ?? "",
|
|
954
|
+
to: related.recipients.get(row.message_id) ?? "",
|
|
903
955
|
subject: row.subject,
|
|
904
956
|
preview: preview(row.body),
|
|
905
957
|
priority: row.priority,
|
|
@@ -909,10 +961,11 @@ export class MessageStore extends EventEmitter {
|
|
|
909
961
|
starred: Boolean(row.starred),
|
|
910
962
|
created_at: row.created_at,
|
|
911
963
|
in_reply_to: row.in_reply_to,
|
|
964
|
+
attachments: related.attachments.get(row.message_id) ?? [],
|
|
912
965
|
};
|
|
913
966
|
}
|
|
914
|
-
toMessage(row,
|
|
915
|
-
const { preview: _preview, ...summary } = this.toSummary(row,
|
|
967
|
+
toMessage(row, related) {
|
|
968
|
+
const { preview: _preview, ...summary } = this.toSummary(row, related);
|
|
916
969
|
return { ...summary, body: row.body };
|
|
917
970
|
}
|
|
918
971
|
/** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
|
|
@@ -941,6 +994,47 @@ function normalizeLabel(value, max, field) {
|
|
|
941
994
|
throw new MessengerError(`${field} must be at most ${max} characters`);
|
|
942
995
|
return trimmed;
|
|
943
996
|
}
|
|
997
|
+
/**
|
|
998
|
+
* Valide les pièces jointes d'un envoi : nombre, tailles, nom et type.
|
|
999
|
+
*
|
|
1000
|
+
* Le nom ne garde que sa dernière composante : il sert à nommer un fichier chez le
|
|
1001
|
+
* destinataire, et ne doit jamais désigner un autre répertoire que celui qu'il choisit.
|
|
1002
|
+
*/
|
|
1003
|
+
function normalizeAttachments(attachments) {
|
|
1004
|
+
if (attachments.length > MAX_ATTACHMENTS) {
|
|
1005
|
+
throw new MessengerError(`at most ${MAX_ATTACHMENTS} attachments per message`);
|
|
1006
|
+
}
|
|
1007
|
+
let total = 0;
|
|
1008
|
+
const normalized = attachments.map((attachment) => {
|
|
1009
|
+
const filename = sanitizeFilename(attachment.filename);
|
|
1010
|
+
if (attachment.data.length === 0)
|
|
1011
|
+
throw new MessengerError(`attachment is empty: ${filename}`);
|
|
1012
|
+
if (attachment.data.length > MAX_ATTACHMENT_BYTES) {
|
|
1013
|
+
throw new MessengerError(`attachment exceeds ${formatBytes(MAX_ATTACHMENT_BYTES)}: ${filename}`, 413);
|
|
1014
|
+
}
|
|
1015
|
+
total += attachment.data.length;
|
|
1016
|
+
const mimeType = (attachment.mime_type ?? "").trim().toLowerCase() || DEFAULT_MIME_TYPE;
|
|
1017
|
+
if (!MIME_TYPE_RE.test(mimeType))
|
|
1018
|
+
throw new MessengerError(`invalid mime_type for ${filename}: ${mimeType}`);
|
|
1019
|
+
return { filename, mime_type: mimeType, data: attachment.data };
|
|
1020
|
+
});
|
|
1021
|
+
if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
|
|
1022
|
+
throw new MessengerError(`attachments exceed ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total`, 413);
|
|
1023
|
+
}
|
|
1024
|
+
return normalized;
|
|
1025
|
+
}
|
|
1026
|
+
function sanitizeFilename(value) {
|
|
1027
|
+
const base = String(value ?? "")
|
|
1028
|
+
.split(/[\\/]/)
|
|
1029
|
+
.pop()
|
|
1030
|
+
.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, "_")
|
|
1031
|
+
.trim();
|
|
1032
|
+
if (!base || base === "." || base === "..")
|
|
1033
|
+
throw new MessengerError("attachment filename cannot be empty");
|
|
1034
|
+
if (base.length > FILENAME_MAX)
|
|
1035
|
+
throw new MessengerError(`attachment filename must be at most ${FILENAME_MAX} characters`);
|
|
1036
|
+
return base;
|
|
1037
|
+
}
|
|
944
1038
|
function dedupe(values) {
|
|
945
1039
|
return [...new Set(values)];
|
|
946
1040
|
}
|
package/dist/src/core/types.d.ts
CHANGED
|
@@ -35,6 +35,21 @@ export interface AgentDirectoryEntry extends Agent {
|
|
|
35
35
|
/** Plus récent de la dernière connexion et de la dernière livraison ; null si aucune. */
|
|
36
36
|
last_activity_at: string | null;
|
|
37
37
|
}
|
|
38
|
+
/** Pièce jointe telle qu'elle voyage avec un message : ses métadonnées, sans son contenu. */
|
|
39
|
+
export interface AttachmentInfo {
|
|
40
|
+
attachment_id: string;
|
|
41
|
+
filename: string;
|
|
42
|
+
mime_type: string;
|
|
43
|
+
/** Taille en octets. */
|
|
44
|
+
size: number;
|
|
45
|
+
}
|
|
46
|
+
/** Pièce jointe fournie à l'envoi. */
|
|
47
|
+
export interface AttachmentUpload {
|
|
48
|
+
filename: string;
|
|
49
|
+
/** Type MIME ; `application/octet-stream` s'il est absent. */
|
|
50
|
+
mime_type?: string | null;
|
|
51
|
+
data: Buffer;
|
|
52
|
+
}
|
|
38
53
|
export interface SendMessageInput {
|
|
39
54
|
from: string;
|
|
40
55
|
/** Un échange lie exactement deux boîtes : un seul destinataire par message. */
|
|
@@ -47,6 +62,7 @@ export interface SendMessageInput {
|
|
|
47
62
|
/** Nom lisible donné au fil. Sur une réponse, renomme le fil existant. */
|
|
48
63
|
thread_title?: string | null;
|
|
49
64
|
thread_description?: string | null;
|
|
65
|
+
attachments?: AttachmentUpload[];
|
|
50
66
|
}
|
|
51
67
|
/**
|
|
52
68
|
* Intitulé d'un fil de discussion.
|
|
@@ -98,6 +114,7 @@ export interface MessageSummary {
|
|
|
98
114
|
starred: boolean;
|
|
99
115
|
created_at: string;
|
|
100
116
|
in_reply_to: string | null;
|
|
117
|
+
attachments: AttachmentInfo[];
|
|
101
118
|
}
|
|
102
119
|
export interface Message extends Omit<MessageSummary, "preview"> {
|
|
103
120
|
body: string;
|
package/dist/src/mcp/start.js
CHANGED
|
@@ -39,7 +39,8 @@ Resuming earlier work:
|
|
|
39
39
|
Writing:
|
|
40
40
|
- One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
|
|
41
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.
|
|
42
|
-
- Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you
|
|
42
|
+
- Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.
|
|
43
|
+
- The host stops that watcher after 30 minutes at most. Each time it tells you the watcher expired, re-arm it at once with the same Monitor call; otherwise notifications stop for good.`;
|
|
43
44
|
/** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
|
|
44
45
|
export function createMcpServer(client) {
|
|
45
46
|
const server = new Server({ name: "mcp-messenger", version: APP_VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { z } from "zod";
|
|
2
5
|
import { writeHandoff } from "../cli/watch-handoff.js";
|
|
6
|
+
import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, MAX_ATTACHMENT_BYTES, formatBytes } from "../core/attachments.js";
|
|
3
7
|
import { PRIORITIES, SYSTEM_FOLDERS } from "../core/types.js";
|
|
4
8
|
import { rebind } from "./mailbox-registry.js";
|
|
5
9
|
import { forgetCandidate, getResumeState } from "./resume-state.js";
|
|
6
10
|
/** Nom publié : l'agent recopie la commande, il n'a pas à connaître de chemin local. */
|
|
7
11
|
const PACKAGE_NAME = "@imenam/mcp-messenger";
|
|
12
|
+
/** Durée maximale d'un `Monitor` côté hôte : au-delà, l'hôte la ramène à cette valeur. */
|
|
13
|
+
const MONITOR_TIMEOUT_MS = 1_800_000;
|
|
14
|
+
/** Répertoire où `save_attachment` écrit quand l'agent ne donne pas de destination. */
|
|
15
|
+
const DEFAULT_ATTACHMENT_DIR = path.join(os.tmpdir(), "mcp-messenger", "attachments");
|
|
16
|
+
/** Type MIME déduit de l'extension, pour les formats courants. */
|
|
17
|
+
const MIME_TYPES = {
|
|
18
|
+
".txt": "text/plain",
|
|
19
|
+
".md": "text/markdown",
|
|
20
|
+
".csv": "text/csv",
|
|
21
|
+
".json": "application/json",
|
|
22
|
+
".xml": "application/xml",
|
|
23
|
+
".yaml": "application/yaml",
|
|
24
|
+
".yml": "application/yaml",
|
|
25
|
+
".html": "text/html",
|
|
26
|
+
".css": "text/css",
|
|
27
|
+
".js": "text/javascript",
|
|
28
|
+
".log": "text/plain",
|
|
29
|
+
".pdf": "application/pdf",
|
|
30
|
+
".png": "image/png",
|
|
31
|
+
".jpg": "image/jpeg",
|
|
32
|
+
".jpeg": "image/jpeg",
|
|
33
|
+
".gif": "image/gif",
|
|
34
|
+
".webp": "image/webp",
|
|
35
|
+
".svg": "image/svg+xml",
|
|
36
|
+
".zip": "application/zip",
|
|
37
|
+
".gz": "application/gzip",
|
|
38
|
+
".tar": "application/x-tar",
|
|
39
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
40
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
41
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
42
|
+
};
|
|
8
43
|
const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
|
|
9
44
|
export const TOOLS = [
|
|
10
45
|
{
|
|
@@ -105,6 +140,23 @@ export const TOOLS = [
|
|
|
105
140
|
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).",
|
|
106
141
|
},
|
|
107
142
|
thread_description: { type: "string", description: "Optional longer description of the thread." },
|
|
143
|
+
attachments: {
|
|
144
|
+
type: "array",
|
|
145
|
+
maxItems: MAX_ATTACHMENTS,
|
|
146
|
+
description: `Files to attach, read from this machine's disk. At most ${MAX_ATTACHMENTS} files, ${formatBytes(MAX_ATTACHMENT_BYTES)} each and ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total. The recipient saves them with save_attachment.`,
|
|
147
|
+
items: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
path: {
|
|
151
|
+
type: "string",
|
|
152
|
+
description: "Path of the file to attach. Prefer an absolute path; a relative one is resolved against this MCP server's working directory.",
|
|
153
|
+
},
|
|
154
|
+
name: { type: "string", description: "File name shown to the recipient. Defaults to the file's own name." },
|
|
155
|
+
},
|
|
156
|
+
required: ["path"],
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
108
160
|
},
|
|
109
161
|
required: ["to", "subject", "body"],
|
|
110
162
|
additionalProperties: false,
|
|
@@ -150,6 +202,23 @@ export const TOOLS = [
|
|
|
150
202
|
additionalProperties: false,
|
|
151
203
|
},
|
|
152
204
|
},
|
|
205
|
+
{
|
|
206
|
+
name: "save_attachment",
|
|
207
|
+
description: "Save a file attached to a message of this mailbox onto this machine's disk, and return the path it was written to. Attachment ids are listed under the message by read_message and get_thread. Read the saved file with your usual file tools.",
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: "object",
|
|
210
|
+
properties: {
|
|
211
|
+
attachment_id: { type: "string", description: "Attachment id (att_…) shown under the message." },
|
|
212
|
+
path: {
|
|
213
|
+
type: "string",
|
|
214
|
+
description: "Where to save it: an existing directory (the file keeps its name) or a file path. Defaults to a directory under the system temp folder.",
|
|
215
|
+
},
|
|
216
|
+
overwrite: { type: "boolean", description: "Replace an existing file at that path. Defaults to false." },
|
|
217
|
+
},
|
|
218
|
+
required: ["attachment_id"],
|
|
219
|
+
additionalProperties: false,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
153
222
|
{
|
|
154
223
|
name: "mark_messages",
|
|
155
224
|
description: "Mark messages as read or unread, and/or star or unstar them.",
|
|
@@ -217,7 +286,7 @@ export const TOOLS = [
|
|
|
217
286
|
},
|
|
218
287
|
{
|
|
219
288
|
name: "enable_notifications",
|
|
220
|
-
description: "Get notified of incoming messages without waiting for them. Returns immediately: it blocks nothing and starts nothing on its own. It hands you the exact Monitor call to make right after — arming that watcher is what actually delivers notifications, and until you do it no message will reach you. Call this once at the start of a session, before any other work; then just keep working, incoming messages will interrupt you on their own.",
|
|
289
|
+
description: "Get notified of incoming messages without waiting for them. Returns immediately: it blocks nothing and starts nothing on its own. It hands you the exact Monitor call to make right after — arming that watcher is what actually delivers notifications, and until you do it no message will reach you. Call this once at the start of a session, before any other work; then just keep working, incoming messages will interrupt you on their own. The host stops the watcher after 30 minutes at most: re-arm it with the same Monitor call each time it expires.",
|
|
221
290
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
222
291
|
},
|
|
223
292
|
{
|
|
@@ -242,6 +311,15 @@ const schemas = {
|
|
|
242
311
|
reply_to: z.string().optional(),
|
|
243
312
|
thread_name: z.string().trim().min(1).max(200).optional(),
|
|
244
313
|
thread_description: z.string().trim().min(1).max(2000).optional(),
|
|
314
|
+
attachments: z
|
|
315
|
+
.array(z.object({ path: z.string().trim().min(1), name: z.string().trim().min(1).max(255).optional() }))
|
|
316
|
+
.max(MAX_ATTACHMENTS)
|
|
317
|
+
.optional(),
|
|
318
|
+
}),
|
|
319
|
+
save_attachment: z.object({
|
|
320
|
+
attachment_id: z.string().trim().min(1),
|
|
321
|
+
path: z.string().trim().min(1).optional(),
|
|
322
|
+
overwrite: z.boolean().optional(),
|
|
245
323
|
}),
|
|
246
324
|
list_messages: z.object({
|
|
247
325
|
folder: z.string().optional(),
|
|
@@ -423,6 +501,7 @@ export async function callTool(client, name, rawArgs) {
|
|
|
423
501
|
}
|
|
424
502
|
case "send_message": {
|
|
425
503
|
const input = schemas.send_message.parse(args);
|
|
504
|
+
const attachments = await readAttachments(input.attachments ?? []);
|
|
426
505
|
const result = await client.sendMessage({
|
|
427
506
|
to: input.to,
|
|
428
507
|
subject: input.subject,
|
|
@@ -432,12 +511,16 @@ export async function callTool(client, name, rawArgs) {
|
|
|
432
511
|
// Le protocole HTTP et le stockage gardent `thread_title` ; seul l'outil dit `thread_name`.
|
|
433
512
|
thread_title: input.thread_name ?? null,
|
|
434
513
|
thread_description: input.thread_description ?? null,
|
|
514
|
+
attachments,
|
|
435
515
|
});
|
|
436
516
|
const lines = [
|
|
437
517
|
`Message sent: ${result.message_id}`,
|
|
438
518
|
`Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
|
|
439
519
|
`Delivered to: ${result.delivered_to}`,
|
|
440
520
|
];
|
|
521
|
+
if (attachments.length > 0) {
|
|
522
|
+
lines.push(`Attachments: ${attachments.map((a) => a.filename).join(", ")}`);
|
|
523
|
+
}
|
|
441
524
|
if (!result.thread_title && !input.reply_to) {
|
|
442
525
|
lines.push("This thread has no name yet — rename_thread with its thread_id makes it readable for everyone.");
|
|
443
526
|
}
|
|
@@ -470,6 +553,22 @@ export async function callTool(client, name, rawArgs) {
|
|
|
470
553
|
.join("\n");
|
|
471
554
|
return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
|
|
472
555
|
}
|
|
556
|
+
case "save_attachment": {
|
|
557
|
+
const input = schemas.save_attachment.parse(args);
|
|
558
|
+
const attachment = await client.downloadAttachment(input.attachment_id);
|
|
559
|
+
const target = await attachmentTarget(input.path, input.attachment_id, attachment.filename);
|
|
560
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
561
|
+
try {
|
|
562
|
+
await writeFile(target, attachment.data, { flag: input.overwrite ? "w" : "wx" });
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (error.code === "EEXIST") {
|
|
566
|
+
throw new Error(`a file already exists at ${target} — pass overwrite: true or choose another path`);
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
return `Saved ${attachment.filename} (${attachment.mime_type}, ${formatBytes(attachment.data.length)}) to ${target}`;
|
|
571
|
+
}
|
|
473
572
|
case "mark_messages": {
|
|
474
573
|
const input = schemas.mark_messages.parse(args);
|
|
475
574
|
if (input.read === undefined && input.starred === undefined) {
|
|
@@ -522,6 +621,9 @@ export async function callTool(client, name, rawArgs) {
|
|
|
522
621
|
`Notifications are already live on mailbox \`${mailbox}\` (watcher armed since ${stats.watcher.since}).`,
|
|
523
622
|
"Nothing to do: keep working, incoming messages will interrupt you on their own.",
|
|
524
623
|
"Do not arm a second watcher — you would get every message twice.",
|
|
624
|
+
"Only exception: if the host just told you your watcher expired or stopped, re-arm it",
|
|
625
|
+
`with the same Monitor call (command \`npx -y ${PACKAGE_NAME} --watch ${mailbox}\`,`,
|
|
626
|
+
`timeout_ms ${MONITOR_TIMEOUT_MS}).`,
|
|
525
627
|
].join("\n");
|
|
526
628
|
}
|
|
527
629
|
const lines = [
|
|
@@ -530,11 +632,15 @@ export async function callTool(client, name, rawArgs) {
|
|
|
530
632
|
"Last step — call the Monitor tool now, with exactly these arguments:",
|
|
531
633
|
` command: npx -y ${PACKAGE_NAME} --watch ${mailbox}`,
|
|
532
634
|
" description: Incoming messenger messages",
|
|
533
|
-
|
|
635
|
+
` timeout_ms: ${MONITOR_TIMEOUT_MS}`,
|
|
534
636
|
"",
|
|
535
637
|
"Until that watcher is armed, nothing will reach you: no message will be announced.",
|
|
536
638
|
"Once it is, keep working normally — each incoming message shows up on its own, even",
|
|
537
639
|
"while you are idle. One line per message; read it with read_message.",
|
|
640
|
+
"",
|
|
641
|
+
"The host stops every monitor after 30 minutes at most. Each time it tells you this",
|
|
642
|
+
"watcher expired, call Monitor again right away with the same arguments — do not call",
|
|
643
|
+
"enable_notifications again. Skip that and notifications stop for good.",
|
|
538
644
|
];
|
|
539
645
|
if (client.isAttachedElsewhere) {
|
|
540
646
|
lines.push("", `Note: you are attached to \`${mailbox}\`, not to this session's own mailbox ` +
|
|
@@ -558,6 +664,42 @@ export async function callTool(client, name, rawArgs) {
|
|
|
558
664
|
throw new Error(`Unknown tool: ${name}`);
|
|
559
665
|
}
|
|
560
666
|
}
|
|
667
|
+
/** Lit sur le disque local les fichiers à joindre, en refusant d'emblée ceux qui dépassent les limites. */
|
|
668
|
+
async function readAttachments(entries) {
|
|
669
|
+
let total = 0;
|
|
670
|
+
const attachments = [];
|
|
671
|
+
for (const entry of entries) {
|
|
672
|
+
const file = path.resolve(entry.path);
|
|
673
|
+
const info = await stat(file).catch(() => null);
|
|
674
|
+
if (!info?.isFile())
|
|
675
|
+
throw new Error(`attachment is not a readable file: ${file}`);
|
|
676
|
+
if (info.size > MAX_ATTACHMENT_BYTES) {
|
|
677
|
+
throw new Error(`attachment exceeds ${formatBytes(MAX_ATTACHMENT_BYTES)}: ${file} (${formatBytes(info.size)})`);
|
|
678
|
+
}
|
|
679
|
+
total += info.size;
|
|
680
|
+
if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
|
|
681
|
+
throw new Error(`attachments exceed ${formatBytes(MAX_ATTACHMENTS_TOTAL_BYTES)} in total`);
|
|
682
|
+
}
|
|
683
|
+
const filename = entry.name ?? path.basename(file);
|
|
684
|
+
attachments.push({
|
|
685
|
+
filename,
|
|
686
|
+
mime_type: MIME_TYPES[path.extname(filename).toLowerCase()] ?? null,
|
|
687
|
+
content_base64: (await readFile(file)).toString("base64"),
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
return attachments;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Chemin d'écriture d'une pièce jointe : dans le répertoire donné sous son propre nom, au
|
|
694
|
+
* chemin donné s'il désigne un fichier, ou dans un répertoire temporaire propre à la pièce.
|
|
695
|
+
*/
|
|
696
|
+
async function attachmentTarget(requested, attachmentId, filename) {
|
|
697
|
+
if (!requested)
|
|
698
|
+
return path.join(DEFAULT_ATTACHMENT_DIR, attachmentId, filename);
|
|
699
|
+
const resolved = path.resolve(requested);
|
|
700
|
+
const isDirectory = /[\\/]$/.test(requested) || (await stat(resolved).catch(() => null))?.isDirectory();
|
|
701
|
+
return isDirectory ? path.join(resolved, filename) : resolved;
|
|
702
|
+
}
|
|
561
703
|
async function renameMailbox(client, name, description) {
|
|
562
704
|
const agent = await client.updateProfile({ display_name: name, description });
|
|
563
705
|
return [
|
|
@@ -743,7 +885,7 @@ function formatSummary(message) {
|
|
|
743
885
|
`${flags} ${message.subject}${priority}`,
|
|
744
886
|
` from ${message.from} to ${message.to || "—"} · ${message.created_at}`,
|
|
745
887
|
` ${message.preview}`,
|
|
746
|
-
` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
|
|
888
|
+
` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}${message.attachments.length > 0 ? ` · ${message.attachments.length} attachment(s)` : ""}`,
|
|
747
889
|
].join("\n");
|
|
748
890
|
}
|
|
749
891
|
function formatMessage(message) {
|
|
@@ -758,5 +900,11 @@ function formatMessage(message) {
|
|
|
758
900
|
]
|
|
759
901
|
.filter(Boolean)
|
|
760
902
|
.join("\n");
|
|
761
|
-
|
|
903
|
+
const attachments = message.attachments.length > 0
|
|
904
|
+
? `\n\nAttachments (save with save_attachment):\n${message.attachments.map(formatAttachment).join("\n")}`
|
|
905
|
+
: "";
|
|
906
|
+
return `${header}\n\n${message.body}${attachments}`;
|
|
907
|
+
}
|
|
908
|
+
function formatAttachment(attachment) {
|
|
909
|
+
return ` ${attachment.attachment_id} · ${attachment.filename} (${attachment.mime_type}, ${formatBytes(attachment.size)})`;
|
|
762
910
|
}
|
package/dist/src/server/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { timingSafeEqual } from "node:crypto";
|
|
6
6
|
import { rateLimit } from "express-rate-limit";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
+
import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_TOTAL_BYTES, contentDisposition } from "../core/attachments.js";
|
|
8
9
|
import { MessengerError, normalizeAgentId } from "../core/store.js";
|
|
9
10
|
import { PRIORITIES } from "../core/types.js";
|
|
10
11
|
import { WatcherRegistry } from "./watchers.js";
|
|
@@ -12,6 +13,9 @@ import { CONSOLE_COOKIE, ConsoleSessions, consoleSessionId } from "./console-ses
|
|
|
12
13
|
import { APP_VERSION } from "../version.js";
|
|
13
14
|
import { logger } from "../logger.js";
|
|
14
15
|
const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
|
|
16
|
+
const JSON_TYPES = ["application/json", "application/*+json"];
|
|
17
|
+
/** Corps d'un envoi : le texte, plus les pièces jointes encodées en base64 (4 octets pour 3). */
|
|
18
|
+
const SEND_BODY_LIMIT = Math.ceil((MAX_ATTACHMENTS_TOTAL_BYTES * 4) / 3) + 1024 * 1024;
|
|
15
19
|
const agentIdSchema = z.string().trim().min(1).max(128);
|
|
16
20
|
const idListSchema = z.array(z.string().trim().min(1)).min(1).max(200);
|
|
17
21
|
/** Nom et description de la boîte : ce que l'agent pose lui-même sur sa session. */
|
|
@@ -34,6 +38,14 @@ const sendSchema = z.object({
|
|
|
34
38
|
reply_to: z.string().trim().min(1).nullish(),
|
|
35
39
|
thread_title: z.string().trim().min(1).max(200).nullish(),
|
|
36
40
|
thread_description: z.string().trim().min(1).max(2000).nullish(),
|
|
41
|
+
attachments: z
|
|
42
|
+
.array(z.object({
|
|
43
|
+
filename: z.string().trim().min(1).max(255),
|
|
44
|
+
mime_type: z.string().trim().max(255).nullish(),
|
|
45
|
+
content_base64: z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/, "expected base64 content"),
|
|
46
|
+
}))
|
|
47
|
+
.max(MAX_ATTACHMENTS)
|
|
48
|
+
.optional(),
|
|
37
49
|
});
|
|
38
50
|
const threadLabelSchema = z
|
|
39
51
|
.object({
|
|
@@ -151,7 +163,10 @@ export function createApp({ store, config }) {
|
|
|
151
163
|
},
|
|
152
164
|
crossOriginEmbedderPolicy: false,
|
|
153
165
|
}));
|
|
154
|
-
|
|
166
|
+
// L'envoi d'un message a sa propre limite, plus large pour ses pièces jointes, et ne
|
|
167
|
+
// s'applique qu'une fois l'agent authentifié.
|
|
168
|
+
const jsonBody = express.json({ type: JSON_TYPES, limit: "2mb" });
|
|
169
|
+
app.use((req, res, next) => (isSendRequest(req) ? next() : jsonBody(req, res, next)));
|
|
155
170
|
app.use(rateLimit({
|
|
156
171
|
windowMs: config.rateLimit.windowMs,
|
|
157
172
|
limit: config.rateLimit.max,
|
|
@@ -209,6 +224,9 @@ export function createApp({ store, config }) {
|
|
|
209
224
|
admin.get("/messages/:id", (req, res) => {
|
|
210
225
|
res.json(store.getMessageAdmin(String(req.params.id)));
|
|
211
226
|
});
|
|
227
|
+
admin.get("/attachments/:id", (req, res) => {
|
|
228
|
+
sendAttachment(res, store.getAttachmentAdmin(String(req.params.id)));
|
|
229
|
+
});
|
|
212
230
|
// Déclarée avant `/threads/:id` : la console lit d'abord les fils, le détail ensuite.
|
|
213
231
|
admin.get("/threads", (req, res) => {
|
|
214
232
|
const query = adminThreadsSchema.parse(req.query);
|
|
@@ -373,7 +391,7 @@ export function createApp({ store, config }) {
|
|
|
373
391
|
api.get("/mailbox", (req, res) => {
|
|
374
392
|
res.json({ ...store.stats(req.agentId), watcher: watchers.status(req.agentId) });
|
|
375
393
|
});
|
|
376
|
-
api.post("/messages", (req, res) => {
|
|
394
|
+
api.post("/messages", express.json({ type: JSON_TYPES, limit: SEND_BODY_LIMIT }), (req, res) => {
|
|
377
395
|
const input = sendSchema.parse(req.body ?? {});
|
|
378
396
|
const result = store.sendMessage({
|
|
379
397
|
from: req.agentId,
|
|
@@ -384,6 +402,11 @@ export function createApp({ store, config }) {
|
|
|
384
402
|
reply_to: input.reply_to ?? null,
|
|
385
403
|
thread_title: input.thread_title ?? null,
|
|
386
404
|
thread_description: input.thread_description ?? null,
|
|
405
|
+
attachments: (input.attachments ?? []).map((a) => ({
|
|
406
|
+
filename: a.filename,
|
|
407
|
+
mime_type: a.mime_type ?? null,
|
|
408
|
+
data: Buffer.from(a.content_base64, "base64"),
|
|
409
|
+
})),
|
|
387
410
|
});
|
|
388
411
|
logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to}`);
|
|
389
412
|
res.status(201).json(result);
|
|
@@ -409,6 +432,9 @@ export function createApp({ store, config }) {
|
|
|
409
432
|
const markRead = req.query.mark_read !== "false";
|
|
410
433
|
res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
|
|
411
434
|
});
|
|
435
|
+
api.get("/attachments/:id", (req, res) => {
|
|
436
|
+
sendAttachment(res, store.getAttachment(req.agentId, String(req.params.id)));
|
|
437
|
+
});
|
|
412
438
|
api.get("/threads", (req, res) => {
|
|
413
439
|
const query = listThreadsQuerySchema.parse(req.query);
|
|
414
440
|
res.json({ threads: store.listThreads(req.agentId, query) });
|
|
@@ -485,12 +511,32 @@ export function createApp({ store, config }) {
|
|
|
485
511
|
res.status(400).json({ error: "invalid request", details: z.treeifyError(err) });
|
|
486
512
|
return;
|
|
487
513
|
}
|
|
514
|
+
// Erreurs du lecteur de corps (trop gros, JSON illisible) : leur message est fait pour être lu.
|
|
515
|
+
if (isExposedHttpError(err)) {
|
|
516
|
+
res.status(err.status).json({ error: err.message });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
488
519
|
logger.error("unhandled error", err);
|
|
489
520
|
// Le détail reste dans les logs : la réponse ne doit rien révéler de l'hôte.
|
|
490
521
|
res.status(500).json({ error: "internal server error" });
|
|
491
522
|
});
|
|
492
523
|
return app;
|
|
493
524
|
}
|
|
525
|
+
function isExposedHttpError(err) {
|
|
526
|
+
if (typeof err !== "object" || err === null)
|
|
527
|
+
return false;
|
|
528
|
+
const { expose, status } = err;
|
|
529
|
+
return expose === true && typeof status === "number" && status >= 400 && status < 500;
|
|
530
|
+
}
|
|
531
|
+
function isSendRequest(req) {
|
|
532
|
+
return req.method === "POST" && req.path === "/api/messages";
|
|
533
|
+
}
|
|
534
|
+
function sendAttachment(res, attachment) {
|
|
535
|
+
res.setHeader("content-type", attachment.mime_type);
|
|
536
|
+
res.setHeader("content-length", String(attachment.data.length));
|
|
537
|
+
res.setHeader("content-disposition", contentDisposition(attachment.filename));
|
|
538
|
+
res.end(attachment.data);
|
|
539
|
+
}
|
|
494
540
|
/**
|
|
495
541
|
* Exige un jeton connu du serveur sur toute requête entrante.
|
|
496
542
|
*
|
|
@@ -27,6 +27,7 @@ const ICONS = {
|
|
|
27
27
|
link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
|
|
28
28
|
chevron: ["m9 18 6-6-6-6"],
|
|
29
29
|
info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
|
|
30
|
+
paperclip: ["m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7"],
|
|
30
31
|
trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
|
|
31
32
|
};
|
|
32
33
|
|
|
@@ -961,6 +962,9 @@ function fillMessage(item, message, isLast) {
|
|
|
961
962
|
column.appendChild(content);
|
|
962
963
|
|
|
963
964
|
content.appendChild(messageText(message));
|
|
965
|
+
if (message.attachments && message.attachments.length > 0) {
|
|
966
|
+
content.appendChild(attachmentList(message.attachments));
|
|
967
|
+
}
|
|
964
968
|
|
|
965
969
|
const foot = node("div", "message-foot");
|
|
966
970
|
foot.appendChild(
|
|
@@ -986,6 +990,30 @@ function messageText(message) {
|
|
|
986
990
|
return container;
|
|
987
991
|
}
|
|
988
992
|
|
|
993
|
+
/** Pièces jointes d'un message : un lien de téléchargement par fichier. */
|
|
994
|
+
function attachmentList(attachments) {
|
|
995
|
+
const list = node("ul", "attachments");
|
|
996
|
+
for (const attachment of attachments) {
|
|
997
|
+
const item = node("li");
|
|
998
|
+
const link = node("a", "attachment");
|
|
999
|
+
link.href = `/admin/attachments/${encodeURIComponent(attachment.attachment_id)}`;
|
|
1000
|
+
link.download = attachment.filename;
|
|
1001
|
+
link.title = attachment.mime_type;
|
|
1002
|
+
link.appendChild(icon("paperclip"));
|
|
1003
|
+
link.appendChild(node("span", "attachment-name", attachment.filename));
|
|
1004
|
+
link.appendChild(node("span", "attachment-size", formatSize(attachment.size)));
|
|
1005
|
+
item.appendChild(link);
|
|
1006
|
+
list.appendChild(item);
|
|
1007
|
+
}
|
|
1008
|
+
return list;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function formatSize(bytes) {
|
|
1012
|
+
if (bytes < 1024) return `${bytes} o`;
|
|
1013
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
|
1014
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
989
1017
|
// --- Détails d'un message --------------------------------------------------
|
|
990
1018
|
|
|
991
1019
|
/**
|
|
@@ -1627,6 +1627,50 @@ body {
|
|
|
1627
1627
|
font-weight: 600;
|
|
1628
1628
|
}
|
|
1629
1629
|
|
|
1630
|
+
.attachments {
|
|
1631
|
+
display: flex;
|
|
1632
|
+
flex-wrap: wrap;
|
|
1633
|
+
gap: 8px;
|
|
1634
|
+
margin: 10px 0 0;
|
|
1635
|
+
padding: 0;
|
|
1636
|
+
list-style: none;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
.attachment {
|
|
1640
|
+
display: inline-flex;
|
|
1641
|
+
align-items: center;
|
|
1642
|
+
gap: 6px;
|
|
1643
|
+
max-width: 100%;
|
|
1644
|
+
padding: 4px 10px;
|
|
1645
|
+
border: 1px solid var(--border);
|
|
1646
|
+
border-radius: var(--radius);
|
|
1647
|
+
background: var(--panel);
|
|
1648
|
+
color: var(--text);
|
|
1649
|
+
font-size: 12px;
|
|
1650
|
+
text-decoration: none;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
.attachment:hover {
|
|
1654
|
+
background: var(--accent-soft);
|
|
1655
|
+
border-color: var(--accent-softer);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
.attachment .icon {
|
|
1659
|
+
flex: none;
|
|
1660
|
+
color: var(--muted);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
.attachment-name {
|
|
1664
|
+
overflow: hidden;
|
|
1665
|
+
text-overflow: ellipsis;
|
|
1666
|
+
white-space: nowrap;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
.attachment-size {
|
|
1670
|
+
flex: none;
|
|
1671
|
+
color: var(--muted);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1630
1674
|
.message-foot {
|
|
1631
1675
|
display: flex;
|
|
1632
1676
|
align-items: center;
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.30";
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Généré par scripts/generate-version.js — ne pas modifier à la main.
|
|
2
|
-
export const APP_VERSION = "1.0.
|
|
2
|
+
export const APP_VERSION = "1.0.30";
|