@imenam/mcp-messenger 1.0.29 → 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 +27 -7
- 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/tools.js +141 -2
- 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
|
@@ -211,10 +211,11 @@ description dit « mailbox address ».
|
|
|
211
211
|
| `rename_thread` | Nommer ou renommer un fil (`thread_id`) ; le nom est partagé par tous ses participants |
|
|
212
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 |
|
|
213
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 |
|
|
214
|
-
| `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`) |
|
|
215
215
|
| `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
|
|
216
|
-
| `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 |
|
|
217
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 |
|
|
218
219
|
| `mark_messages` | Marquer lu / non lu, suivi / non suivi |
|
|
219
220
|
| `move_messages` | Classer dans un dossier |
|
|
220
221
|
| `delete_messages` | Corbeille, ou suppression définitive (`permanent: true`) |
|
|
@@ -231,6 +232,16 @@ elle-même. Pour toucher plusieurs agents, on leur envoie un message chacun, dan
|
|
|
231
232
|
fil. C'est aussi pourquoi `list_agents` ne montre jamais l'appelant : la seule adresse qu'il
|
|
232
233
|
ne peut pas viser n'a pas à figurer dans son annuaire.
|
|
233
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
|
+
|
|
234
245
|
**Dossiers système** — toujours présents, non supprimables : `inbox`, `sent`, `archive`,
|
|
235
246
|
`trash`. Les dossiers personnalisés sont propres à chaque boîte.
|
|
236
247
|
|
|
@@ -463,7 +474,9 @@ correspondants et l'état de lecture :
|
|
|
463
474
|
écrit ne peut devenir une balise, et seuls les liens `http`, `https` et `mailto` sont
|
|
464
475
|
suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
|
|
465
476
|
Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
|
|
466
|
-
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 ;
|
|
467
480
|
- **les détails d'un message**, dans une modale ouverte par l'icône d'information au bout
|
|
468
481
|
de sa ligne : l'objet, le nom et l'identifiant du fil, les identifiants, la date, la
|
|
469
482
|
priorité, les deux noms et l'adresse de chaque boîte, et le tableau de distribution
|
|
@@ -591,12 +604,13 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
591
604
|
| `GET` | `/api/agents/:id` | Une boîte, ou 404 |
|
|
592
605
|
| `PATCH` | `/api/agents/me` | Renommer la boîte courante |
|
|
593
606
|
| `GET` | `/api/mailbox` | Compteurs de la boîte, et présence d'un veilleur (`watcher`) |
|
|
594
|
-
| `POST` | `/api/messages` | Envoyer un message |
|
|
607
|
+
| `POST` | `/api/messages` | Envoyer un message ; `attachments: [{ filename, mime_type?, content_base64 }]` |
|
|
595
608
|
| `GET` | `/api/messages` | Lister (`folder`, `unread_only`, `from`, `thread_id`, `search`, `limit`, `offset`) |
|
|
596
609
|
| `GET` | `/api/messages/:id` | Lire (`?mark_read=false` pour ne pas marquer) |
|
|
597
610
|
| `POST` | `/api/messages/mark` | Marquer lu / suivi |
|
|
598
611
|
| `POST` | `/api/messages/move` | Déplacer vers un dossier |
|
|
599
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 |
|
|
600
614
|
| `GET` | `/api/threads` | Fils de la boîte (`unread_only`, `search`, `limit`, `offset`) |
|
|
601
615
|
| `GET` | `/api/threads/:id` | Fil complet, avec son intitulé |
|
|
602
616
|
| `PATCH` | `/api/threads/:id` | Nommer le fil (`title`, `description`) — participants uniquement |
|
|
@@ -605,6 +619,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
605
619
|
| `GET` | `/admin/overview` · `/admin/messages[/:id]` | Supervision, lecture seule |
|
|
606
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`) |
|
|
607
621
|
| `GET` | `/admin/threads/:id` | Fil complet, corps inclus |
|
|
622
|
+
| `GET` | `/admin/attachments/:id` | Contenu d'une pièce jointe, en téléchargement |
|
|
608
623
|
| `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
|
|
609
624
|
| `GET` | `/admin/mailboxes/created-before` | Boîtes ouvertes avant `created_before`, quelle que soit leur activité |
|
|
610
625
|
| `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
|
|
@@ -625,8 +640,12 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
625
640
|
d'emprunter l'identité d'un autre agent. À préférer au jeton partagé dès que les agents
|
|
626
641
|
ne se font pas mutuellement confiance.
|
|
627
642
|
- Comparaison des jetons à **temps constant**, en-têtes de sécurité (`helmet`), **rate
|
|
628
|
-
limiting** par IP, corps JSON limité à 2 Mo
|
|
629
|
-
|
|
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.
|
|
630
649
|
- **Isolation des boîtes** garantie côté serveur : les identifiants de messages fournis par
|
|
631
650
|
un agent sont systématiquement filtrés sur ses propres livraisons.
|
|
632
651
|
- La console web n'insère jamais de HTML fourni par un agent : le corps des messages est
|
|
@@ -665,7 +684,8 @@ src/
|
|
|
665
684
|
index.ts # point d'entrée : --server, --watch, --help, --version, sinon mode MCP
|
|
666
685
|
config.ts # lecture et validation des variables d'environnement
|
|
667
686
|
logger.ts # journalisation sur stderr (stdout est réservé au JSON-RPC)
|
|
668
|
-
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
|
|
669
689
|
server/app.ts # API HTTP, authentification, supervision
|
|
670
690
|
server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
|
|
671
691
|
server/start.ts # démarrage, arrêt propre, purge de la corbeille
|
|
@@ -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/tools.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
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";
|
|
@@ -7,6 +11,35 @@ import { forgetCandidate, getResumeState } from "./resume-state.js";
|
|
|
7
11
|
const PACKAGE_NAME = "@imenam/mcp-messenger";
|
|
8
12
|
/** Durée maximale d'un `Monitor` côté hôte : au-delà, l'hôte la ramène à cette valeur. */
|
|
9
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
|
+
};
|
|
10
43
|
const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
|
|
11
44
|
export const TOOLS = [
|
|
12
45
|
{
|
|
@@ -107,6 +140,23 @@ export const TOOLS = [
|
|
|
107
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).",
|
|
108
141
|
},
|
|
109
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
|
+
},
|
|
110
160
|
},
|
|
111
161
|
required: ["to", "subject", "body"],
|
|
112
162
|
additionalProperties: false,
|
|
@@ -152,6 +202,23 @@ export const TOOLS = [
|
|
|
152
202
|
additionalProperties: false,
|
|
153
203
|
},
|
|
154
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
|
+
},
|
|
155
222
|
{
|
|
156
223
|
name: "mark_messages",
|
|
157
224
|
description: "Mark messages as read or unread, and/or star or unstar them.",
|
|
@@ -244,6 +311,15 @@ const schemas = {
|
|
|
244
311
|
reply_to: z.string().optional(),
|
|
245
312
|
thread_name: z.string().trim().min(1).max(200).optional(),
|
|
246
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(),
|
|
247
323
|
}),
|
|
248
324
|
list_messages: z.object({
|
|
249
325
|
folder: z.string().optional(),
|
|
@@ -425,6 +501,7 @@ export async function callTool(client, name, rawArgs) {
|
|
|
425
501
|
}
|
|
426
502
|
case "send_message": {
|
|
427
503
|
const input = schemas.send_message.parse(args);
|
|
504
|
+
const attachments = await readAttachments(input.attachments ?? []);
|
|
428
505
|
const result = await client.sendMessage({
|
|
429
506
|
to: input.to,
|
|
430
507
|
subject: input.subject,
|
|
@@ -434,12 +511,16 @@ export async function callTool(client, name, rawArgs) {
|
|
|
434
511
|
// Le protocole HTTP et le stockage gardent `thread_title` ; seul l'outil dit `thread_name`.
|
|
435
512
|
thread_title: input.thread_name ?? null,
|
|
436
513
|
thread_description: input.thread_description ?? null,
|
|
514
|
+
attachments,
|
|
437
515
|
});
|
|
438
516
|
const lines = [
|
|
439
517
|
`Message sent: ${result.message_id}`,
|
|
440
518
|
`Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
|
|
441
519
|
`Delivered to: ${result.delivered_to}`,
|
|
442
520
|
];
|
|
521
|
+
if (attachments.length > 0) {
|
|
522
|
+
lines.push(`Attachments: ${attachments.map((a) => a.filename).join(", ")}`);
|
|
523
|
+
}
|
|
443
524
|
if (!result.thread_title && !input.reply_to) {
|
|
444
525
|
lines.push("This thread has no name yet — rename_thread with its thread_id makes it readable for everyone.");
|
|
445
526
|
}
|
|
@@ -472,6 +553,22 @@ export async function callTool(client, name, rawArgs) {
|
|
|
472
553
|
.join("\n");
|
|
473
554
|
return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
|
|
474
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
|
+
}
|
|
475
572
|
case "mark_messages": {
|
|
476
573
|
const input = schemas.mark_messages.parse(args);
|
|
477
574
|
if (input.read === undefined && input.starred === undefined) {
|
|
@@ -567,6 +664,42 @@ export async function callTool(client, name, rawArgs) {
|
|
|
567
664
|
throw new Error(`Unknown tool: ${name}`);
|
|
568
665
|
}
|
|
569
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
|
+
}
|
|
570
703
|
async function renameMailbox(client, name, description) {
|
|
571
704
|
const agent = await client.updateProfile({ display_name: name, description });
|
|
572
705
|
return [
|
|
@@ -752,7 +885,7 @@ function formatSummary(message) {
|
|
|
752
885
|
`${flags} ${message.subject}${priority}`,
|
|
753
886
|
` from ${message.from} to ${message.to || "—"} · ${message.created_at}`,
|
|
754
887
|
` ${message.preview}`,
|
|
755
|
-
` 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)` : ""}`,
|
|
756
889
|
].join("\n");
|
|
757
890
|
}
|
|
758
891
|
function formatMessage(message) {
|
|
@@ -767,5 +900,11 @@ function formatMessage(message) {
|
|
|
767
900
|
]
|
|
768
901
|
.filter(Boolean)
|
|
769
902
|
.join("\n");
|
|
770
|
-
|
|
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)})`;
|
|
771
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";
|