v3.0 - Conversation-based messaging system
Features: - Direct conversations (1-to-1) auto-created and permanent - Group conversations with leave/archive support - Real-time messaging via long-polling - Offline notifications via CLAUDE.md - Auto-registration on MCP startup Architecture: - Broker: Express HTTP server + SQLite - MCP Partner: Modular tools (one file per tool) - Full documentation and API reference
This commit is contained in:
parent
0bb8af199e
commit
66e5c677ea
78
CONTRIBUTING.md
Normal file
78
CONTRIBUTING.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# Contributing to MCP Claude Duo
|
||||||
|
|
||||||
|
Thanks for your interest in contributing!
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Clone your fork
|
||||||
|
3. Install dependencies: `npm install`
|
||||||
|
4. Start the broker: `npm run broker`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp-claude-duo/
|
||||||
|
├── broker/ # HTTP server + SQLite
|
||||||
|
│ ├── index.js # Express routes
|
||||||
|
│ └── db.js # Database layer
|
||||||
|
├── mcp-partner/ # MCP server for Claude Code
|
||||||
|
│ ├── index.js # Entry point
|
||||||
|
│ ├── shared.js # Shared utilities
|
||||||
|
│ └── tools/ # One file per MCP tool
|
||||||
|
└── docs/ # Documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Tool
|
||||||
|
|
||||||
|
1. Create a new file in `mcp-partner/tools/`
|
||||||
|
2. Export `definition` (tool schema) and `handler` (async function)
|
||||||
|
3. Import and register in `mcp-partner/index.js`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```javascript
|
||||||
|
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "my_tool",
|
||||||
|
description: "Description of my tool",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
param: { type: "string", description: "A parameter" }
|
||||||
|
},
|
||||||
|
required: ["param"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
await ensureRegistered();
|
||||||
|
// Your logic here
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Result" }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start broker
|
||||||
|
npm run broker
|
||||||
|
|
||||||
|
# Test with curl
|
||||||
|
curl http://localhost:3210/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
1. Create a feature branch
|
||||||
|
2. Make your changes
|
||||||
|
3. Test locally
|
||||||
|
4. Submit a PR with a clear description
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
Feel free to open issues for bugs, feature requests, or questions.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 alexi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
211
README.md
211
README.md
@ -1,118 +1,207 @@
|
|||||||
# MCP Claude Duo
|
# MCP Claude Duo
|
||||||
|
|
||||||
MCP pour faire discuter plusieurs instances Claude Code ensemble.
|
> Make multiple Claude Code instances talk to each other through conversations.
|
||||||
|
|
||||||
## Architecture v2
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MCP Claude Duo is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables real-time communication between multiple Claude Code instances. Each Claude can send messages, create group conversations, and receive notifications when offline.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Direct Conversations** - Auto-created 1-to-1 threads between any two Claude instances
|
||||||
|
- **Group Conversations** - Create named group chats with multiple participants
|
||||||
|
- **Real-time Messaging** - Long-polling based instant message delivery
|
||||||
|
- **Offline Notifications** - Messages are queued and notifications written to `CLAUDE.md`
|
||||||
|
- **Auto-registration** - Claude instances automatically connect when launched
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
│ Claude A │ │ Broker │ │ Claude B │
|
│ Claude A │ │ Broker │ │ Claude B │
|
||||||
│ (projet-a) │◄───►│ HTTP + SQLite │◄───►│ (projet-b) │
|
│ (project-a) │◄───►│ HTTP + SQLite │◄───►│ (project-b) │
|
||||||
│ + mcp-partner │ │ │ │ + mcp-partner │
|
│ + mcp-partner │ │ Conversations │ │ + mcp-partner │
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Un seul MCP unifié** : `mcp-partner` pour tout le monde
|
- **Broker**: Central HTTP server managing conversations and message routing
|
||||||
- **Messages bufferisés** : SQLite stocke les messages, pas besoin d'être connecté en permanence
|
- **MCP Partner**: MCP server running in each Claude Code instance
|
||||||
- **Bidirectionnel** : tout le monde peut parler à tout le monde
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USER/mcp-claude-duo.git
|
||||||
cd mcp-claude-duo
|
cd mcp-claude-duo
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Démarrage
|
## Quick Start
|
||||||
|
|
||||||
### 1. Lancer le broker
|
### 1. Start the Broker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run broker
|
npm run broker
|
||||||
```
|
```
|
||||||
|
|
||||||
Le broker tourne sur `http://localhost:3210` avec une base SQLite dans `data/duo.db`.
|
The broker runs on `http://localhost:3210`.
|
||||||
|
|
||||||
### 2. Configurer le MCP (global)
|
### 2. Configure MCP in Claude Code
|
||||||
|
|
||||||
|
**Global (all projects):**
|
||||||
```bash
|
```bash
|
||||||
claude mcp add duo-partner -s user -e BROKER_URL=http://localhost:3210 -- node "CHEMIN/mcp-claude-duo/mcp-partner/index.js"
|
claude mcp add duo-partner -s user \
|
||||||
|
-e BROKER_URL=http://localhost:3210 \
|
||||||
|
-- node "/path/to/mcp-claude-duo/mcp-partner/index.js"
|
||||||
```
|
```
|
||||||
|
|
||||||
Ou par projet :
|
**Per project (with custom name):**
|
||||||
```bash
|
```bash
|
||||||
cd mon-projet
|
claude mcp add duo-partner -s project \
|
||||||
claude mcp add duo-partner -s project -e BROKER_URL=http://localhost:3210 -e PARTNER_NAME="Mon Nom" -- node "CHEMIN/mcp-claude-duo/mcp-partner/index.js"
|
-e BROKER_URL=http://localhost:3210 \
|
||||||
|
-e PARTNER_NAME="My Project" \
|
||||||
|
-- node "/path/to/mcp-claude-duo/mcp-partner/index.js"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tools disponibles
|
### 3. Start Talking!
|
||||||
|
|
||||||
|
In any Claude Code instance:
|
||||||
|
```
|
||||||
|
talk("Hello!", to: "other_project")
|
||||||
|
```
|
||||||
|
|
||||||
|
In the other instance:
|
||||||
|
```
|
||||||
|
listen()
|
||||||
|
→ Message received from other_project: "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `register(name?)` | S'enregistrer sur le réseau |
|
| `register(name?)` | Register with the network (optional, auto on startup) |
|
||||||
| `talk(message, to?)` | Envoyer un message et attendre la réponse |
|
| `talk(message, to?, conversation?)` | Send a message |
|
||||||
| `check_messages(wait?)` | Vérifier les messages en attente |
|
| `listen(conversation?, timeout?)` | Listen for messages (2-15 min timeout) |
|
||||||
| `listen()` | Écouter en temps réel (long-polling) |
|
| `list_partners()` | List connected partners |
|
||||||
| `reply(message)` | Répondre au dernier message reçu |
|
|
||||||
| `list_partners()` | Lister les partenaires connectés |
|
|
||||||
| `history(partnerId, limit?)` | Historique de conversation |
|
|
||||||
|
|
||||||
## Exemples
|
### Conversations
|
||||||
|
|
||||||
### Conversation simple
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `list_conversations()` | List your conversations |
|
||||||
|
| `create_conversation(name, participants)` | Create a group conversation |
|
||||||
|
| `leave_conversation(conversation)` | Leave a group |
|
||||||
|
| `history(conversation, limit?)` | Get conversation history |
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `set_status(message?)` | Set your status message |
|
||||||
|
| `notifications(enabled)` | Enable/disable CLAUDE.md notifications |
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Direct Conversation
|
||||||
|
|
||||||
**Claude A :**
|
|
||||||
```
|
|
||||||
register("Alice")
|
|
||||||
talk("Salut, ça va ?")
|
|
||||||
→ attend la réponse...
|
|
||||||
→ "Bob: Oui et toi ?"
|
|
||||||
```
|
```
|
||||||
|
# Claude A
|
||||||
|
talk("Hey, can you help with the auth module?", to: "project_b")
|
||||||
|
|
||||||
**Claude B :**
|
# Claude B
|
||||||
```
|
|
||||||
register("Bob")
|
|
||||||
listen()
|
listen()
|
||||||
→ "Alice: Salut, ça va ?"
|
→ 📁 direct_project_a_project_b
|
||||||
reply("Oui et toi ?")
|
[10:30] project_a: Hey, can you help with the auth module?
|
||||||
|
|
||||||
|
talk("Sure, what do you need?", to: "project_a")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Messages bufferisés
|
### Group Conversation
|
||||||
|
|
||||||
**Claude A envoie même si B n'est pas connecté :**
|
|
||||||
```
|
```
|
||||||
talk("Hey, t'es là ?")
|
# Claude A creates a group
|
||||||
→ message stocké en DB, attend la réponse...
|
create_conversation("Backend Team", "project_b, project_c")
|
||||||
|
→ Created: group_1706123456789_abc123
|
||||||
|
|
||||||
|
# Anyone can send to the group
|
||||||
|
talk("Sprint planning in 5 min", conversation: "group_1706123456789_abc123")
|
||||||
```
|
```
|
||||||
|
|
||||||
**Claude B se connecte plus tard :**
|
### Filtered Listening
|
||||||
|
|
||||||
```
|
```
|
||||||
check_messages()
|
# Listen only to a specific conversation
|
||||||
→ "Alice: Hey, t'es là ?"
|
listen(conversation: "direct_project_a_project_b", timeout: 10)
|
||||||
reply("Oui, j'arrive !")
|
|
||||||
→ Claude A reçoit la réponse
|
# Listen to all conversations
|
||||||
|
listen(timeout: 5)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Broker
|
## Project Structure
|
||||||
|
|
||||||
| Endpoint | Description |
|
```
|
||||||
|----------|-------------|
|
mcp-claude-duo/
|
||||||
| `POST /register` | S'enregistrer |
|
├── broker/
|
||||||
| `POST /talk` | Envoyer et attendre réponse |
|
│ ├── index.js # HTTP server & routes
|
||||||
| `GET /messages/:id` | Récupérer messages non lus |
|
│ └── db.js # SQLite database layer
|
||||||
| `GET /wait/:id` | Long-polling |
|
├── mcp-partner/
|
||||||
| `POST /respond` | Répondre à un message |
|
│ ├── index.js # MCP server entry point
|
||||||
| `GET /partners` | Lister les partenaires |
|
│ ├── shared.js # Shared utilities
|
||||||
| `GET /history/:a/:b` | Historique entre deux partenaires |
|
│ └── tools/ # One file per tool
|
||||||
| `GET /health` | Status du broker |
|
│ ├── register.js
|
||||||
|
│ ├── talk.js
|
||||||
|
│ ├── listen.js
|
||||||
|
│ └── ...
|
||||||
|
├── docs/
|
||||||
|
│ ├── schema.sql # Database schema
|
||||||
|
│ └── db-schema.md # Schema documentation
|
||||||
|
└── data/ # SQLite database (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
## Base de données
|
## API Reference
|
||||||
|
|
||||||
SQLite dans `data/duo.db` :
|
### Broker Endpoints
|
||||||
|
|
||||||
- `partners` : ID, nom, status, dernière connexion
|
| Endpoint | Method | Description |
|
||||||
- `messages` : contenu, expéditeur, destinataire, timestamps
|
|----------|--------|-------------|
|
||||||
|
| `/register` | POST | Register a partner |
|
||||||
|
| `/talk` | POST | Send a message |
|
||||||
|
| `/listen/:partnerId` | GET | Long-poll for messages |
|
||||||
|
| `/conversations` | POST | Create group conversation |
|
||||||
|
| `/conversations/:partnerId` | GET | List conversations |
|
||||||
|
| `/conversations/:id/leave` | POST | Leave a conversation |
|
||||||
|
| `/conversations/:id/messages` | GET | Get conversation history |
|
||||||
|
| `/partners` | GET | List all partners |
|
||||||
|
| `/health` | GET | Health check |
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
SQLite database with the following tables:
|
||||||
|
|
||||||
|
- **partners** - Registered Claude instances
|
||||||
|
- **conversations** - Direct and group conversations
|
||||||
|
- **conversation_participants** - Membership tracking
|
||||||
|
- **messages** - All messages
|
||||||
|
|
||||||
|
See [docs/db-schema.md](docs/db-schema.md) for full schema documentation.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Environment Variable | Default | Description |
|
||||||
|
|---------------------|---------|-------------|
|
||||||
|
| `BROKER_URL` | `http://localhost:3210` | Broker server URL |
|
||||||
|
| `BROKER_PORT` | `3210` | Broker listen port |
|
||||||
|
| `PARTNER_NAME` | `Claude` | Display name for the partner |
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT - See [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
|||||||
284
broker/db.js
284
broker/db.js
@ -24,97 +24,172 @@ db.exec(`
|
|||||||
CREATE TABLE IF NOT EXISTS partners (
|
CREATE TABLE IF NOT EXISTS partners (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
project_path TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
status TEXT DEFAULT 'online'
|
status TEXT DEFAULT 'online',
|
||||||
|
status_message TEXT,
|
||||||
|
notifications_enabled INTEGER DEFAULT 1
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Messages
|
-- Conversations
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
type TEXT NOT NULL DEFAULT 'direct',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by TEXT,
|
||||||
|
is_archived INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES partners(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Participants aux conversations
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
partner_id TEXT NOT NULL,
|
||||||
|
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_read_at DATETIME,
|
||||||
|
PRIMARY KEY (conversation_id, partner_id),
|
||||||
|
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
|
||||||
|
FOREIGN KEY (partner_id) REFERENCES partners(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Messages (maintenant liés aux conversations)
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
from_id TEXT NOT NULL,
|
from_id TEXT NOT NULL,
|
||||||
to_id TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
request_id TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
delivered_at DATETIME,
|
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
|
||||||
response_to INTEGER REFERENCES messages(id),
|
FOREIGN KEY (from_id) REFERENCES partners(id)
|
||||||
FOREIGN KEY (from_id) REFERENCES partners(id),
|
|
||||||
FOREIGN KEY (to_id) REFERENCES partners(id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour les requêtes fréquentes
|
-- Index
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(to_id, delivered_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_request_id ON messages(request_id);
|
CREATE INDEX IF NOT EXISTS idx_participants_partner ON conversation_participants(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversations_archived ON conversations(is_archived);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Génère un ID de conversation directe (déterministe, trié alphabétiquement)
|
||||||
|
function getDirectConversationId(partnerId1, partnerId2) {
|
||||||
|
const sorted = [partnerId1, partnerId2].sort();
|
||||||
|
return `direct_${sorted[0]}_${sorted[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Prepared statements
|
// Prepared statements
|
||||||
const stmts = {
|
const stmts = {
|
||||||
// Partners
|
// Partners
|
||||||
upsertPartner: db.prepare(`
|
upsertPartner: db.prepare(`
|
||||||
INSERT INTO partners (id, name, last_seen, status)
|
INSERT INTO partners (id, name, project_path, last_seen, status)
|
||||||
VALUES (?, ?, CURRENT_TIMESTAMP, 'online')
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 'online')
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
|
project_path = excluded.project_path,
|
||||||
last_seen = CURRENT_TIMESTAMP,
|
last_seen = CURRENT_TIMESTAMP,
|
||||||
status = 'online'
|
status = 'online'
|
||||||
`),
|
`),
|
||||||
|
|
||||||
getPartner: db.prepare(`SELECT * FROM partners WHERE id = ?`),
|
getPartner: db.prepare(`SELECT * FROM partners WHERE id = ?`),
|
||||||
|
|
||||||
getAllPartners: db.prepare(`SELECT * FROM partners ORDER BY last_seen DESC`),
|
getAllPartners: db.prepare(`SELECT * FROM partners ORDER BY last_seen DESC`),
|
||||||
|
updatePartnerStatus: db.prepare(`UPDATE partners SET status = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?`),
|
||||||
|
updatePartnerNotifications: db.prepare(`UPDATE partners SET notifications_enabled = ? WHERE id = ?`),
|
||||||
|
updatePartnerStatusMessage: db.prepare(`UPDATE partners SET status_message = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?`),
|
||||||
|
|
||||||
updatePartnerStatus: db.prepare(`
|
// Conversations
|
||||||
UPDATE partners SET status = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?
|
createConversation: db.prepare(`
|
||||||
|
INSERT INTO conversations (id, name, type, created_by)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`),
|
||||||
|
|
||||||
|
getConversation: db.prepare(`SELECT * FROM conversations WHERE id = ?`),
|
||||||
|
|
||||||
|
getConversationsByPartner: db.prepare(`
|
||||||
|
SELECT c.*,
|
||||||
|
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id
|
||||||
|
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')) as unread_count
|
||||||
|
FROM conversations c
|
||||||
|
JOIN conversation_participants cp ON c.id = cp.conversation_id
|
||||||
|
WHERE cp.partner_id = ? AND c.is_archived = 0
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
`),
|
||||||
|
|
||||||
|
archiveConversation: db.prepare(`UPDATE conversations SET is_archived = 1 WHERE id = ?`),
|
||||||
|
|
||||||
|
// Participants
|
||||||
|
addParticipant: db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO conversation_participants (conversation_id, partner_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`),
|
||||||
|
|
||||||
|
removeParticipant: db.prepare(`
|
||||||
|
DELETE FROM conversation_participants WHERE conversation_id = ? AND partner_id = ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
getParticipants: db.prepare(`
|
||||||
|
SELECT p.* FROM partners p
|
||||||
|
JOIN conversation_participants cp ON p.id = cp.partner_id
|
||||||
|
WHERE cp.conversation_id = ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
countParticipants: db.prepare(`
|
||||||
|
SELECT COUNT(*) as count FROM conversation_participants WHERE conversation_id = ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
isParticipant: db.prepare(`
|
||||||
|
SELECT 1 FROM conversation_participants WHERE conversation_id = ? AND partner_id = ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
updateLastRead: db.prepare(`
|
||||||
|
UPDATE conversation_participants SET last_read_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE conversation_id = ? AND partner_id = ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
getLastRead: db.prepare(`
|
||||||
|
SELECT last_read_at FROM conversation_participants
|
||||||
|
WHERE conversation_id = ? AND partner_id = ?
|
||||||
`),
|
`),
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
insertMessage: db.prepare(`
|
insertMessage: db.prepare(`
|
||||||
INSERT INTO messages (from_id, to_id, content, request_id)
|
INSERT INTO messages (conversation_id, from_id, content)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
`),
|
`),
|
||||||
|
|
||||||
getUndeliveredMessages: db.prepare(`
|
getMessages: db.prepare(`
|
||||||
|
SELECT * FROM messages WHERE conversation_id = ?
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
getMessagesSince: db.prepare(`
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE to_id = ? AND delivered_at IS NULL
|
WHERE conversation_id = ? AND created_at > ?
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`),
|
`),
|
||||||
|
|
||||||
markDelivered: db.prepare(`
|
getUnreadMessages: db.prepare(`
|
||||||
UPDATE messages SET delivered_at = CURRENT_TIMESTAMP WHERE id = ?
|
SELECT m.* FROM messages m
|
||||||
|
JOIN conversation_participants cp ON m.conversation_id = cp.conversation_id
|
||||||
|
WHERE cp.partner_id = ? AND m.from_id != ?
|
||||||
|
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')
|
||||||
|
ORDER BY m.created_at ASC
|
||||||
`),
|
`),
|
||||||
|
|
||||||
getMessageByRequestId: db.prepare(`
|
getUnreadMessagesInConv: db.prepare(`
|
||||||
SELECT * FROM messages WHERE request_id = ?
|
SELECT m.* FROM messages m
|
||||||
`),
|
JOIN conversation_participants cp ON m.conversation_id = cp.conversation_id
|
||||||
|
WHERE cp.partner_id = ? AND m.conversation_id = ? AND m.from_id != ?
|
||||||
insertResponse: db.prepare(`
|
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')
|
||||||
INSERT INTO messages (from_id, to_id, content, response_to)
|
ORDER BY m.created_at ASC
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`),
|
|
||||||
|
|
||||||
getResponse: db.prepare(`
|
|
||||||
SELECT * FROM messages WHERE response_to = ? AND delivered_at IS NULL
|
|
||||||
`),
|
|
||||||
|
|
||||||
markResponseDelivered: db.prepare(`
|
|
||||||
UPDATE messages SET delivered_at = CURRENT_TIMESTAMP WHERE response_to = ?
|
|
||||||
`),
|
|
||||||
|
|
||||||
// Conversations history
|
|
||||||
getConversation: db.prepare(`
|
|
||||||
SELECT * FROM messages
|
|
||||||
WHERE (from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`),
|
`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// API
|
// API
|
||||||
export const DB = {
|
export const DB = {
|
||||||
// Partners
|
// Partners
|
||||||
registerPartner(id, name) {
|
registerPartner(id, name, projectPath = null) {
|
||||||
stmts.upsertPartner.run(id, name);
|
stmts.upsertPartner.run(id, name, projectPath);
|
||||||
return stmts.getPartner.get(id);
|
return stmts.getPartner.get(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -134,47 +209,104 @@ export const DB = {
|
|||||||
stmts.updatePartnerStatus.run("online", id);
|
stmts.updatePartnerStatus.run("online", id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setNotificationsEnabled(id, enabled) {
|
||||||
|
stmts.updatePartnerNotifications.run(enabled ? 1 : 0, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
setStatusMessage(id, message) {
|
||||||
|
stmts.updatePartnerStatusMessage.run(message, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conversations
|
||||||
|
getOrCreateDirectConversation(partnerId1, partnerId2) {
|
||||||
|
const convId = getDirectConversationId(partnerId1, partnerId2);
|
||||||
|
let conv = stmts.getConversation.get(convId);
|
||||||
|
|
||||||
|
if (!conv) {
|
||||||
|
stmts.createConversation.run(convId, null, "direct", partnerId1);
|
||||||
|
stmts.addParticipant.run(convId, partnerId1);
|
||||||
|
stmts.addParticipant.run(convId, partnerId2);
|
||||||
|
conv = stmts.getConversation.get(convId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return conv;
|
||||||
|
},
|
||||||
|
|
||||||
|
createGroupConversation(name, creatorId, participantIds) {
|
||||||
|
const convId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
stmts.createConversation.run(convId, name, "group", creatorId);
|
||||||
|
|
||||||
|
// Ajouter le créateur et tous les participants
|
||||||
|
stmts.addParticipant.run(convId, creatorId);
|
||||||
|
for (const pid of participantIds) {
|
||||||
|
if (pid !== creatorId) {
|
||||||
|
stmts.addParticipant.run(convId, pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stmts.getConversation.get(convId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getConversation(convId) {
|
||||||
|
return stmts.getConversation.get(convId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getConversationsByPartner(partnerId) {
|
||||||
|
return stmts.getConversationsByPartner.all(partnerId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getParticipants(convId) {
|
||||||
|
return stmts.getParticipants.all(convId);
|
||||||
|
},
|
||||||
|
|
||||||
|
isParticipant(convId, partnerId) {
|
||||||
|
return !!stmts.isParticipant.get(convId, partnerId);
|
||||||
|
},
|
||||||
|
|
||||||
|
addParticipant(convId, partnerId) {
|
||||||
|
stmts.addParticipant.run(convId, partnerId);
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveConversation(convId, partnerId) {
|
||||||
|
const conv = stmts.getConversation.get(convId);
|
||||||
|
if (!conv) return { error: "Conversation not found" };
|
||||||
|
if (conv.type === "direct") return { error: "Cannot leave a direct conversation" };
|
||||||
|
|
||||||
|
stmts.removeParticipant.run(convId, partnerId);
|
||||||
|
|
||||||
|
// Vérifier s'il reste des participants
|
||||||
|
const count = stmts.countParticipants.get(convId).count;
|
||||||
|
if (count === 0) {
|
||||||
|
stmts.archiveConversation.run(convId);
|
||||||
|
return { left: true, archived: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { left: true, archived: false };
|
||||||
|
},
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
sendMessage(fromId, toId, content, requestId = null) {
|
sendMessage(convId, fromId, content) {
|
||||||
const result = stmts.insertMessage.run(fromId, toId, content, requestId);
|
const result = stmts.insertMessage.run(convId, fromId, content);
|
||||||
return result.lastInsertRowid;
|
return result.lastInsertRowid;
|
||||||
},
|
},
|
||||||
|
|
||||||
getUndeliveredMessages(toId) {
|
getMessages(convId, limit = 50) {
|
||||||
return stmts.getUndeliveredMessages.all(toId);
|
return stmts.getMessages.all(convId, limit);
|
||||||
},
|
},
|
||||||
|
|
||||||
markDelivered(messageId) {
|
getUnreadMessages(partnerId) {
|
||||||
stmts.markDelivered.run(messageId);
|
return stmts.getUnreadMessages.all(partnerId, partnerId);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Pour talk() qui attend une réponse
|
getUnreadMessagesInConv(partnerId, convId) {
|
||||||
sendAndWaitResponse(fromId, toId, content, requestId) {
|
return stmts.getUnreadMessagesInConv.all(partnerId, convId, partnerId);
|
||||||
stmts.insertMessage.run(fromId, toId, content, requestId);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getMessageByRequestId(requestId) {
|
markConversationRead(convId, partnerId) {
|
||||||
return stmts.getMessageByRequestId.get(requestId);
|
stmts.updateLastRead.run(convId, partnerId);
|
||||||
},
|
},
|
||||||
|
|
||||||
sendResponse(fromId, toId, content, originalMessageId) {
|
// Raw access
|
||||||
stmts.insertResponse.run(fromId, toId, content, originalMessageId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getResponse(originalMessageId) {
|
|
||||||
return stmts.getResponse.get(originalMessageId);
|
|
||||||
},
|
|
||||||
|
|
||||||
markResponseDelivered(originalMessageId) {
|
|
||||||
stmts.markResponseDelivered.run(originalMessageId);
|
|
||||||
},
|
|
||||||
|
|
||||||
// History
|
|
||||||
getConversation(partnerId1, partnerId2, limit = 50) {
|
|
||||||
return stmts.getConversation.all(partnerId1, partnerId2, partnerId2, partnerId1, limit);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Raw access for complex queries
|
|
||||||
raw: db,
|
raw: db,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
466
broker/index.js
466
broker/index.js
@ -1,4 +1,6 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
import { DB } from "./db.js";
|
import { DB } from "./db.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -6,151 +8,396 @@ app.use(express.json());
|
|||||||
|
|
||||||
const PORT = process.env.BROKER_PORT || 3210;
|
const PORT = process.env.BROKER_PORT || 3210;
|
||||||
|
|
||||||
// Réponses en attente (pour talk qui attend une réponse)
|
// Partenaires en écoute (long-polling)
|
||||||
// { requestId: { resolve, fromId, toId } }
|
// { visitorId: { res, heartbeat, timeout, conversationId? } }
|
||||||
const pendingResponses = new Map();
|
|
||||||
|
|
||||||
// Long-polling en attente (pour check_messages)
|
|
||||||
// { partnerId: { res, heartbeat } }
|
|
||||||
const waitingPartners = new Map();
|
const waitingPartners = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écrit une notification dans le CLAUDE.md du projet destinataire
|
||||||
|
*/
|
||||||
|
function writeNotificationToClaudeMd(partnerId, fromId, convId, content) {
|
||||||
|
const partner = DB.getPartner(partnerId);
|
||||||
|
if (!partner || !partner.project_path || !partner.notifications_enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudeMdPath = join(partner.project_path, "CLAUDE.md");
|
||||||
|
const notificationMarker = "<!-- CLAUDE-DUO-NOTIFICATIONS -->";
|
||||||
|
const endMarker = "<!-- /CLAUDE-DUO-NOTIFICATIONS -->";
|
||||||
|
|
||||||
|
const timestamp = new Date().toLocaleString();
|
||||||
|
const convLabel = convId.startsWith("direct_") ? `DM de ${fromId}` : `[${convId}]`;
|
||||||
|
const newNotification = `- **[${timestamp}] ${convLabel}:** ${content.substring(0, 200)}${content.length > 200 ? "..." : ""}`;
|
||||||
|
|
||||||
|
let claudeMdContent = "";
|
||||||
|
if (existsSync(claudeMdPath)) {
|
||||||
|
claudeMdContent = readFileSync(claudeMdPath, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIdx = claudeMdContent.indexOf(notificationMarker);
|
||||||
|
const endIdx = claudeMdContent.indexOf(endMarker);
|
||||||
|
|
||||||
|
let notificationsSection = `
|
||||||
|
${notificationMarker}
|
||||||
|
## PRIORITE: Messages en attente (Claude Duo)
|
||||||
|
|
||||||
|
**ACTION REQUISE: Tu as des messages non lus. Utilise \`listen\` pour les lire.**
|
||||||
|
|
||||||
|
${newNotification}
|
||||||
|
|
||||||
|
${endMarker}`;
|
||||||
|
|
||||||
|
if (startIdx !== -1 && endIdx !== -1) {
|
||||||
|
const existingSection = claudeMdContent.substring(startIdx + notificationMarker.length, endIdx);
|
||||||
|
const cleanedNotifications = existingSection
|
||||||
|
.replace("## PRIORITE: Messages en attente (Claude Duo)", "")
|
||||||
|
.replace(/\*\*ACTION REQUISE:.*\*\*/g, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
notificationsSection = `
|
||||||
|
${notificationMarker}
|
||||||
|
## PRIORITE: Messages en attente (Claude Duo)
|
||||||
|
|
||||||
|
**ACTION REQUISE: Tu as des messages non lus. Utilise \`listen\` pour les lire.**
|
||||||
|
|
||||||
|
${newNotification}
|
||||||
|
${cleanedNotifications}
|
||||||
|
|
||||||
|
${endMarker}`;
|
||||||
|
|
||||||
|
const beforeSection = claudeMdContent.substring(0, startIdx);
|
||||||
|
const afterSection = claudeMdContent.substring(endIdx + endMarker.length);
|
||||||
|
claudeMdContent = beforeSection.trimEnd() + "\n" + notificationsSection + afterSection;
|
||||||
|
} else {
|
||||||
|
claudeMdContent = claudeMdContent.trimEnd() + "\n" + notificationsSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(claudeMdPath, claudeMdContent);
|
||||||
|
console.log(`[BROKER] Notification written to ${claudeMdPath}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[BROKER] Failed to write notification to ${claudeMdPath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime les notifications du CLAUDE.md
|
||||||
|
*/
|
||||||
|
function clearNotificationsFromClaudeMd(partnerId) {
|
||||||
|
const partner = DB.getPartner(partnerId);
|
||||||
|
if (!partner || !partner.project_path) return;
|
||||||
|
|
||||||
|
const claudeMdPath = join(partner.project_path, "CLAUDE.md");
|
||||||
|
if (!existsSync(claudeMdPath)) return;
|
||||||
|
|
||||||
|
const notificationMarker = "<!-- CLAUDE-DUO-NOTIFICATIONS -->";
|
||||||
|
const endMarker = "<!-- /CLAUDE-DUO-NOTIFICATIONS -->";
|
||||||
|
|
||||||
|
let claudeMdContent = readFileSync(claudeMdPath, "utf-8");
|
||||||
|
const startIdx = claudeMdContent.indexOf(notificationMarker);
|
||||||
|
const endIdx = claudeMdContent.indexOf(endMarker);
|
||||||
|
|
||||||
|
if (startIdx !== -1 && endIdx !== -1) {
|
||||||
|
const beforeSection = claudeMdContent.substring(0, startIdx);
|
||||||
|
const afterSection = claudeMdContent.substring(endIdx + endMarker.length);
|
||||||
|
claudeMdContent = (beforeSection.trimEnd() + afterSection).trim() + "\n";
|
||||||
|
writeFileSync(claudeMdPath, claudeMdContent);
|
||||||
|
console.log(`[BROKER] Notifications cleared from ${claudeMdPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifie un partenaire en attente qu'il a des messages
|
||||||
|
*/
|
||||||
|
function notifyWaitingPartner(partnerId, conversationId = null) {
|
||||||
|
if (waitingPartners.has(partnerId)) {
|
||||||
|
const { res, heartbeat, timeout, conversationId: listeningConvId } = waitingPartners.get(partnerId);
|
||||||
|
|
||||||
|
// Si le partenaire écoute une conv spécifique, ne notifier que pour celle-là
|
||||||
|
if (listeningConvId && conversationId && listeningConvId !== conversationId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
waitingPartners.delete(partnerId);
|
||||||
|
|
||||||
|
// Récupérer les messages non lus
|
||||||
|
let messages;
|
||||||
|
if (listeningConvId) {
|
||||||
|
messages = DB.getUnreadMessagesInConv(partnerId, listeningConvId);
|
||||||
|
DB.markConversationRead(listeningConvId, partnerId);
|
||||||
|
} else {
|
||||||
|
messages = DB.getUnreadMessages(partnerId);
|
||||||
|
// Marquer toutes les convs comme lues
|
||||||
|
const convIds = [...new Set(messages.map(m => m.conversation_id))];
|
||||||
|
for (const cid of convIds) {
|
||||||
|
DB.markConversationRead(cid, partnerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length > 0) {
|
||||||
|
clearNotificationsFromClaudeMd(partnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
res.json({ hasMessages: true, messages });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ ROUTES ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* S'enregistrer
|
* S'enregistrer
|
||||||
* POST /register
|
* POST /register
|
||||||
*/
|
*/
|
||||||
app.post("/register", (req, res) => {
|
app.post("/register", (req, res) => {
|
||||||
const { partnerId, name } = req.body;
|
const { partnerId, name, projectPath } = req.body;
|
||||||
|
|
||||||
if (!partnerId) {
|
if (!partnerId) {
|
||||||
return res.status(400).json({ error: "partnerId required" });
|
return res.status(400).json({ error: "partnerId required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const partner = DB.registerPartner(partnerId, name || partnerId);
|
const partner = DB.registerPartner(partnerId, name || partnerId, projectPath);
|
||||||
console.log(`[BROKER] Registered: ${partner.name} (${partnerId})`);
|
console.log(`[BROKER] Registered: ${partner.name} (${partnerId})`);
|
||||||
|
|
||||||
res.json({ success: true, partner });
|
res.json({ success: true, partner });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Envoyer un message et attendre la réponse
|
* Envoyer un message dans une conversation
|
||||||
* POST /talk
|
* POST /talk
|
||||||
|
* Body: { fromId, to?, conversationId?, content }
|
||||||
|
* - to: pour créer/trouver une conv directe
|
||||||
|
* - conversationId: pour envoyer dans une conv existante
|
||||||
*/
|
*/
|
||||||
app.post("/talk", (req, res) => {
|
app.post("/talk", (req, res) => {
|
||||||
const { fromId, toId, content } = req.body;
|
const { fromId, to, conversationId, content } = req.body;
|
||||||
|
|
||||||
if (!fromId || !toId || !content) {
|
if (!fromId || !content) {
|
||||||
return res.status(400).json({ error: "fromId, toId, and content required" });
|
return res.status(400).json({ error: "fromId and content required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
if (!to && !conversationId) {
|
||||||
|
return res.status(400).json({ error: "Either 'to' or 'conversationId' required" });
|
||||||
|
}
|
||||||
|
|
||||||
// Enregistrer le message en DB
|
let conv;
|
||||||
const messageId = DB.sendMessage(fromId, toId, content, requestId);
|
let targetIds = [];
|
||||||
|
|
||||||
console.log(`[BROKER] ${fromId} -> ${toId}: "${content.substring(0, 50)}..."`);
|
if (conversationId) {
|
||||||
|
// Envoyer dans une conv existante
|
||||||
// Notifier le destinataire s'il est en attente
|
conv = DB.getConversation(conversationId);
|
||||||
notifyWaitingPartner(toId);
|
if (!conv) {
|
||||||
|
return res.status(404).json({ error: "Conversation not found" });
|
||||||
// Attendre la réponse (pas de timeout)
|
}
|
||||||
const responsePromise = new Promise((resolve) => {
|
if (!DB.isParticipant(conversationId, fromId)) {
|
||||||
pendingResponses.set(requestId, { resolve, fromId, toId, messageId });
|
return res.status(403).json({ error: "Not a participant of this conversation" });
|
||||||
|
}
|
||||||
|
targetIds = DB.getParticipants(conversationId).map(p => p.id).filter(id => id !== fromId);
|
||||||
|
} else {
|
||||||
|
// Conversation directe
|
||||||
|
const recipient = DB.getPartner(to);
|
||||||
|
if (!recipient) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Destinataire inconnu",
|
||||||
|
message: `"${to}" n'est pas enregistré. Il doit se register d'abord.`
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
conv = DB.getOrCreateDirectConversation(fromId, to);
|
||||||
|
targetIds = [to];
|
||||||
|
}
|
||||||
|
|
||||||
responsePromise.then((response) => {
|
// Envoyer le message
|
||||||
res.json(response);
|
const msgId = DB.sendMessage(conv.id, fromId, content);
|
||||||
|
console.log(`[BROKER] ${fromId} -> ${conv.id}: "${content.substring(0, 50)}..."`);
|
||||||
|
|
||||||
|
// Notifier les participants
|
||||||
|
let notifiedCount = 0;
|
||||||
|
for (const targetId of targetIds) {
|
||||||
|
const notified = notifyWaitingPartner(targetId, conv.id);
|
||||||
|
if (notified) {
|
||||||
|
notifiedCount++;
|
||||||
|
} else {
|
||||||
|
// Pas en écoute, écrire notification
|
||||||
|
writeNotificationToClaudeMd(targetId, fromId, conv.id, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
conversationId: conv.id,
|
||||||
|
messageId: msgId,
|
||||||
|
notified: notifiedCount,
|
||||||
|
queued: targetIds.length - notifiedCount
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupérer les messages non lus
|
* Écouter les messages (long-polling)
|
||||||
* GET /messages/:partnerId
|
* GET /listen/:partnerId?conversationId=xxx&timeout=5
|
||||||
*/
|
*/
|
||||||
app.get("/messages/:partnerId", (req, res) => {
|
app.get("/listen/:partnerId", (req, res) => {
|
||||||
const { partnerId } = req.params;
|
const { partnerId } = req.params;
|
||||||
|
const { conversationId } = req.query;
|
||||||
|
|
||||||
const messages = DB.getUndeliveredMessages(partnerId);
|
// Timeout en minutes (min 2, max 15, défaut 2)
|
||||||
|
let timeoutMinutes = parseInt(req.query.timeout) || 2;
|
||||||
|
timeoutMinutes = Math.max(2, Math.min(15, timeoutMinutes));
|
||||||
|
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||||
|
|
||||||
// Marquer comme délivrés
|
|
||||||
for (const msg of messages) {
|
|
||||||
DB.markDelivered(msg.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ messages });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attendre des messages (long-polling)
|
|
||||||
* GET /wait/:partnerId
|
|
||||||
*/
|
|
||||||
app.get("/wait/:partnerId", (req, res) => {
|
|
||||||
const { partnerId } = req.params;
|
|
||||||
|
|
||||||
// Mettre à jour le status
|
|
||||||
DB.setPartnerOnline(partnerId);
|
DB.setPartnerOnline(partnerId);
|
||||||
|
|
||||||
// Check s'il y a des messages en attente
|
// Vérifier s'il y a des messages non lus
|
||||||
const messages = DB.getUndeliveredMessages(partnerId);
|
let messages;
|
||||||
if (messages.length > 0) {
|
if (conversationId) {
|
||||||
// Marquer comme délivrés
|
if (!DB.isParticipant(conversationId, partnerId)) {
|
||||||
for (const msg of messages) {
|
return res.status(403).json({ error: "Not a participant of this conversation" });
|
||||||
DB.markDelivered(msg.id);
|
|
||||||
}
|
}
|
||||||
|
messages = DB.getUnreadMessagesInConv(partnerId, conversationId);
|
||||||
|
} else {
|
||||||
|
messages = DB.getUnreadMessages(partnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length > 0) {
|
||||||
|
// Marquer comme lu
|
||||||
|
const convIds = [...new Set(messages.map(m => m.conversation_id))];
|
||||||
|
for (const cid of convIds) {
|
||||||
|
DB.markConversationRead(cid, partnerId);
|
||||||
|
}
|
||||||
|
clearNotificationsFromClaudeMd(partnerId);
|
||||||
return res.json({ hasMessages: true, messages });
|
return res.json({ hasMessages: true, messages });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Annuler l'ancien waiting s'il existe
|
// Pas de messages, on attend
|
||||||
if (waitingPartners.has(partnerId)) {
|
if (waitingPartners.has(partnerId)) {
|
||||||
const old = waitingPartners.get(partnerId);
|
const old = waitingPartners.get(partnerId);
|
||||||
if (old.heartbeat) clearInterval(old.heartbeat);
|
if (old.heartbeat) clearInterval(old.heartbeat);
|
||||||
old.res.json({ hasMessages: false, messages: [], reason: "reconnect" });
|
if (old.timeout) clearTimeout(old.timeout);
|
||||||
}
|
|
||||||
|
|
||||||
// Heartbeat toutes les 30s
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
try {
|
try {
|
||||||
res.write(": heartbeat\n\n");
|
old.res.json({ hasMessages: false, messages: [], reason: "reconnect" });
|
||||||
} catch (e) {
|
} catch {}
|
||||||
clearInterval(heartbeat);
|
|
||||||
}
|
}
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
// Nettoyer quand la connexion se ferme
|
const timeout = setTimeout(() => {
|
||||||
|
if (waitingPartners.has(partnerId)) {
|
||||||
|
const waiting = waitingPartners.get(partnerId);
|
||||||
|
clearInterval(waiting.heartbeat);
|
||||||
|
waitingPartners.delete(partnerId);
|
||||||
|
try {
|
||||||
|
res.json({ hasMessages: false, messages: [], reason: "timeout", timeoutMinutes });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {}, 30000);
|
||||||
|
|
||||||
res.on("close", () => {
|
res.on("close", () => {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
|
clearTimeout(timeout);
|
||||||
waitingPartners.delete(partnerId);
|
waitingPartners.delete(partnerId);
|
||||||
DB.setPartnerOffline(partnerId);
|
DB.setPartnerOffline(partnerId);
|
||||||
console.log(`[BROKER] ${partnerId} disconnected`);
|
console.log(`[BROKER] ${partnerId} disconnected`);
|
||||||
});
|
});
|
||||||
|
|
||||||
waitingPartners.set(partnerId, { res, heartbeat });
|
waitingPartners.set(partnerId, { res, heartbeat, timeout, conversationId });
|
||||||
|
console.log(`[BROKER] ${partnerId} is now listening${conversationId ? ` on ${conversationId}` : ""}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Répondre à un message
|
* Créer une conversation de groupe
|
||||||
* POST /respond
|
* POST /conversations
|
||||||
|
* Body: { creatorId, name, participants: [] }
|
||||||
*/
|
*/
|
||||||
app.post("/respond", (req, res) => {
|
app.post("/conversations", (req, res) => {
|
||||||
const { fromId, toId, content, requestId } = req.body;
|
const { creatorId, name, participants } = req.body;
|
||||||
|
|
||||||
console.log(`[BROKER] ${fromId} responded to ${toId}: "${content.substring(0, 50)}..."`);
|
if (!creatorId || !name || !participants?.length) {
|
||||||
|
return res.status(400).json({ error: "creatorId, name, and participants required" });
|
||||||
// Trouver la requête en attente
|
|
||||||
if (requestId && pendingResponses.has(requestId)) {
|
|
||||||
const { resolve, messageId } = pendingResponses.get(requestId);
|
|
||||||
pendingResponses.delete(requestId);
|
|
||||||
|
|
||||||
// Enregistrer la réponse en DB
|
|
||||||
DB.sendResponse(fromId, toId, content, messageId);
|
|
||||||
|
|
||||||
resolve({ success: true, content });
|
|
||||||
} else {
|
|
||||||
// Pas de requête en attente, juste enregistrer comme message normal
|
|
||||||
DB.sendMessage(fromId, toId, content, null);
|
|
||||||
notifyWaitingPartner(toId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
// Vérifier que tous les participants existent
|
||||||
|
for (const pid of participants) {
|
||||||
|
if (!DB.getPartner(pid)) {
|
||||||
|
return res.status(404).json({ error: `Partner "${pid}" not found` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = DB.createGroupConversation(name, creatorId, participants);
|
||||||
|
console.log(`[BROKER] Group conversation created: ${conv.id} by ${creatorId}`);
|
||||||
|
|
||||||
|
res.json({ success: true, conversation: conv });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lister les conversations d'un partenaire
|
||||||
|
* GET /conversations/:partnerId
|
||||||
|
*/
|
||||||
|
app.get("/conversations/:partnerId", (req, res) => {
|
||||||
|
const { partnerId } = req.params;
|
||||||
|
const conversations = DB.getConversationsByPartner(partnerId);
|
||||||
|
|
||||||
|
// Ajouter les participants à chaque conversation
|
||||||
|
const convsWithParticipants = conversations.map(conv => ({
|
||||||
|
...conv,
|
||||||
|
participants: DB.getParticipants(conv.id).map(p => ({ id: p.id, name: p.name }))
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ conversations: convsWithParticipants });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quitter une conversation
|
||||||
|
* POST /conversations/:conversationId/leave
|
||||||
|
* Body: { partnerId }
|
||||||
|
*/
|
||||||
|
app.post("/conversations/:conversationId/leave", (req, res) => {
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
const { partnerId } = req.body;
|
||||||
|
|
||||||
|
if (!partnerId) {
|
||||||
|
return res.status(400).json({ error: "partnerId required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = DB.leaveConversation(conversationId, partnerId);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return res.status(400).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BROKER] ${partnerId} left ${conversationId}${result.archived ? " (archived)" : ""}`);
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtenir l'historique d'une conversation
|
||||||
|
* GET /conversations/:conversationId/messages?limit=50
|
||||||
|
*/
|
||||||
|
app.get("/conversations/:conversationId/messages", (req, res) => {
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
|
|
||||||
|
const conv = DB.getConversation(conversationId);
|
||||||
|
if (!conv) {
|
||||||
|
return res.status(404).json({ error: "Conversation not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = DB.getMessages(conversationId, limit);
|
||||||
|
res.json({ conversation: conv, messages });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtenir les participants d'une conversation
|
||||||
|
* GET /conversations/:conversationId/participants
|
||||||
|
*/
|
||||||
|
app.get("/conversations/:conversationId/participants", (req, res) => {
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
const participants = DB.getParticipants(conversationId);
|
||||||
|
res.json({ participants });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -158,20 +405,33 @@ app.post("/respond", (req, res) => {
|
|||||||
* GET /partners
|
* GET /partners
|
||||||
*/
|
*/
|
||||||
app.get("/partners", (req, res) => {
|
app.get("/partners", (req, res) => {
|
||||||
const partners = DB.getAllPartners();
|
const partners = DB.getAllPartners().map((p) => ({
|
||||||
|
...p,
|
||||||
|
isListening: waitingPartners.has(p.id),
|
||||||
|
}));
|
||||||
res.json({ partners });
|
res.json({ partners });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Historique de conversation
|
* Définir le status message d'un partenaire
|
||||||
* GET /history/:partner1/:partner2
|
* POST /partners/:partnerId/status
|
||||||
*/
|
*/
|
||||||
app.get("/history/:partner1/:partner2", (req, res) => {
|
app.post("/partners/:partnerId/status", (req, res) => {
|
||||||
const { partner1, partner2 } = req.params;
|
const { partnerId } = req.params;
|
||||||
const limit = parseInt(req.query.limit) || 50;
|
const { message } = req.body;
|
||||||
|
DB.setStatusMessage(partnerId, message || null);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
const messages = DB.getConversation(partner1, partner2, limit);
|
/**
|
||||||
res.json({ messages });
|
* Activer/désactiver les notifications
|
||||||
|
* POST /partners/:partnerId/notifications
|
||||||
|
*/
|
||||||
|
app.post("/partners/:partnerId/notifications", (req, res) => {
|
||||||
|
const { partnerId } = req.params;
|
||||||
|
const { enabled } = req.body;
|
||||||
|
DB.setNotificationsEnabled(partnerId, enabled);
|
||||||
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -180,28 +440,10 @@ app.get("/history/:partner1/:partner2", (req, res) => {
|
|||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
const partners = DB.getAllPartners();
|
const partners = DB.getAllPartners();
|
||||||
const online = partners.filter((p) => p.status === "online").length;
|
const online = partners.filter((p) => p.status === "online").length;
|
||||||
res.json({ status: "ok", partners: partners.length, online });
|
const listening = waitingPartners.size;
|
||||||
|
res.json({ status: "ok", partners: partners.length, online, listening });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifie un partenaire en attente qu'il a des messages
|
|
||||||
*/
|
|
||||||
function notifyWaitingPartner(partnerId) {
|
|
||||||
if (waitingPartners.has(partnerId)) {
|
|
||||||
const { res, heartbeat } = waitingPartners.get(partnerId);
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
waitingPartners.delete(partnerId);
|
|
||||||
|
|
||||||
const messages = DB.getUndeliveredMessages(partnerId);
|
|
||||||
for (const msg of messages) {
|
|
||||||
DB.markDelivered(msg.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ hasMessages: true, messages });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[BROKER] Claude Duo Broker v2 running on http://localhost:${PORT}`);
|
console.log(`[BROKER] Claude Duo Broker v3 (Conversations) running on http://localhost:${PORT}`);
|
||||||
console.log(`[BROKER] Database: data/duo.db`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
110
docs/db-schema.md
Normal file
110
docs/db-schema.md
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# Structure de la Base de Données v3
|
||||||
|
|
||||||
|
La base SQLite est créée automatiquement dans `data/duo.db`.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### partners
|
||||||
|
|
||||||
|
Stocke les informations sur les partenaires (instances Claude Code).
|
||||||
|
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `id` | TEXT (PK) | Identifiant unique (basé sur le nom du dossier projet) |
|
||||||
|
| `name` | TEXT | Nom d'affichage du partenaire |
|
||||||
|
| `project_path` | TEXT | Chemin absolu du projet (pour les notifications CLAUDE.md) |
|
||||||
|
| `created_at` | DATETIME | Date de première inscription |
|
||||||
|
| `last_seen` | DATETIME | Dernière activité |
|
||||||
|
| `status` | TEXT | `online` ou `offline` |
|
||||||
|
| `status_message` | TEXT | Message de status personnalisé |
|
||||||
|
| `notifications_enabled` | INTEGER | 1 = activées, 0 = désactivées |
|
||||||
|
|
||||||
|
### conversations
|
||||||
|
|
||||||
|
Stocke les conversations (directes ou de groupe).
|
||||||
|
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `id` | TEXT (PK) | `direct_<a>_<b>` pour direct, `group_<ts>_<rand>` pour groupe |
|
||||||
|
| `name` | TEXT | Nom de la conversation (null pour direct) |
|
||||||
|
| `type` | TEXT | `direct` ou `group` |
|
||||||
|
| `created_at` | DATETIME | Date de création |
|
||||||
|
| `created_by` | TEXT (FK) | Créateur de la conversation |
|
||||||
|
| `is_archived` | INTEGER | 1 = archivée (plus de participants) |
|
||||||
|
|
||||||
|
### conversation_participants
|
||||||
|
|
||||||
|
Lie les partenaires aux conversations.
|
||||||
|
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `conversation_id` | TEXT (PK) | Référence conversation |
|
||||||
|
| `partner_id` | TEXT (PK) | Référence partenaire |
|
||||||
|
| `joined_at` | DATETIME | Date d'arrivée |
|
||||||
|
| `last_read_at` | DATETIME | Dernier message lu (pour calculer les non lus) |
|
||||||
|
|
||||||
|
### messages
|
||||||
|
|
||||||
|
Stocke tous les messages.
|
||||||
|
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `id` | INTEGER (PK) | Auto-increment |
|
||||||
|
| `conversation_id` | TEXT (FK) | Conversation du message |
|
||||||
|
| `from_id` | TEXT (FK) | Expéditeur |
|
||||||
|
| `content` | TEXT | Contenu du message |
|
||||||
|
| `created_at` | DATETIME | Date de création |
|
||||||
|
|
||||||
|
## Diagramme ER
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ partners │
|
||||||
|
├─────────────────────┤
|
||||||
|
│ id (PK) │◄────────────────────────────┐
|
||||||
|
│ name │ │
|
||||||
|
│ project_path │ │
|
||||||
|
│ status │ │
|
||||||
|
│ status_message │ │
|
||||||
|
│ notifications_enabled│ │
|
||||||
|
│ created_at │ │
|
||||||
|
│ last_seen │ │
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────────┐ │
|
||||||
|
│ conversation_participants │ │
|
||||||
|
├─────────────────────────────┤ │
|
||||||
|
│ conversation_id (PK, FK) │─────┐ │
|
||||||
|
│ partner_id (PK, FK) │─────│───────────────┘
|
||||||
|
│ joined_at │ │
|
||||||
|
│ last_read_at │ │
|
||||||
|
└─────────────────────────────┘ │
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ conversations │ │ messages │
|
||||||
|
├─────────────────────┤ ├─────────────────────┤
|
||||||
|
│ id (PK) │◄───│ conversation_id (FK)│
|
||||||
|
│ name │ │ id (PK) │
|
||||||
|
│ type │ │ from_id (FK) │───► partners.id
|
||||||
|
│ created_at │ │ content │
|
||||||
|
│ created_by (FK) │ │ created_at │
|
||||||
|
│ is_archived │ └─────────────────────┘
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conversations directes vs groupe
|
||||||
|
|
||||||
|
### Direct (1-to-1)
|
||||||
|
- ID déterministe: `direct_alice_bob` (trié alphabétiquement)
|
||||||
|
- Créée automatiquement au premier message
|
||||||
|
- Impossible à quitter
|
||||||
|
- Toujours 2 participants
|
||||||
|
|
||||||
|
### Groupe
|
||||||
|
- ID aléatoire: `group_1706123456789_abc123def`
|
||||||
|
- Créée explicitement via `create_conversation`
|
||||||
|
- Possibilité de quitter
|
||||||
|
- Auto-archivée quand plus de participants
|
||||||
52
docs/schema.sql
Normal file
52
docs/schema.sql
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
-- Schema de la base de données Claude Duo v3 (Conversations)
|
||||||
|
-- La base est créée automatiquement par broker/db.js
|
||||||
|
|
||||||
|
-- Partenaires (instances Claude Code)
|
||||||
|
CREATE TABLE IF NOT EXISTS partners (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
project_path TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'online',
|
||||||
|
status_message TEXT,
|
||||||
|
notifications_enabled INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Conversations
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id TEXT PRIMARY KEY, -- direct_<a>_<b> ou group_<timestamp>_<random>
|
||||||
|
name TEXT, -- Nom (null pour les direct)
|
||||||
|
type TEXT NOT NULL DEFAULT 'direct', -- 'direct' ou 'group'
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by TEXT, -- Créateur (pour les groupes)
|
||||||
|
is_archived INTEGER DEFAULT 0, -- Archivée quand plus de participants
|
||||||
|
FOREIGN KEY (created_by) REFERENCES partners(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Participants aux conversations
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
partner_id TEXT NOT NULL,
|
||||||
|
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_read_at DATETIME, -- Pour calculer les messages non lus
|
||||||
|
PRIMARY KEY (conversation_id, partner_id),
|
||||||
|
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
|
||||||
|
FOREIGN KEY (partner_id) REFERENCES partners(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Messages
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
from_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
|
||||||
|
FOREIGN KEY (from_id) REFERENCES partners(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_participants_partner ON conversation_participants(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversations_archived ON conversations(is_archived);
|
||||||
@ -7,60 +7,39 @@ import {
|
|||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
|
import { brokerFetch, myId, PARTNER_NAME, cwd, setRegistered } from "./shared.js";
|
||||||
const PARTNER_NAME = process.env.PARTNER_NAME || "Claude";
|
|
||||||
|
|
||||||
// ID basé sur le dossier de travail (unique par projet)
|
// Import all tools
|
||||||
const cwd = process.cwd();
|
import * as register from "./tools/register.js";
|
||||||
const projectName = cwd.split(/[/\\]/).pop().toLowerCase().replace(/[^a-z0-9]/g, "_");
|
import * as talk from "./tools/talk.js";
|
||||||
const myId = projectName || "partner";
|
import * as listen from "./tools/listen.js";
|
||||||
|
import * as listPartners from "./tools/list_partners.js";
|
||||||
|
import * as listConversations from "./tools/list_conversations.js";
|
||||||
|
import * as createConversation from "./tools/create_conversation.js";
|
||||||
|
import * as leaveConversation from "./tools/leave_conversation.js";
|
||||||
|
import * as history from "./tools/history.js";
|
||||||
|
import * as setStatus from "./tools/set_status.js";
|
||||||
|
import * as notifications from "./tools/notifications.js";
|
||||||
|
|
||||||
let isRegistered = false;
|
// Tool registry
|
||||||
let lastReceivedRequestId = null; // Pour savoir à quel message répondre
|
const tools = {
|
||||||
|
register,
|
||||||
/**
|
talk,
|
||||||
* Appel HTTP au broker
|
listen,
|
||||||
*/
|
list_partners: listPartners,
|
||||||
async function brokerFetch(path, options = {}, timeoutMs = 0) {
|
list_conversations: listConversations,
|
||||||
const url = `${BROKER_URL}${path}`;
|
create_conversation: createConversation,
|
||||||
|
leave_conversation: leaveConversation,
|
||||||
const fetchOptions = {
|
history,
|
||||||
...options,
|
set_status: setStatus,
|
||||||
headers: {
|
notifications,
|
||||||
"Content-Type": "application/json",
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (timeoutMs > 0) {
|
// Create MCP server
|
||||||
const controller = new AbortController();
|
|
||||||
setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
fetchOptions.signal = controller.signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* S'enregistrer auprès du broker
|
|
||||||
*/
|
|
||||||
async function ensureRegistered() {
|
|
||||||
if (!isRegistered) {
|
|
||||||
await brokerFetch("/register", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME }),
|
|
||||||
});
|
|
||||||
isRegistered = true;
|
|
||||||
console.error(`[MCP-PARTNER] Registered as ${PARTNER_NAME} (${myId})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Créer le serveur MCP
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{
|
||||||
name: "mcp-claude-duo-partner",
|
name: "mcp-claude-duo-partner",
|
||||||
version: "2.0.0",
|
version: "3.0.0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@ -69,414 +48,45 @@ const server = new Server(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Liste des tools
|
// List all tools
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
return {
|
return {
|
||||||
tools: [
|
tools: Object.values(tools).map((t) => t.definition),
|
||||||
{
|
|
||||||
name: "register",
|
|
||||||
description:
|
|
||||||
"S'enregistre auprès du réseau de conversation. Utilise au début pour te connecter.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: {
|
|
||||||
type: "string",
|
|
||||||
description: "Ton nom/pseudo (optionnel)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "talk",
|
|
||||||
description:
|
|
||||||
"Envoie un message à un partenaire et attend sa réponse. Pour initier ou continuer une conversation.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
message: {
|
|
||||||
type: "string",
|
|
||||||
description: "Le message à envoyer",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
type: "string",
|
|
||||||
description: "L'ID du destinataire (optionnel si un seul partenaire)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["message"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "check_messages",
|
|
||||||
description:
|
|
||||||
"Vérifie s'il y a des messages en attente. Les messages sont bufferisés, donc pas besoin d'écouter en permanence.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
wait: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Si true, attend qu'un message arrive (long-polling). Sinon retourne immédiatement.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "reply",
|
|
||||||
description:
|
|
||||||
"Répond au dernier message reçu. À utiliser après check_messages quand quelqu'un attend ta réponse.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
message: {
|
|
||||||
type: "string",
|
|
||||||
description: "Ta réponse",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["message"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "listen",
|
|
||||||
description:
|
|
||||||
"Écoute en temps réel les messages entrants (long-polling). Bloque jusqu'à ce qu'un message arrive.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list_partners",
|
|
||||||
description: "Liste tous les partenaires connectés au réseau.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "history",
|
|
||||||
description: "Récupère l'historique de conversation avec un partenaire.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
partnerId: {
|
|
||||||
type: "string",
|
|
||||||
description: "L'ID du partenaire",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Nombre de messages max (défaut: 20)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["partnerId"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler des tools
|
// Handle tool calls
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
switch (name) {
|
const tool = tools[name];
|
||||||
case "register": {
|
if (!tool) {
|
||||||
try {
|
|
||||||
const displayName = args.name || PARTNER_NAME;
|
|
||||||
await brokerFetch("/register", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ partnerId: myId, name: displayName }),
|
|
||||||
});
|
|
||||||
isRegistered = true;
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Connecté en tant que **${displayName}** (ID: ${myId})`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "talk": {
|
|
||||||
try {
|
|
||||||
await ensureRegistered();
|
|
||||||
|
|
||||||
// Trouver le destinataire
|
|
||||||
let toId = args.to;
|
|
||||||
if (!toId) {
|
|
||||||
const { partners } = await brokerFetch("/partners");
|
|
||||||
const other = partners?.find((p) => p.id !== myId);
|
|
||||||
if (!other) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Aucun partenaire connecté. Attends qu'un autre Claude se connecte.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
toId = other.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await brokerFetch("/talk", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
fromId: myId,
|
|
||||||
toId,
|
|
||||||
content: args.message,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `**${toId}:** ${response.content}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "check_messages": {
|
|
||||||
try {
|
|
||||||
await ensureRegistered();
|
|
||||||
|
|
||||||
let response;
|
|
||||||
if (args.wait) {
|
|
||||||
// Long-polling
|
|
||||||
response = await brokerFetch(`/wait/${myId}`);
|
|
||||||
} else {
|
|
||||||
// Récupération immédiate
|
|
||||||
response = await brokerFetch(`/messages/${myId}`);
|
|
||||||
response = { messages: response.messages, hasMessages: response.messages?.length > 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.hasMessages || !response.messages?.length) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Pas de nouveaux messages.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Formater les messages
|
|
||||||
let text = `**${response.messages.length} message(s) reçu(s):**\n\n`;
|
|
||||||
for (const msg of response.messages) {
|
|
||||||
text += `**${msg.from_id}:** ${msg.content}\n`;
|
|
||||||
// Garder le request_id du dernier message pour pouvoir y répondre
|
|
||||||
if (msg.request_id) {
|
|
||||||
lastReceivedRequestId = msg.request_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastReceivedRequestId) {
|
|
||||||
text += `\n_Utilise \`reply\` pour répondre._`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text }],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "reply": {
|
|
||||||
try {
|
|
||||||
await ensureRegistered();
|
|
||||||
|
|
||||||
if (!lastReceivedRequestId) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Aucun message en attente de réponse. Utilise `check_messages` d'abord.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trouver le destinataire original
|
|
||||||
const { partners } = await brokerFetch("/partners");
|
|
||||||
const other = partners?.find((p) => p.id !== myId);
|
|
||||||
const toId = other?.id || "unknown";
|
|
||||||
|
|
||||||
await brokerFetch("/respond", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
fromId: myId,
|
|
||||||
toId,
|
|
||||||
content: args.message,
|
|
||||||
requestId: lastReceivedRequestId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
lastReceivedRequestId = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Réponse envoyée.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "listen": {
|
|
||||||
try {
|
|
||||||
await ensureRegistered();
|
|
||||||
|
|
||||||
// Long-polling - attend qu'un message arrive
|
|
||||||
console.error("[MCP-PARTNER] Listening...");
|
|
||||||
const response = await brokerFetch(`/wait/${myId}`);
|
|
||||||
|
|
||||||
if (!response.hasMessages || !response.messages?.length) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Timeout. Rappelle `listen` pour continuer à écouter.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Formater les messages
|
|
||||||
let text = "";
|
|
||||||
for (const msg of response.messages) {
|
|
||||||
text += `**${msg.from_id}:** ${msg.content}\n`;
|
|
||||||
if (msg.request_id) {
|
|
||||||
lastReceivedRequestId = msg.request_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastReceivedRequestId) {
|
|
||||||
text += `\n_Utilise \`reply\` pour répondre._`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text }],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "list_partners": {
|
|
||||||
try {
|
|
||||||
const { partners } = await brokerFetch("/partners");
|
|
||||||
|
|
||||||
if (!partners?.length) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "Aucun partenaire enregistré." }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = "**Partenaires:**\n\n";
|
|
||||||
for (const p of partners) {
|
|
||||||
const status = p.status === "online" ? "🟢" : "⚫";
|
|
||||||
const isMe = p.id === myId ? " (toi)" : "";
|
|
||||||
text += `${status} **${p.name}** (${p.id})${isMe}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text }],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "history": {
|
|
||||||
try {
|
|
||||||
const limit = args.limit || 20;
|
|
||||||
const response = await brokerFetch(
|
|
||||||
`/history/${myId}/${args.partnerId}?limit=${limit}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.messages?.length) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Pas d'historique avec ${args.partnerId}.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = `**Historique avec ${args.partnerId}:**\n\n`;
|
|
||||||
// Inverser pour avoir l'ordre chronologique
|
|
||||||
const messages = response.messages.reverse();
|
|
||||||
for (const msg of messages) {
|
|
||||||
const date = new Date(msg.created_at).toLocaleString();
|
|
||||||
text += `[${date}] **${msg.from_id}:** ${msg.content}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text }],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Tool inconnu: ${name}` }],
|
content: [{ type: "text", text: `Tool inconnu: ${name}` }],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await tool.handler(args || {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Démarrer
|
// Start server
|
||||||
async function main() {
|
async function main() {
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
console.error(`[MCP-PARTNER] Started (ID: ${myId})`);
|
console.error(`[MCP-PARTNER] Started (ID: ${myId})`);
|
||||||
|
|
||||||
|
// Auto-register on startup
|
||||||
|
try {
|
||||||
|
await brokerFetch("/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME, projectPath: cwd }),
|
||||||
|
});
|
||||||
|
setRegistered(true);
|
||||||
|
console.error(`[MCP-PARTNER] Auto-registered as ${PARTNER_NAME} (${myId})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MCP-PARTNER] Auto-register failed: ${error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(console.error);
|
main().catch(console.error);
|
||||||
|
|||||||
58
mcp-partner/shared.js
Normal file
58
mcp-partner/shared.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// Shared utilities and state for MCP partner
|
||||||
|
|
||||||
|
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
|
||||||
|
const PARTNER_NAME = process.env.PARTNER_NAME || "Claude";
|
||||||
|
|
||||||
|
// ID basé sur le dossier de travail (unique par projet)
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const projectName = cwd.split(/[/\\]/).pop().toLowerCase().replace(/[^a-z0-9]/g, "_");
|
||||||
|
const myId = projectName || "partner";
|
||||||
|
|
||||||
|
let isRegistered = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appel HTTP au broker
|
||||||
|
*/
|
||||||
|
async function brokerFetch(path, options = {}) {
|
||||||
|
const url = `${BROKER_URL}${path}`;
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S'enregistrer auprès du broker
|
||||||
|
*/
|
||||||
|
async function ensureRegistered() {
|
||||||
|
if (!isRegistered) {
|
||||||
|
await brokerFetch("/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME, projectPath: cwd }),
|
||||||
|
});
|
||||||
|
isRegistered = true;
|
||||||
|
console.error(`[MCP-PARTNER] Registered as ${PARTNER_NAME} (${myId}) at ${cwd}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegistered(value) {
|
||||||
|
isRegistered = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
BROKER_URL,
|
||||||
|
PARTNER_NAME,
|
||||||
|
cwd,
|
||||||
|
myId,
|
||||||
|
isRegistered,
|
||||||
|
brokerFetch,
|
||||||
|
ensureRegistered,
|
||||||
|
setRegistered,
|
||||||
|
};
|
||||||
58
mcp-partner/tools/create_conversation.js
Normal file
58
mcp-partner/tools/create_conversation.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "create_conversation",
|
||||||
|
description: "Crée une nouvelle conversation de groupe.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Nom de la conversation",
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
type: "string",
|
||||||
|
description: "IDs des participants séparés par des virgules",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name", "participants"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
try {
|
||||||
|
await ensureRegistered();
|
||||||
|
|
||||||
|
const participantIds = args.participants.split(",").map((s) => s.trim());
|
||||||
|
|
||||||
|
const response = await brokerFetch("/conversations", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
creatorId: myId,
|
||||||
|
name: args.name,
|
||||||
|
participants: participantIds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Conversation créée: **${args.name}**\nID: \`${response.conversation.id}\`\nParticipants: ${participantIds.join(", ")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
61
mcp-partner/tools/history.js
Normal file
61
mcp-partner/tools/history.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { brokerFetch, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "history",
|
||||||
|
description: "Récupère l'historique d'une conversation.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
conversation: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID de la conversation",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Nombre de messages max (défaut: 50)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["conversation"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
try {
|
||||||
|
await ensureRegistered();
|
||||||
|
|
||||||
|
const limit = args.limit || 50;
|
||||||
|
const response = await brokerFetch(
|
||||||
|
`/conversations/${args.conversation}/messages?limit=${limit}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.messages?.length) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Pas de messages dans cette conversation.` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const convName = response.conversation.name || response.conversation.id;
|
||||||
|
let text = `**Historique: ${convName}**\n\n`;
|
||||||
|
|
||||||
|
for (const msg of response.messages) {
|
||||||
|
const date = new Date(msg.created_at).toLocaleString();
|
||||||
|
text += `[${date}] **${msg.from_id}:** ${msg.content}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
mcp-partner/tools/leave_conversation.js
Normal file
44
mcp-partner/tools/leave_conversation.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "leave_conversation",
|
||||||
|
description: "Quitte une conversation de groupe. Impossible de quitter une conv directe.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
conversation: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID de la conversation à quitter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["conversation"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
try {
|
||||||
|
await ensureRegistered();
|
||||||
|
|
||||||
|
const response = await brokerFetch(`/conversations/${args.conversation}/leave`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ partnerId: myId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const archived = response.archived ? " (conversation archivée car plus de participants)" : "";
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Tu as quitté la conversation.${archived}` }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
mcp-partner/tools/list_conversations.js
Normal file
42
mcp-partner/tools/list_conversations.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "list_conversations",
|
||||||
|
description: "Liste toutes tes conversations actives.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler() {
|
||||||
|
try {
|
||||||
|
await ensureRegistered();
|
||||||
|
|
||||||
|
const { conversations } = await brokerFetch(`/conversations/${myId}`);
|
||||||
|
|
||||||
|
if (!conversations?.length) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Aucune conversation." }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "**Conversations:**\n\n";
|
||||||
|
for (const conv of conversations) {
|
||||||
|
const type = conv.type === "direct" ? "💬" : "👥";
|
||||||
|
const unread = conv.unread_count > 0 ? ` (${conv.unread_count} non lu${conv.unread_count > 1 ? "s" : ""})` : "";
|
||||||
|
const participants = conv.participants.map((p) => p.name).join(", ");
|
||||||
|
const name = conv.name || participants;
|
||||||
|
text += `${type} **${name}**${unread}\n ID: \`${conv.id}\`\n Participants: ${participants}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
mcp-partner/tools/list_partners.js
Normal file
42
mcp-partner/tools/list_partners.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { brokerFetch, myId } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "list_partners",
|
||||||
|
description: "Liste tous les partenaires connectés au réseau.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler() {
|
||||||
|
try {
|
||||||
|
const { partners } = await brokerFetch("/partners");
|
||||||
|
|
||||||
|
if (!partners?.length) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Aucun partenaire enregistré." }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "**Partenaires:**\n\n";
|
||||||
|
for (const p of partners) {
|
||||||
|
const status = p.status === "online" ? "🟢" : "⚫";
|
||||||
|
const listening = p.isListening ? " 👂" : "";
|
||||||
|
const isMe = p.id === myId ? " (toi)" : "";
|
||||||
|
const statusMsg = p.status_message ? ` — _${p.status_message}_` : "";
|
||||||
|
text += `${status}${listening} **${p.name}** (${p.id})${isMe}${statusMsg}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
text += "\n_Légende: 🟢 en ligne, ⚫ hors ligne, 👂 en écoute_";
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
82
mcp-partner/tools/listen.js
Normal file
82
mcp-partner/tools/listen.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "listen",
|
||||||
|
description: "Écoute les messages entrants. Retourne immédiatement s'il y a des messages non lus, sinon attend.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
conversation: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID de la conversation à écouter (optionnel, toutes par défaut)",
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
type: "number",
|
||||||
|
description: "Timeout en minutes (min: 2, max: 15, défaut: 2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
try {
|
||||||
|
await ensureRegistered();
|
||||||
|
|
||||||
|
let timeoutMinutes = args.timeout || 2;
|
||||||
|
timeoutMinutes = Math.max(2, Math.min(15, timeoutMinutes));
|
||||||
|
|
||||||
|
let url = `/listen/${myId}?timeout=${timeoutMinutes}`;
|
||||||
|
if (args.conversation) {
|
||||||
|
url += `&conversationId=${encodeURIComponent(args.conversation)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[MCP-PARTNER] Listening (timeout: ${timeoutMinutes}min)...`);
|
||||||
|
const response = await brokerFetch(url);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.hasMessages || !response.messages?.length) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Timeout après ${response.timeoutMinutes || timeoutMinutes} minutes. Rappelle \`listen\` pour continuer.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouper par conversation
|
||||||
|
const byConv = {};
|
||||||
|
for (const msg of response.messages) {
|
||||||
|
if (!byConv[msg.conversation_id]) {
|
||||||
|
byConv[msg.conversation_id] = [];
|
||||||
|
}
|
||||||
|
byConv[msg.conversation_id].push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = `**${response.messages.length} message(s) reçu(s):**\n\n`;
|
||||||
|
for (const [convId, msgs] of Object.entries(byConv)) {
|
||||||
|
text += `📁 **${convId}**\n`;
|
||||||
|
for (const msg of msgs) {
|
||||||
|
const time = new Date(msg.created_at).toLocaleTimeString();
|
||||||
|
text += ` [${time}] **${msg.from_id}:** ${msg.content}\n`;
|
||||||
|
}
|
||||||
|
text += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
mcp-partner/tools/notifications.js
Normal file
37
mcp-partner/tools/notifications.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "notifications",
|
||||||
|
description: "Active ou désactive les notifications dans CLAUDE.md.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
enabled: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "true pour activer, false pour désactiver",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["enabled"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
try {
|
||||||
|
await ensureRegistered();
|
||||||
|
|
||||||
|
await brokerFetch(`/partners/${myId}/notifications`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ enabled: args.enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = args.enabled ? "activées" : "désactivées";
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Notifications ${status}.` }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
mcp-partner/tools/register.js
Normal file
40
mcp-partner/tools/register.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { brokerFetch, myId, cwd, PARTNER_NAME, setRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "register",
|
||||||
|
description: "S'enregistre auprès du réseau de conversation. Optionnel car auto-register au démarrage.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Ton nom/pseudo (optionnel)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
try {
|
||||||
|
const displayName = args.name || PARTNER_NAME;
|
||||||
|
await brokerFetch("/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ partnerId: myId, name: displayName, projectPath: cwd }),
|
||||||
|
});
|
||||||
|
setRegistered(true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Connecté en tant que **${displayName}** (ID: ${myId})\nProjet: ${cwd}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
41
mcp-partner/tools/set_status.js
Normal file
41
mcp-partner/tools/set_status.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "set_status",
|
||||||
|
description: "Définit ton status visible par les autres partenaires.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
message: {
|
||||||
|
type: "string",
|
||||||
|
description: "Ton status (ex: 'Working on auth module'). Vide pour effacer.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
try {
|
||||||
|
await ensureRegistered();
|
||||||
|
|
||||||
|
await brokerFetch(`/partners/${myId}/status`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ message: args.message || null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.message) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Status: _${args.message}_` }],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Status effacé." }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
90
mcp-partner/tools/talk.js
Normal file
90
mcp-partner/tools/talk.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||||
|
|
||||||
|
export const definition = {
|
||||||
|
name: "talk",
|
||||||
|
description: "Envoie un message dans une conversation. Crée automatiquement une conv directe si besoin.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
message: {
|
||||||
|
type: "string",
|
||||||
|
description: "Le message à envoyer",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
description: "L'ID du destinataire (pour conv directe)",
|
||||||
|
},
|
||||||
|
conversation: {
|
||||||
|
type: "string",
|
||||||
|
description: "L'ID de la conversation (pour conv existante)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["message"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handler(args) {
|
||||||
|
try {
|
||||||
|
await ensureRegistered();
|
||||||
|
|
||||||
|
if (!args.to && !args.conversation) {
|
||||||
|
// Essayer de trouver un partenaire unique
|
||||||
|
const { partners } = await brokerFetch("/partners");
|
||||||
|
const others = partners?.filter((p) => p.id !== myId);
|
||||||
|
if (!others?.length) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Aucun partenaire enregistré. Précise `to` ou `conversation`." }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (others.length === 1) {
|
||||||
|
args.to = others[0].id;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Plusieurs partenaires: ${others.map((p) => p.id).join(", ")}. Précise \`to\` ou \`conversation\`.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await brokerFetch("/talk", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
fromId: myId,
|
||||||
|
to: args.to,
|
||||||
|
conversationId: args.conversation,
|
||||||
|
content: args.message,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${response.error}\n${response.message || ""}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = response.notified > 0
|
||||||
|
? `${response.notified} notifié(s) en temps réel`
|
||||||
|
: `${response.queued} en file d'attente`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Message envoyé dans ${response.conversationId}\n${status}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user