Construire « Anne », un bot IA, de A à Z
Ce guide vous fait construire, tester et déployer un vrai bot GPChat propulsé par l'IA, en Node.js et TypeScript — sans rien laisser de côté : configuration du projet, réception des messages, appel à un modèle d'IA, mémoire de conversation, tests sans risque, puis mise en ligne pour de bon. Comptez environ 45 minutes, et moins de 200 lignes de code au total.
Ce tutoriel part du principe que vous connaissez les bases de la plateforme (application, bot, clé API, webhook). Si un terme vous échappe, la page Concepts clés de la documentation les définit tous en une table.
Il n'existe pas d'environnement de test séparé (« staging ») pour les développeurs tiers — l'app GPChat distribuée sur les stores ne parle qu'à la production. Tout ce tutoriel se déroule donc directement en production, dès la première étape, et c'est parfaitement sûr : voir l'encadré de l'étape 1.
Prérequis
| Outil | Version | Pourquoi |
|---|---|---|
| Node.js | ≥ 18 | fetch natif, pas de dépendance HTTP supplémentaire |
| Un compte GPChat | — | Pour créer votre application et votre bot |
| Un terminal + un éditeur de code | — | VS Code ou équivalent |
| Une clé Gemini gratuite | — | L'« intelligence » du bot (étape 2, gratuit, 2 minutes) |
Aucune base de données, aucun compte cloud payant, aucun framework superflu : le but est de vous montrer que l'essentiel tient dans très peu de code.
Comment ça s'articule
Cinq acteurs, un seul aller-retour à chaque message :
- Un utilisateur écrit à Anne depuis l'app GPChat (mobile ou web).
- GPChat appelle votre serveur : un
POSTsigné sur votre URL de webhook, avec le message reçu. - Votre serveur vérifie la signature, accuse réception immédiatement (avant même de répondre à l'utilisateur — obligatoire, voir Webhooks), puis traite le message.
- Votre serveur interroge Gemini avec le message de l'utilisateur et l'historique récent de la conversation.
- Votre serveur renvoie la réponse à l'utilisateur via l'API GPChat (
POST /v1/messages).
Utilisateur → App GPChat → [webhook signé] → Votre serveur → [prompt] → Gemini
↓
Utilisateur ← App GPChat ← [POST /v1/messages] ← Votre serveur ← [réponse] ← Gemini
Votre serveur ne stocke rien de durable dans ce tutoriel : l'historique de conversation vit en mémoire (une Map), ce qui suffit largement pour un bot personnel ou une démo. La section Pour aller plus loin explique comment le rendre persistant.
1Créer votre bot
Pas de « bac à sable » séparé chez GPChat : l'app mobile distribuée sur les stores ne parle qu'à la production, il n'y a donc rien d'autre à quoi se connecter. On crée le bot directement en production, dès maintenant.
C'est totalement sans risque. Un bot fraîchement créé n'est trouvable par personne : il faut connaître son nom d'utilisateur exact pour lui écrire, et vous êtes le seul à le connaître tant que vous ne l'avez partagé avec personne. Un bot ne peut d'ailleurs répondre qu'à quelqu'un qui lui a déjà écrit en premier (voir règles anti-spam) — vous pouvez donc développer, casser, corriger et retester autant que vous voulez, en toute discrétion, avec votre propre compte GPChat habituel.
- Ouvrez la console développeur et connectez-vous (QR code depuis l'app GPChat, ou Google).
- Créez une application nommée par exemple
Anne, avec la case Créer le bot cochée. - Copiez immédiatement les deux valeurs affichées une seule fois :
- la clé API :
gpk_live_…(64 caractères hexadécimaux après le préfixe) ; - le secret de webhook : nécessaire pour vérifier que les requêtes reçues viennent bien de GPChat.
- la clé API :
- Donnez un nom d'utilisateur à votre bot dans l'onglet Bot de la console, par exemple
annebot(doit se terminer parbot, 5 à 32 caractères, minuscules/chiffres/_). Ne le partagez pas encore — on ne le rendra public qu'à l'étape 13.
Si vous perdez la clé API avant de l'avoir copiée, pas de panique : bouton Régénérer dans l'onglet Identifiants de la console (ou /token auprès de @gpchat_forge sur mobile). L'ancienne clé est immédiatement révoquée.
2Obtenir une clé Gemini gratuite
Gemini (Google) propose un niveau gratuit largement suffisant pour développer et tester un bot personnel.
- Rendez-vous sur aistudio.google.com/apikey et connectez-vous avec un compte Google.
- Cliquez sur Create API key, choisissez ou créez un projet Google Cloud, puis copiez la clé générée (commence par
AIza…). - Gardez-la de côté : elle rejoindra votre fichier
.envà l'étape suivante.
Le nom exact du modèle disponible peut évoluer avec le temps. Ce tutoriel utilise gemini-flash-latest (rapide, peu coûteux, largement suffisant pour un bot conversationnel) ; consultez Google AI Studio si vous voulez en changer.
3Initialiser le projet
Un dossier, cinq fichiers, quatre dépendances.
mkdir anne-bot && cd anne-bot
npm init -y
npm install express dotenv
npm install -D typescript tsx @types/express @types/node
Créez tsconfig.json :
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
Ouvrez package.json et ajoutez ces scripts (à côté de ceux générés par npm init) :
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
Créez l'arborescence :
mkdir src
touch src/gpchat.ts src/gemini.ts src/memory.ts src/index.ts .env .env.example .gitignore
.gitignore
node_modules/
dist/
.env
.env.example
# Copiez ce fichier en .env et remplissez les vraies valeurs.
# Ne committez JAMAIS le fichier .env (voir docs.html#security).
# --- GPChat (étape 1) ---
GPCHAT_API_KEY=gpk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GPCHAT_WEBHOOK_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GPCHAT_SEND_URL=https://api.gpchat.app/v1/messages
# --- Gemini (étape 2) ---
GEMINI_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GEMINI_MODEL=gemini-flash-latest
# --- Serveur ---
PORT=3000
Copiez ce fichier en .env et remplissez-le avec vos vraies valeurs (clé API et secret de webhook de l'étape 1, clé Gemini de l'étape 2). On ne le retouchera plus jusqu'au déploiement (étape 12).
cp .env.example .env
# puis éditez .env avec vos vraies valeurs
4Vérifier la signature des webhooks
Avant de traiter le moindre message, il faut s'assurer qu'il vient bien de GPChat et pas d'un tiers qui aurait deviné votre URL. GPChat signe chaque requête en HMAC-SHA256 (détails complets : Vérifier la signature) — voici l'implémentation, isolée dans son propre fichier pour rester réutilisable.
src/gpchat.tsimport crypto from 'node:crypto';
const { GPCHAT_API_KEY, GPCHAT_WEBHOOK_SECRET, GPCHAT_SEND_URL } = process.env;
if (!GPCHAT_API_KEY || !GPCHAT_WEBHOOK_SECRET || !GPCHAT_SEND_URL) {
throw new Error(
'Variables manquantes : GPCHAT_API_KEY, GPCHAT_WEBHOOK_SECRET, GPCHAT_SEND_URL (voir .env.example)',
);
}
/**
* Vérifie qu'une requête webhook vient bien de GPChat.
* `rawBody` DOIT être le corps brut de la requête (jamais un JSON déjà
* re-sérialisé : le moindre octet différent invalide la signature).
*/
export function verifyWebhookSignature(
rawBody: string,
signatureHeader: string | undefined,
toleranceSeconds = 300,
): boolean {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader || '');
if (!match) return false;
const [, timestamp, signature] = match;
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > toleranceSeconds) return false; // protection contre le rejeu
const expected = crypto
.createHmac('sha256', GPCHAT_WEBHOOK_SECRET!)
.update(`${timestamp}.${rawBody}`)
.digest();
return crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
/** Envoie un message texte à un utilisateur via l'API GPChat. */
export async function sendTextMessage(to: string, content: string): Promise<void> {
// 4096 caractères max côté GPChat — on coupe large pour ne jamais échouer.
const truncated = content.length > 4000 ? content.slice(0, 3997) + '…' : content;
const res = await fetch(GPCHAT_SEND_URL!, {
method: 'POST',
headers: {
Authorization: `Bearer ${GPCHAT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to, type: 'text', content: truncated }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Échec d'envoi GPChat (${res.status}) : ${body}`);
}
}
fetch est natif à partir de Node 18 : aucune dépendance HTTP supplémentaire n'est nécessaire.
5Le serveur Express
Le point le plus important de ce tutoriel : accuser réception en moins de 10 secondes, avant même d'avoir fini de traiter le message (voir Webhooks). Le corps de la requête doit rester brut (non parsé en JSON par Express) pour que la signature soit vérifiable.
src/index.ts (version 1 — squelette)import 'dotenv/config';
import express from 'express';
import { verifyWebhookSignature, sendTextMessage } from './gpchat';
const app = express();
const PORT = Number(process.env.PORT) || 3000;
app.get('/', (_req, res) => res.send('Anne est en ligne 👋'));
app.post(
'/webhook',
express.raw({ type: 'application/json' }), // corps BRUT, pas express.json()
async (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(rawBody, req.get('X-GPChat-Signature'))) {
return res.sendStatus(401);
}
// On accuse réception TOUT DE SUITE : le traitement (appel IA, envoi de
// la réponse) continue après, sans faire attendre GPChat.
res.sendStatus(200);
const event = JSON.parse(rawBody) as { type: string; data: Record<string, unknown> };
void handleEvent(event).catch((err) => console.error('Erreur de traitement :', err));
},
);
async function handleEvent(event: { type: string; data: Record<string, unknown> }) {
if (event.type !== 'message.received') return; // on ignore les groupes pour l'instant
const chatId = event.data.chatId as string;
const text = (event.data.textMsg as string) || '';
if (!text) return;
await sendTextMessage(chatId, `Vous avez écrit : « ${text} »`);
}
app.listen(PORT, () => console.log(`Anne écoute sur http://localhost:${PORT}`));
À ce stade, Anne répond déjà — en écho. C'est peu utile, mais ça valide toute la mécanique (signature, réception, envoi) avant d'ajouter la complexité de l'IA. On va maintenant remplacer l'écho par un vrai appel à Gemini.
6Appeler Gemini
Un appel REST simple, sans SDK : une requête, une réponse. On garde gemini.ts totalement indépendant de GPChat — il ne connaît que des messages, il pourrait servir n'importe quel autre projet.
const { GEMINI_API_KEY, GEMINI_MODEL = 'gemini-flash-latest' } = process.env;
if (!GEMINI_API_KEY) {
throw new Error('Variable manquante : GEMINI_API_KEY (voir .env.example)');
}
export type ChatTurn = { role: 'user' | 'model'; text: string };
const SYSTEM_PROMPT =
"Tu es Anne, une assistante amicale et concise sur GPChat. " +
"Tu réponds en français, en 2 à 4 phrases maximum, sans emojis excessifs. " +
"Si tu ne sais pas, dis-le simplement plutôt que d'inventer.";
/** Interroge Gemini avec l'historique de conversation et renvoie le texte de la réponse. */
export async function askGemini(history: ChatTurn[]): Promise<string> {
const url =
`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent` +
`?key=${GEMINI_API_KEY}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents: history.map((turn) => ({
role: turn.role,
parts: [{ text: turn.text }],
})),
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Échec d'appel Gemini (${res.status}) : ${body}`);
}
const json = await res.json();
const text = json?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error('Réponse Gemini vide ou inattendue');
return text.trim();
}
La clé Gemini, tout comme la clé API GPChat, ne doit exister que côté serveur (fichier .env, jamais dans un dépôt public ni dans du code exécuté dans un navigateur).
7Mémoire de conversation
Sans mémoire, chaque message serait traité isolément et Anne « oublierait » tout d'une réponse à l'autre. Une simple Map en mémoire, limitée aux derniers échanges, suffit largement pour un bot personnel — pas besoin de base de données pour démarrer.
import type { ChatTurn } from './gemini';
const MAX_TURNS_PER_USER = 10; // 5 échanges utilisateur/bot
const conversations = new Map<string, ChatTurn[]>();
export function getHistory(userId: string): ChatTurn[] {
return conversations.get(userId) ?? [];
}
export function pushTurn(userId: string, turn: ChatTurn): ChatTurn[] {
const history = [...getHistory(userId), turn].slice(-MAX_TURNS_PER_USER);
conversations.set(userId, history);
return history;
}
export function resetHistory(userId: string): void {
conversations.delete(userId);
}
Cette mémoire est perdue à chaque redémarrage du serveur (déploiement, crash, redémarrage programmé) — c'est un choix délibéré pour rester simple. La section Pour aller plus loin explique comment la rendre persistante si vous en avez besoin.
8Commandes /start et /reset, puis Gemini pour le reste
On assemble tout dans index.ts : deux commandes utilitaires, et Gemini pour tout le reste. On protège aussi les messages qui ne sont pas du texte (image, position…), qu'Anne ne sait pas encore lire.
import 'dotenv/config';
import express from 'express';
import { verifyWebhookSignature, sendTextMessage } from './gpchat';
import { askGemini } from './gemini';
import { getHistory, pushTurn, resetHistory } from './memory';
const app = express();
const PORT = Number(process.env.PORT) || 3000;
// Dédoublonnage : une même livraison de webhook peut être rejouée par
// GPChat (voir docs.html#webhook-events) — on ignore les msgId déjà vus.
const seenMessageIds = new Set<string>();
function alreadyProcessed(msgId: string): boolean {
if (seenMessageIds.has(msgId)) return true;
seenMessageIds.add(msgId);
if (seenMessageIds.size > 500) {
seenMessageIds.delete(seenMessageIds.values().next().value!);
}
return false;
}
app.get('/', (_req, res) => res.send('Anne est en ligne 👋'));
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(rawBody, req.get('X-GPChat-Signature'))) {
return res.sendStatus(401);
}
res.sendStatus(200);
const event = JSON.parse(rawBody) as { type: string; data: Record<string, unknown> };
void handleEvent(event).catch((err) => console.error('Erreur de traitement :', err));
},
);
async function handleEvent(event: { type: string; data: Record<string, unknown> }) {
if (event.type === 'ping') return; // « Envoyer un ping » depuis la console
if (event.type !== 'message.received') return; // groupes : voir « Pour aller plus loin »
const { msgId, chatId, type, textMsg } = event.data as {
msgId: string; chatId: string; type: string; textMsg?: string;
};
if (alreadyProcessed(msgId)) return;
if (type !== 'text') {
await sendTextMessage(chatId, "Je ne sais lire que le texte pour l'instant 🙂");
return;
}
const text = (textMsg || '').trim();
if (text === '/start') {
await sendTextMessage(chatId, "Bonjour, je suis Anne 👋 Posez-moi une question, je me souviens de notre conversation. Tapez /reset pour repartir de zéro.");
return;
}
if (text === '/reset') {
resetHistory(chatId);
await sendTextMessage(chatId, 'Mémoire effacée ✅ On repart de zéro.');
return;
}
if (!text) return;
try {
const history = pushTurn(chatId, { role: 'user', text });
const reply = await askGemini(history);
pushTurn(chatId, { role: 'model', text: reply });
await sendTextMessage(chatId, reply);
} catch (err) {
console.error('Erreur IA :', err);
await sendTextMessage(chatId, "Désolée, j'ai eu un souci pour répondre. Réessayez dans un instant 🙏");
}
}
app.listen(PORT, () => console.log(`Anne écoute sur http://localhost:${PORT}`));
C'est tout. Moins de 130 lignes réparties sur quatre fichiers, et Anne sait déjà : recevoir des messages en toute sécurité (signature vérifiée), tenir une conversation avec mémoire, gérer deux commandes, et ne jamais planter silencieusement sur une erreur IA.
9Lancer en local avec un tunnel HTTPS
GPChat doit pouvoir atteindre votre webhook depuis Internet, en HTTPS. En développement, un tunnel évite de déployer à chaque test.
npm run dev
# → Anne écoute sur http://localhost:3000
Dans un second terminal, avec ngrok (ou Cloudflare Tunnel, équivalent) :
ngrok http 3000
# → https://abcd-1-2-3-4.ngrok-free.app (copiez cette URL HTTPS)
Dans la console, onglet Webhooks de votre application, collez https://abcd-1-2-3-4.ngrok-free.app/webhook puis cliquez sur Envoyer un ping. Votre terminal doit afficher la requête reçue et répondre 200 immédiatement.
Une URL ngrok gratuite change à chaque redémarrage du tunnel : remettez-la à jour dans la console si vous relancez ngrok.
10Premiers tests
Deux façons de tester, à faire dans cet ordre :
A. Le simulateur de la console (sans écrire un vrai message)
Onglet Webhooks → Simuler un événement : choisissez message.received, un texte comme Quelle est la capitale du Cameroun ?, et envoyez. La console affiche la charge utile exacte envoyée, la signature calculée, et la réponse de votre serveur — pratique pour déboguer sans dépendre de l'app mobile.
B. Un vrai message, depuis votre propre compte GPChat
Pas besoin de compte spécial ni d'environnement particulier : ouvrez l'app GPChat que vous utilisez tous les jours (celle du store), avec votre compte habituel.
- Recherchez votre bot par son nom d'utilisateur (
annebot, étape 1) et envoyez/start. - Posez une vraie question. La réponse doit arriver en 2 à 5 secondes.
- Enchaînez avec une question qui dépend de la précédente (« et pourquoi ? ») pour vérifier que la mémoire fonctionne.
- Tapez
/reset, puis reposez la même question de suivi : Anne ne doit plus avoir le contexte.
Si tout ça fonctionne, votre bot est fonctionnellement complet — et déjà en production. Les étapes suivantes ne changent rien à son comportement : elles le rendent robuste, l'installent sur un vrai serveur, puis le préparent à recevoir d'autres utilisateurs que vous.
11Robustesse et cas limites
Le code des étapes précédentes couvre déjà les cas les plus importants. Voici ce qu'il gère et pourquoi, à titre de check-list :
| Cas | Géré par |
|---|---|
| Requête qui ne vient pas de GPChat | verifyWebhookSignature → 401, rien n'est traité. |
| Même message livré deux fois (webhook rejoué) | alreadyProcessed(msgId). |
| Gemini indisponible ou lent | try/catch autour de askGemini, message d'erreur convivial envoyé à l'utilisateur plutôt qu'un silence. |
| Message non textuel (image, position…) | Réponse explicite plutôt qu'un crash sur textMsg vide. |
| Réponse de Gemini trop longue | Troncature à 4000 caractères dans sendTextMessage (limite GPChat : 4096). |
| Utilisateur qui n'a jamais écrit au bot | Non applicable ici : on ne répond qu'à des messages déjà reçus, jamais en initiant une conversation (voir règles anti-spam). |
Deux points à connaître mais que ce tutoriel ne code pas, pour rester simple :
- Limite de requêtes (429) :
/v1/messagesest limité à 60 requêtes/minute par clé (voir Limites et quotas). Pour un bot personnel, vous ne l'atteindrez pas. S'il devient populaire, entourezsendTextMessaged'un repli exponentiel sur les réponses429. - Coupure du serveur : si votre process redémarre entre l'envoi du webhook et la fin du traitement, ce message précis n'aura pas de réponse (mais aucun autre n'est perdu). Un hébergement avec redémarrage rapide (étape 12) limite le risque.
12Déployer le serveur
Anne doit tourner quelque part en permanence, joignable en HTTPS. La façon la plus simple sans gérer de serveur vous-même :
- Poussez votre projet sur un dépôt Git (GitHub, GitLab…) — pensez à vérifier que
.envest bien ignoré (.gitignore, étape 3). - Créez un compte sur une plateforme d'hébergement simple (Render, Railway ou Fly.io conviennent tous — l'exemple ci-dessous utilise Render, dont le niveau gratuit suffit pour démarrer).
- Créez un « Web Service » pointant vers votre dépôt, avec :
- Build command :
npm install && npm run build - Start command :
npm start
- Build command :
- Renseignez les variables d'environnement de votre
.envdans l'interface de la plateforme (jamais dans le code poussé sur Git). - Déployez : vous obtenez une URL HTTPS stable, par exemple
https://anne-bot.onrender.com.
Quelle que soit la plateforme choisie, les seules exigences sont : HTTPS, une adresse publique stable, et la possibilité de définir des variables d'environnement hors du code. Un VPS avec un reverse-proxy (Caddy ou nginx + Let's Encrypt) fonctionne tout aussi bien si vous préférez tout maîtriser.
13Se préparer à un vrai lancement
Votre bot tourne déjà en production depuis l'étape 1 — il n'y a donc aucune clé à changer, aucune URL à basculer. Il ne vous reste qu'à mettre à jour une seule chose (l'URL de webhook, qui pointait sur votre tunnel local) et à vérifier que vous êtes prêt avant de le rendre public.
- Renseignez l'URL de webhook définitive (celle de votre déploiement de l'étape 12, par exemple
https://anne-bot.onrender.com/webhook) dans la console, à la place de l'URL ngrok. Cliquez sur Envoyer un ping pour confirmer. - Retestez une dernière fois avec votre propre compte (
/start, une question,/reset) pour valider que le nouveau serveur répond bien. - Vérifiez le quota : 60 requêtes/minute par défaut (voir Limites et quotas) — largement suffisant pour démarrer, à surveiller si le bot devient populaire.
- Renseignez un contact d'alerte (e-mail ou webhook Slack/Discord) dans l'onglet Paramètres de la console, pour être prévenu si votre serveur tombe en panne (voir Réessais et monitoring).
Votre bot est prêt à accueillir de vrais utilisateurs. Pour le rendre découvrable, partagez son nom d'utilisateur ou publiez-le dans le Store des bots (rappel : un utilisateur doit toujours écrire en premier à votre bot — voir règles anti-spam).
Code complet
Les quatre fichiers de src/, tels qu'ils doivent se présenter à la fin du tutoriel :
import crypto from 'node:crypto';
const { GPCHAT_API_KEY, GPCHAT_WEBHOOK_SECRET, GPCHAT_SEND_URL } = process.env;
if (!GPCHAT_API_KEY || !GPCHAT_WEBHOOK_SECRET || !GPCHAT_SEND_URL) {
throw new Error(
'Variables manquantes : GPCHAT_API_KEY, GPCHAT_WEBHOOK_SECRET, GPCHAT_SEND_URL (voir .env.example)',
);
}
export function verifyWebhookSignature(
rawBody: string,
signatureHeader: string | undefined,
toleranceSeconds = 300,
): boolean {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader || '');
if (!match) return false;
const [, timestamp, signature] = match;
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', GPCHAT_WEBHOOK_SECRET!)
.update(`${timestamp}.${rawBody}`)
.digest();
return crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
export async function sendTextMessage(to: string, content: string): Promise<void> {
const truncated = content.length > 4000 ? content.slice(0, 3997) + '…' : content;
const res = await fetch(GPCHAT_SEND_URL!, {
method: 'POST',
headers: {
Authorization: `Bearer ${GPCHAT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to, type: 'text', content: truncated }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Échec d'envoi GPChat (${res.status}) : ${body}`);
}
}
const { GEMINI_API_KEY, GEMINI_MODEL = 'gemini-flash-latest' } = process.env;
if (!GEMINI_API_KEY) {
throw new Error('Variable manquante : GEMINI_API_KEY (voir .env.example)');
}
export type ChatTurn = { role: 'user' | 'model'; text: string };
const SYSTEM_PROMPT =
"Tu es Anne, une assistante amicale et concise sur GPChat. " +
"Tu réponds en français, en 2 à 4 phrases maximum, sans emojis excessifs. " +
"Si tu ne sais pas, dis-le simplement plutôt que d'inventer.";
export async function askGemini(history: ChatTurn[]): Promise<string> {
const url =
`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent` +
`?key=${GEMINI_API_KEY}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents: history.map((turn) => ({
role: turn.role,
parts: [{ text: turn.text }],
})),
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Échec d'appel Gemini (${res.status}) : ${body}`);
}
const json = await res.json();
const text = json?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error('Réponse Gemini vide ou inattendue');
return text.trim();
}
import type { ChatTurn } from './gemini';
const MAX_TURNS_PER_USER = 10;
const conversations = new Map<string, ChatTurn[]>();
export function getHistory(userId: string): ChatTurn[] {
return conversations.get(userId) ?? [];
}
export function pushTurn(userId: string, turn: ChatTurn): ChatTurn[] {
const history = [...getHistory(userId), turn].slice(-MAX_TURNS_PER_USER);
conversations.set(userId, history);
return history;
}
export function resetHistory(userId: string): void {
conversations.delete(userId);
}
import 'dotenv/config';
import express from 'express';
import { verifyWebhookSignature, sendTextMessage } from './gpchat';
import { askGemini } from './gemini';
import { getHistory, pushTurn, resetHistory } from './memory';
const app = express();
const PORT = Number(process.env.PORT) || 3000;
const seenMessageIds = new Set<string>();
function alreadyProcessed(msgId: string): boolean {
if (seenMessageIds.has(msgId)) return true;
seenMessageIds.add(msgId);
if (seenMessageIds.size > 500) {
seenMessageIds.delete(seenMessageIds.values().next().value!);
}
return false;
}
app.get('/', (_req, res) => res.send('Anne est en ligne 👋'));
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(rawBody, req.get('X-GPChat-Signature'))) {
return res.sendStatus(401);
}
res.sendStatus(200);
const event = JSON.parse(rawBody) as { type: string; data: Record<string, unknown> };
void handleEvent(event).catch((err) => console.error('Erreur de traitement :', err));
},
);
async function handleEvent(event: { type: string; data: Record<string, unknown> }) {
if (event.type === 'ping') return;
if (event.type !== 'message.received') return;
const { msgId, chatId, type, textMsg } = event.data as {
msgId: string; chatId: string; type: string; textMsg?: string;
};
if (alreadyProcessed(msgId)) return;
if (type !== 'text') {
await sendTextMessage(chatId, "Je ne sais lire que le texte pour l'instant 🙂");
return;
}
const text = (textMsg || '').trim();
if (text === '/start') {
await sendTextMessage(chatId, "Bonjour, je suis Anne 👋 Posez-moi une question, je me souviens de notre conversation. Tapez /reset pour repartir de zéro.");
return;
}
if (text === '/reset') {
resetHistory(chatId);
await sendTextMessage(chatId, 'Mémoire effacée ✅ On repart de zéro.');
return;
}
if (!text) return;
try {
const history = pushTurn(chatId, { role: 'user', text });
const reply = await askGemini(history);
pushTurn(chatId, { role: 'model', text: reply });
await sendTextMessage(chatId, reply);
} catch (err) {
console.error('Erreur IA :', err);
await sendTextMessage(chatId, "Désolée, j'ai eu un souci pour répondre. Réessayez dans un instant 🙏");
}
}
app.listen(PORT, () => console.log(`Anne écoute sur http://localhost:${PORT}`));
Pour aller plus loin
| Envie de… | Piste |
|---|---|
| Répondre aussi dans les groupes | Gérez l'événement group_message.received (voir Événements et charge utile) ; ne répondez que si isMentioned est vrai, sous peine de spammer le groupe. |
| Garder la mémoire après un redémarrage | Remplacez la Map de memory.ts par une table dans Redis, SQLite ou Postgres — l'interface (getHistory/pushTurn/resetHistory) reste identique, seul le fichier change. |
Ajouter des commandes visibles dans le menu / | Console → onglet Bot → Commandes (ou /setcommands auprès de @gpchat_forge). |
| Répondre avec des images, de la position, etc. | Tous les types de messages — même endpoint, juste d'autres champs. |
| Être alerté si le webhook tombe en panne | Renseignez un e-mail ou un webhook d'alerte dans l'onglet Paramètres de la console (voir Réessais et monitoring). |
| Laisser des utilisateurs se connecter avec leur compte GPChat | C'est un besoin différent (agir au nom d'un utilisateur, pas envoyer des messages) : voir Login with GPChat (OAuth2). |
Checklist finale
- ☑ Bot créé en production, testé de bout en bout (simulateur et vrai message avec votre propre compte).
- ☑
.envjamais committé (.gitignorevérifié). - ☑ Signature de webhook vérifiée sur le corps brut, avant tout traitement.
- ☑ Réponse
200envoyée en moins de 10 s, traitement après coup. - ☑ Erreurs Gemini interceptées, message convivial envoyé plutôt qu'un silence.
- ☑ Serveur déployé sur une vraie adresse HTTPS (plus de tunnel ngrok).
- ☑ URL de webhook définitive renseignée dans la console et testée (« Envoyer un ping »).
- ☑ Contact d'alerte configuré avant de partager le nom d'utilisateur du bot.
Bravo — vous avez un bot IA complet, testé, et en production. La suite est une question de goût : commandes supplémentaires, mémoire persistante, support des groupes. Tout ce dont vous avez besoin est dans la documentation complète.