Author SHA1 Message Date
cedric b01132f104 feat: ajouter 14 langues africaines (dont le yoruba, hors DeepL)
Déploiement API BETA / build (push) Successful in 2m26s
Déploiement API BETA / deploy (push) Successful in 47s
Vérification PR / build (pull_request) Successful in 2m21s
Vérification PR / deploy-beta (pull_request) Successful in 43s
2026-07-06 00:05:33 +04:00
cedric 821b9b96aa feat: mettre en avant les langues asiatiques dans l'ordre des traductions
Déploiement API BETA / build (push) Successful in 2m22s
Déploiement API PROD / build (push) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 48s
Déploiement API PROD / deploy (push) Successful in 55s
2026-07-05 22:28:27 +04:00
cedric 842e87543c revert: garder les mentions dans les commentaires importés
Déploiement API BETA / build (push) Successful in 2m22s
Déploiement API PROD / deploy (push) Successful in 54s
Déploiement API PROD / build (push) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 44s
2026-07-05 20:10:51 +04:00
cedric ecbcf5d86e fix: retirer la mention initiale des commentaires importés
Déploiement API BETA / build (push) Successful in 2m16s
Déploiement API PROD / build (push) Successful in 2m24s
Déploiement API BETA / deploy (push) Successful in 44s
Déploiement API PROD / deploy (push) Successful in 53s
2026-07-05 19:39:33 +04:00
cedric 128c027af1 fix: extraire l'id de la relation avant de la déréférencer
Déploiement API BETA / build (push) Successful in 2m27s
Déploiement API PROD / build (push) Successful in 2m21s
Déploiement API BETA / deploy (push) Successful in 47s
Déploiement API PROD / deploy (push) Successful in 53s
2026-07-05 19:22:29 +04:00
cedric 29bdf72640 fix: réparer la relation commentaire.parole / parole.commentaires
Déploiement API BETA / build (push) Successful in 2m25s
Déploiement API PROD / build (push) Successful in 2m22s
Déploiement API BETA / deploy (push) Successful in 46s
Déploiement API PROD / deploy (push) Successful in 53s
2026-07-05 19:04:39 +04:00
cedric fb60226bd4 fix: publier les commentaires bokante importés
Déploiement API BETA / build (push) Successful in 2m22s
Déploiement API PROD / build (push) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 47s
Déploiement API PROD / deploy (push) Successful in 52s
2026-07-05 18:39:01 +04:00
cedric 34f3a7783b docs: documenter les commentaires fédérés (bokante)
Déploiement API BETA / build (push) Successful in 2m20s
Déploiement API PROD / build (push) Successful in 2m21s
Déploiement API BETA / deploy (push) Successful in 44s
Déploiement API PROD / deploy (push) Successful in 56s
2026-07-05 02:06:23 +04:00
cedric 03960cc952 Merge pull request 'Commentaires fédérés via Mastodon (bokante.o-k-i.net) — backend' (#5) from feat/comment-fedi into master
Déploiement API BETA / build (push) Successful in 2m20s
Déploiement API PROD / build (push) Successful in 2m24s
Déploiement API BETA / deploy (push) Successful in 44s
Déploiement API PROD / deploy (push) Successful in 53s
Reviewed-on: #5
2026-07-04 21:24:31 +00:00
cedric 719b4c7905 chore: régénérer les types Strapi (champs bokante)
Déploiement API BETA / build (push) Successful in 2m20s
Vérification PR / build (pull_request) Successful in 2m23s
Déploiement API BETA / deploy (push) Successful in 46s
Vérification PR / deploy-beta (pull_request) Successful in 47s
2026-07-05 01:18:44 +04:00
cedric e416f94061 feat: backfiller le catalogue existant, une parole/heure 2026-07-05 01:07:31 +04:00
cedric 26471d8b0e fix: déplacer le miroir bokante de beforeUpdate vers afterCreate
Avec draftAndPublish sur Strapi 5, publier une entrée crée une
nouvelle ligne (document-service publishEntry -> createEntry) au lieu
de mettre à jour la ligne existante : beforeUpdate ne se déclenche
donc jamais sur une vraie action Publier, seulement sur l'édition
d'une entrée déjà publiée. Le miroir bokante est maintenant posté
dans afterCreate, sur la condition result.publishedAt, avec
synchronisation brouillon/publié pour rester idempotent à travers les
cycles dépublier/republier.
2026-07-05 00:59:59 +04:00
cedric 861e75fde2 feat: planifier l'import des commentaires bokante (20 min) 2026-07-04 23:39:25 +04:00
cedric 6c828a2394 feat: importer les réponses bokante en commentaires 2026-07-04 23:38:41 +04:00
cedric 2a4cea0854 feat: publier un statut miroir bokante à la publication 2026-07-04 23:37:15 +04:00
cedric 648e51fe3c feat: client HTTP minimal pour l'API Mastodon de bokante 2026-07-04 23:33:57 +04:00
cedric 0043a07ec1 feat: schéma pour les commentaires fédérés 2026-07-04 23:31:09 +04:00
cedric 7e705d3976 Merge pull request 'Audit sécurité/qualité : corrections critiques, tests, CI et lint' (#4) from fix/audit-2026-07-04 into master
Déploiement API BETA / deploy (push) Successful in 45s
Déploiement API PROD / deploy (push) Successful in 1m25s
Déploiement API BETA / build (push) Successful in 2m19s
Déploiement API PROD / build (push) Successful in 2m27s
Reviewed-on: #4
2026-07-04 17:01:30 +00:00
19 changed files with 815 additions and 17 deletions
+31
View File
@@ -163,6 +163,37 @@ curl -H "Authorization: Bearer <token>" \
-o dataset.jsonl
```
## Commentaires fédérés (Mastodon / bokante.o-k-i.net)
Chaque parole publiée peut obtenir un statut miroir sur l'instance Mastodon de
l'organisation, [bokante.o-k-i.net](https://bokante.o-k-i.net), et les réponses reçues
sur ce statut sont importées comme commentaires. Voir le RFC dédié pour le détail de la
conception (`RFC-commentaires-activitypub-2026-07-04.md`, à la racine du dossier
`PAWOL.NU`).
**Variables d'environnement :**
| Variable | Obligatoire | Description |
|---|---|---|
| `BOKANTE_ACCESS_TOKEN` | Oui, pour activer la fonctionnalité | Jeton d'application Mastodon (scopes `write:statuses`, `read:statuses`) d'un compte bot dédié sur bokante.o-k-i.net |
| `BOKANTE_INSTANCE_URL` | Non | Instance Mastodon cible (défaut : `https://bokante.o-k-i.net`) |
| `BOKANTE_BACKFILL_CRON` | Non | Règle cron du backfill du catalogue existant (défaut : `0 * * * *`, une parole/heure) |
Sans `BOKANTE_ACCESS_TOKEN`, toute la fonctionnalité (publication du miroir, import des
réponses, backfill) est un no-op silencieux — comme les intégrations Telegram/Revolt.
**Comment obtenir le jeton :** créer un compte bot dédié sur bokante.o-k-i.net, puis dans
*Préférences → Développement → Nouvelle application*, cocher uniquement `write:statuses`
et `read:statuses`. Le jeton d'accès est affiché directement sur la page de l'application
créée.
**Tâches planifiées (cron) :**
- Publication du miroir : déclenchée à chaque publication d'une parole (pas de cron).
- Import des réponses : toutes les 20 minutes.
- Backfill du catalogue existant : une parole par exécution (la plus ancienne sans
miroir en premier), au rythme défini par `BOKANTE_BACKFILL_CRON`. S'arrête de
lui-même une fois le catalogue rattrapé.
## License
Copyright (C) 2024 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
+36
View File
@@ -1,5 +1,7 @@
const path = require('path');
const {backupDatabase} = require('../src/utils/backup-database');
const {importBokanteComments} = require('../src/utils/import-bokante-comments');
const {backfillBokanteMirror} = require('../src/utils/backfill-bokante-mirrors');
module.exports = {
myJob: {
@@ -19,4 +21,38 @@ module.exports = {
tz: 'Indian/Reunion',
},
},
importBokanteComments: {
task: async ({ strapi }) => {
if (!process.env.BOKANTE_ACCESS_TOKEN) {
return;
}
const {imported} = await importBokanteComments({strapi});
if (imported > 0) {
strapi.log.info(`Import bokante : ${imported} commentaire(s) importé(s).`);
}
},
options: {
rule: '*/20 * * * *',
tz: 'Indian/Reunion',
},
},
backfillBokanteMirror: {
task: async ({ strapi }) => {
if (!process.env.BOKANTE_ACCESS_TOKEN) {
return;
}
const {backfilled, slug} = await backfillBokanteMirror({strapi});
if (backfilled) {
strapi.log.info(`Backfill bokante : parole "${slug}" publiée.`);
}
},
options: {
rule: process.env.BOKANTE_BACKFILL_CRON || '0 * * * *',
tz: 'Indian/Reunion',
},
},
};
@@ -27,11 +27,33 @@ describe('commentaire afterCreate — notification email', () => {
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate({params: {data: {user: 1, parole: 7, contenu: '<img src=x onerror=alert(1)>'}}});
await afterCreate({params: {data: {user: {connect: [{id: 1}]}, parole: {connect: [{id: 7}]}, contenu: '<img src=x onerror=alert(1)>'}}});
expect(emailSend).toHaveBeenCalledTimes(1);
const [payload] = emailSend.mock.calls[0];
expect(payload.text).toBe('<img src=x onerror=alert(1)>');
expect(payload.html).toBeUndefined();
});
it('extrait l\'id de la relation quelle que soit sa forme (connect, set, id brut)', async () => {
const paroleFindOne = vi.fn(async ({where}) => ({id: where.id, titre: 'Mon titre'}));
const strapiMock = {
db: {
query: vi.fn(uid => {
if (uid === 'plugin::users-permissions.user') return {findOne: vi.fn(async () => null)};
if (uid === 'api::parole.parole') return {findOne: paroleFindOne};
throw new Error(`unexpected uid: ${uid}`);
})
},
plugins: {email: {services: {email: {send: vi.fn()}}}}
};
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate({params: {data: {parole: {set: [{id: 7}]}, contenu: 'ok'}}});
expect(paroleFindOne).toHaveBeenCalledWith({where: {id: 7}});
await afterCreate({params: {data: {parole: 7, contenu: 'ok'}}});
expect(paroleFindOne).toHaveBeenCalledWith({where: {id: 7}});
});
});
@@ -2,6 +2,17 @@
const { ApplicationError, NotFoundError } = require('@strapi/utils').errors;
// Le Document Service transforme les relations en { connect: [{id}] } ou
// { set: [{id}] } avant que les hooks bas niveau (beforeCreate/afterCreate)
// ne reçoivent `data` : un id brut n'est plus systématiquement garanti ici.
const idRelasyonAn = valè => {
if (valè == null || typeof valè !== 'object') {
return valè;
}
return valè.connect?.[0]?.id ?? valè.set?.[0]?.id ?? null;
};
const jwennUserEpiId = async userId => {
if (!userId) {
return null;
@@ -47,8 +58,8 @@ module.exports = {
},
afterCreate: async event => {
const {data} = event.params;
const user = await jwennUserEpiId(data.user);
const parole = await jwennParoleEpiId(data.parole);
const user = await jwennUserEpiId(idRelasyonAn(data.user));
const parole = await jwennParoleEpiId(idRelasyonAn(data.parole));
if (!parole) {
throw new NotFoundError('Texte introuvable.');
@@ -27,8 +27,34 @@
},
"parole": {
"type": "relation",
"relation": "oneToOne",
"target": "api::parole.parole"
"relation": "manyToOne",
"target": "api::parole.parole",
"inversedBy": "commentaires"
},
"origine": {
"type": "enumeration",
"enum": ["local", "activitypub"],
"default": "local",
"required": true
},
"auteurNom": {
"type": "string"
},
"auteurHandle": {
"type": "string"
},
"auteurAvatarUrl": {
"type": "string"
},
"auteurProfilUrl": {
"type": "string"
},
"remoteId": {
"type": "string",
"unique": true
},
"remoteUrl": {
"type": "string"
}
}
}
@@ -118,6 +118,113 @@ describe('beforeUpdate — notifications Telegram/Revolt', () => {
});
});
describe('afterCreate — publication miroir bokante', () => {
const originalEnv = {...process.env};
const bokanteMastodon = require('../../../../../utils/bokante-mastodon');
const originalCreateStatus = bokanteMastodon.createStatus;
function buildStrapi() {
const dbQuery = {
findOne: vi.fn(async () => null),
updateMany: vi.fn()
};
return {
db: {query: vi.fn(() => dbQuery)},
plugins: {email: {services: {email: {send: vi.fn()}}}},
log: {error: vi.fn()}
};
}
function buildEvent(resultOverrides = {}) {
return {
params: {data: {titre: 'Mon titre'}},
result: {
documentId: 'doc-1',
titre: 'Mon titre',
slug: 'mon-titre',
publishedAt: '2026-07-04T00:00:00.000Z',
bokanteStatusId: null,
...resultOverrides
}
};
}
afterEach(() => {
process.env = {...originalEnv};
bokanteMastodon.createStatus = originalCreateStatus;
delete global.strapi;
});
it('publie un statut miroir et synchronise bokanteStatusId sur le documentId', async () => {
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'}));
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate(buildEvent());
expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1);
expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Mon titre');
expect(strapiMock.db.query('api::parole.parole').updateMany).toHaveBeenCalledWith({
where: {documentId: 'doc-1'},
data: {bokanteStatusId: '112233'}
});
});
it('ne publie rien si la ligne créée n\'est pas publiée (simple brouillon)', async () => {
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate(buildEvent({publishedAt: null}));
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
});
it('ne republie pas si bokanteStatusId existe déjà', async () => {
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate(buildEvent({bokanteStatusId: '112233'}));
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
});
it('ne publie rien si BOKANTE_ACCESS_TOKEN n\'est pas configuré', async () => {
delete process.env.BOKANTE_ACCESS_TOKEN;
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate(buildEvent());
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
});
it('n\'interrompt pas la création quand bokante échoue', async () => {
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
bokanteMastodon.createStatus = vi.fn(async () => {
throw new Error('boom');
});
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await expect(afterCreate(buildEvent())).resolves.not.toThrow();
expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('bokante'));
expect(strapiMock.db.query('api::parole.parole').updateMany).not.toHaveBeenCalled();
});
});
describe('beforeUpdate — createdBy/updatedBy', () => {
afterEach(() => {
delete global.strapi;
@@ -2,6 +2,7 @@
const slugify = require('slugify');
const axios = require('axios');
const bokanteMastodon = require('../../../../utils/bokante-mastodon');
const utils = require('@strapi/utils');
const { ApplicationError } = utils.errors;
@@ -272,6 +273,27 @@ module.exports = {
},
afterCreate: async event => {
const {data} = event.params;
// Avec draftAndPublish, "publier" crée une nouvelle ligne (la version publiée)
// au lieu de mettre à jour la ligne existante : c'est ici, et non dans
// beforeUpdate, qu'un événement de publication est détectable.
if (event.result?.publishedAt && !event.result?.bokanteStatusId && process.env.BOKANTE_ACCESS_TOKEN) {
try {
const status = await bokanteMastodon.createStatus(
`"${event.result.titre}" — nouvelle parole sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${event.result.slug}`
);
// Synchronise brouillon et version publiée pour éviter une republication
// en double au prochain cycle dépublier/republier (qui recrée la ligne
// publiée à partir du brouillon).
await strapi.db.query('api::parole.parole').updateMany({
where: {documentId: event.result.documentId},
data: {bokanteStatusId: status.id}
});
} catch (err) {
strapi.log.error(`Publication bokante : ${err.message}`);
}
}
const user = await jwennUserEpiId(data?.user?.id);
const userAdmin = await jwennUserAdminEpiId(data?.createdBy);
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
@@ -82,7 +82,8 @@
"commentaires": {
"type": "relation",
"relation": "oneToMany",
"target": "api::commentaire.commentaire"
"target": "api::commentaire.commentaire",
"mappedBy": "parole"
},
"prioriteArtistes": {
"type": "string"
@@ -158,6 +159,9 @@
"type": "component",
"component": "kit.lyen",
"repeatable": true
},
"bokanteStatusId": {
"type": "string"
}
}
}
@@ -57,4 +57,28 @@ describe('translateLyrics', () => {
expect(result.anglais).toContain('traduit-EN');
expect(result.espagnol).toBeUndefined();
});
it('traduit les langues africaines ajoutées, sans appeler DeepL pour le yoruba', async () => {
global.fetch = vi.fn(async (_url, options) => {
const {target_lang: target} = JSON.parse(options.body);
return fakeDeeplResponse(`traduit-${target}`);
});
const strapi = {
contentType: vi.fn(() => ({uid: 'api::parole.parole', kind: 'collectionType'})),
log: {error: vi.fn()}
};
const service = createService({strapi});
const result = await service.translateLyrics('Bonjour le monde');
expect(result.swahili).toContain('traduit-SW');
expect(result.lingala).toContain('traduit-LN');
expect(result.wolof).toContain('traduit-WO');
expect(result.hausa).toContain('traduit-HA');
expect(result.yoruba).toBeUndefined();
const calledTargets = global.fetch.mock.calls.map(([, options]) => JSON.parse(options.body).target_lang);
expect(calledTargets).not.toContain('YO');
expect(calledTargets).not.toContain(undefined);
});
});
+21 -4
View File
@@ -6,16 +6,33 @@ const { ApplicationError } = require('@strapi/utils').errors;
const LANG_MAP = {
fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' },
// Pas de code DeepL pour le yoruba : traduction manuelle uniquement.
yo: { field: 'yoruba', targetLang: 'yo', userPrompt: 'Translate to Yoruba' },
ln: { field: 'lingala', targetLang: 'ln', userPrompt: 'Translate to Lingala', deeplTarget: 'LN', suffix: '\n\n (DeepL)' },
wo: { field: 'wolof', targetLang: 'wo', userPrompt: 'Translate to Wolof', deeplTarget: 'WO', suffix: '\n\n (DeepL)' },
sw: { field: 'swahili', targetLang: 'sw', userPrompt: 'Translate to Swahili', deeplTarget: 'SW', suffix: '\n\n (DeepL)' },
ha: { field: 'hausa', targetLang: 'ha', userPrompt: 'Translate to Hausa', deeplTarget: 'HA', suffix: '\n\n (DeepL)' },
ar: { field: 'arabe', targetLang: 'ar', userPrompt: 'Translate to Arabic', deeplTarget: 'AR', suffix: '\n\n (DeepL)' },
om: { field: 'oromo', targetLang: 'om', userPrompt: 'Translate to Oromo', deeplTarget: 'OM', suffix: '\n\n (DeepL)' },
ig: { field: 'igbo', targetLang: 'ig', userPrompt: 'Translate to Igbo', deeplTarget: 'IG', suffix: '\n\n (DeepL)' },
zu: { field: 'zoulou', targetLang: 'zu', userPrompt: 'Translate to Zulu', deeplTarget: 'ZU', suffix: '\n\n (DeepL)' },
mg: { field: 'malgache', targetLang: 'mg', userPrompt: 'Translate to Malagasy', deeplTarget: 'MG', suffix: '\n\n (DeepL)' },
xh: { field: 'xhosa', targetLang: 'xh', userPrompt: 'Translate to Xhosa', deeplTarget: 'XH', suffix: '\n\n (DeepL)' },
tn: { field: 'tswana', targetLang: 'tn', userPrompt: 'Translate to Tswana', deeplTarget: 'TN', suffix: '\n\n (DeepL)' },
ts: { field: 'tsonga', targetLang: 'ts', userPrompt: 'Translate to Tsonga', deeplTarget: 'TS', suffix: '\n\n (DeepL)' },
st: { field: 'sesotho', targetLang: 'st', userPrompt: 'Translate to Sesotho', deeplTarget: 'ST', suffix: '\n\n (DeepL)' },
ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' },
ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' },
en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English', deeplTarget: 'EN', suffix: '\n\n (Translated by DeepL)' },
es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' },
de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch', deeplTarget: 'DE', suffix: '\n\n (Übersetzt von DeepL)' },
it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano', deeplTarget: 'IT', suffix: '\n\n (Tradotto da DeepL)' },
pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' },
ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' },
ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' },
};
const ALL_LANGS = Object.keys(LANG_MAP);
// Langues suivies (schéma + export) mais sans traduction automatique DeepL.
const NO_AUTO_TRANSLATE = new Set(['fr', 'yo']);
function stripMarkdown(text) {
if (!text) return '';
@@ -88,7 +105,7 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
const result = { francais: parolesFR };
for (const lang of ALL_LANGS) {
if (lang === 'fr') continue;
if (NO_AUTO_TRANSLATE.has(lang)) continue;
const { field, deeplTarget, suffix } = LANG_MAP[lang];
try {
const translated = await this.translate('FR', deeplTarget, parolesFR);
@@ -203,7 +220,7 @@ module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
async bulkTranslateMissing() {
const TARGET_LANGS = ALL_LANGS
.filter(lang => lang !== 'fr')
.filter(lang => !NO_AUTO_TRANSLATE.has(lang))
.map(lang => ({ lang, ...LANG_MAP[lang] }));
const pageSize = 100;
+48 -6
View File
@@ -10,6 +10,54 @@
"francais": {
"type": "richtext"
},
"yoruba": {
"type": "richtext"
},
"lingala": {
"type": "richtext"
},
"wolof": {
"type": "richtext"
},
"swahili": {
"type": "richtext"
},
"hausa": {
"type": "richtext"
},
"arabe": {
"type": "richtext"
},
"oromo": {
"type": "richtext"
},
"igbo": {
"type": "richtext"
},
"zoulou": {
"type": "richtext"
},
"malgache": {
"type": "richtext"
},
"xhosa": {
"type": "richtext"
},
"tswana": {
"type": "richtext"
},
"tsonga": {
"type": "richtext"
},
"sesotho": {
"type": "richtext"
},
"japonais": {
"type": "richtext"
},
"coreen": {
"type": "richtext"
},
"anglais": {
"type": "richtext"
},
@@ -24,12 +72,6 @@
},
"portugais": {
"type": "richtext"
},
"japonais": {
"type": "richtext"
},
"coreen": {
"type": "richtext"
}
}
}
@@ -0,0 +1,75 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
import {backfillBokanteMirror} from '../backfill-bokante-mirrors.js';
const bokanteMastodon = require('../bokante-mastodon.js');
function buildStrapi({paroles = []} = {}) {
const updateMany = vi.fn();
return {
db: {
query: () => ({
findMany: vi.fn(async ({where, orderBy, limit}) => {
expect(where).toEqual({
publishedAt: {$notNull: true},
bokanteStatusId: {$null: true}
});
expect(orderBy).toEqual({publishedAt: 'asc'});
expect(limit).toBe(1);
return paroles.slice(0, limit);
}),
updateMany
})
},
log: {error: vi.fn(), info: vi.fn()}
};
}
describe('backfillBokanteMirror', () => {
const originalCreateStatus = bokanteMastodon.createStatus;
afterEach(() => {
bokanteMastodon.createStatus = originalCreateStatus;
});
it('publie le miroir pour la parole publiée la plus ancienne sans bokanteStatusId', async () => {
bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'}));
const parole = {documentId: 'doc-1', titre: 'Vieux titre', slug: 'vieux-titre'};
const strapi = buildStrapi({paroles: [parole]});
const result = await backfillBokanteMirror({strapi});
expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1);
expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Vieux titre');
expect(bokanteMastodon.createStatus.mock.calls[0][0]).not.toContain('nouvelle parole');
expect(strapi.db.query().updateMany).toHaveBeenCalledWith({
where: {documentId: 'doc-1'},
data: {bokanteStatusId: '112233'}
});
expect(result).toEqual({backfilled: true, slug: 'vieux-titre'});
});
it('ne fait rien si le catalogue est déjà rattrapé', async () => {
bokanteMastodon.createStatus = vi.fn();
const strapi = buildStrapi({paroles: []});
const result = await backfillBokanteMirror({strapi});
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
expect(result).toEqual({backfilled: false});
});
it('n\'interrompt rien si bokante échoue, et ne marque pas la parole comme traitée', async () => {
bokanteMastodon.createStatus = vi.fn(async () => {
throw new Error('boom');
});
const parole = {documentId: 'doc-1', titre: 'Titre', slug: 'titre'};
const strapi = buildStrapi({paroles: [parole]});
const result = await backfillBokanteMirror({strapi});
expect(strapi.log.error).toHaveBeenCalledWith(expect.stringContaining('titre'));
expect(strapi.db.query().updateMany).not.toHaveBeenCalled();
expect(result).toEqual({backfilled: false});
});
});
@@ -0,0 +1,73 @@
import {describe, it, expect, vi, afterEach, beforeEach} from 'vitest';
import {createStatus, getStatusContext} from '../bokante-mastodon.js';
describe('bokante-mastodon', () => {
const originalFetch = global.fetch;
const originalEnv = {...process.env};
beforeEach(() => {
process.env.BOKANTE_INSTANCE_URL = 'https://bokante.o-k-i.net';
process.env.BOKANTE_ACCESS_TOKEN = 'test-token';
});
afterEach(() => {
global.fetch = originalFetch;
process.env = {...originalEnv};
vi.restoreAllMocks();
});
describe('createStatus', () => {
it('poste le statut avec le bon endpoint, jeton et corps', async () => {
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({id: '112233', url: 'https://bokante.o-k-i.net/@paroles/112233'})
}));
const result = await createStatus('Nouvelle parole : "Titre"');
const [url, options] = global.fetch.mock.calls[0];
expect(url).toBe('https://bokante.o-k-i.net/api/v1/statuses');
expect(options.method).toBe('POST');
expect(options.headers.Authorization).toBe('Bearer test-token');
expect(JSON.parse(options.body)).toEqual({
status: 'Nouvelle parole : "Titre"',
visibility: 'public'
});
expect(result).toEqual({id: '112233', url: 'https://bokante.o-k-i.net/@paroles/112233'});
});
it('lève une erreur claire sur réponse HTTP non-ok', async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 422,
text: async () => 'Validation Failed'
}));
await expect(createStatus('texte')).rejects.toThrow('Mastodon 422: Validation Failed');
});
});
describe('getStatusContext', () => {
it('récupère le contexte du statut avec le bon endpoint et jeton', async () => {
const context = {ancestors: [], descendants: [{id: '1', content: '<p>coucou</p>'}]};
global.fetch = vi.fn(async () => ({ok: true, json: async () => context}));
const result = await getStatusContext('112233');
const [url, options] = global.fetch.mock.calls[0];
expect(url).toBe('https://bokante.o-k-i.net/api/v1/statuses/112233/context');
expect(options.headers.Authorization).toBe('Bearer test-token');
expect(result).toEqual(context);
});
it('lève une erreur claire sur réponse HTTP non-ok', async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 404,
text: async () => 'Record not found'
}));
await expect(getStatusContext('inconnu')).rejects.toThrow('Mastodon 404: Record not found');
});
});
});
@@ -0,0 +1,133 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
import {importBokanteComments} from '../import-bokante-comments.js';
const bokanteMastodon = require('../bokante-mastodon.js');
function buildStatus(overrides = {}) {
return {
id: 'status-1',
content: '<p>Trè bèl parol !</p>',
created_at: '2026-07-04T12:00:00.000Z',
url: 'https://bokante.o-k-i.net/@quelqun/status-1',
account: {
display_name: 'Quelqu\'un',
acct: 'quelqun@mastodon.social',
avatar: 'https://mastodon.social/avatars/quelqun.png',
url: 'https://mastodon.social/@quelqun'
},
...overrides
};
}
function buildStrapi({paroles, existingRemoteIds = [], createImpl}) {
const commentaireDocuments = {
create: createImpl || vi.fn(async ({data}) => ({id: Math.floor(Math.random() * 10_000), ...data}))
};
const paroleDocuments = {update: vi.fn(async () => ({}))};
return {
db: {
query: uid => {
if (uid === 'api::parole.parole') {
return {findMany: vi.fn(async () => paroles)};
}
if (uid === 'api::commentaire.commentaire') {
return {
findOne: vi.fn(async ({where}) => (existingRemoteIds.includes(where.remoteId) ? {id: 1} : null))
};
}
return {};
}
},
documents: uid => {
if (uid === 'api::commentaire.commentaire') return commentaireDocuments;
if (uid === 'api::parole.parole') return paroleDocuments;
return {};
},
log: {error: vi.fn()}
};
}
describe('importBokanteComments', () => {
const originalGetStatusContext = bokanteMastodon.getStatusContext;
afterEach(() => {
bokanteMastodon.getStatusContext = originalGetStatusContext;
});
it('importe les nouveaux commentaires et les connecte à la parole', async () => {
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus()]}));
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
const strapi = buildStrapi({paroles});
const result = await importBokanteComments({strapi});
expect(bokanteMastodon.getStatusContext).toHaveBeenCalledWith('112233');
expect(strapi.documents('api::commentaire.commentaire').create).toHaveBeenCalledWith({
status: 'published',
data: expect.objectContaining({
contenu: 'Trè bèl parol !',
origine: 'activitypub',
auteurNom: 'Quelqu\'un',
auteurHandle: '@quelqun@mastodon.social',
auteurAvatarUrl: 'https://mastodon.social/avatars/quelqun.png',
auteurProfilUrl: 'https://mastodon.social/@quelqun',
remoteId: 'status-1',
remoteUrl: 'https://bokante.o-k-i.net/@quelqun/status-1',
parole: 7
})
});
expect(strapi.documents('api::parole.parole').update).toHaveBeenCalledWith({
documentId: 'doc-7',
status: 'published',
data: {commentaires: {connect: expect.any(Array)}}
});
expect(result.imported).toBe(1);
});
it('n\'importe pas un commentaire déjà présent (déduplication par remoteId)', async () => {
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus({id: 'deja-la'})]}));
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
const strapi = buildStrapi({paroles, existingRemoteIds: ['deja-la']});
const result = await importBokanteComments({strapi});
expect(strapi.documents('api::commentaire.commentaire').create).not.toHaveBeenCalled();
expect(strapi.documents('api::parole.parole').update).not.toHaveBeenCalled();
expect(result.imported).toBe(0);
});
it('ignore les statuts marqués sensitive', async () => {
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus({sensitive: true})]}));
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
const strapi = buildStrapi({paroles});
const result = await importBokanteComments({strapi});
expect(strapi.documents('api::commentaire.commentaire').create).not.toHaveBeenCalled();
expect(result.imported).toBe(0);
});
it('continue les autres paroles quand une requête bokante échoue', async () => {
bokanteMastodon.getStatusContext = vi.fn(async statusId => {
if (statusId === 'echoue') {
throw new Error('Mastodon 500: boom');
}
return {descendants: [buildStatus({id: 'ok-1'})]};
});
const paroles = [
{id: 1, documentId: 'doc-1', slug: 'echoue', bokanteStatusId: 'echoue'},
{id: 2, documentId: 'doc-2', slug: 'reussi', bokanteStatusId: 'ok'}
];
const strapi = buildStrapi({paroles});
const result = await importBokanteComments({strapi});
expect(strapi.log.error).toHaveBeenCalledWith(expect.stringContaining('echoue'));
expect(result.imported).toBe(1);
});
});
+36
View File
@@ -0,0 +1,36 @@
'use strict';
const bokanteMastodon = require('./bokante-mastodon');
async function backfillBokanteMirror({strapi}) {
const [parole] = await strapi.db.query('api::parole.parole').findMany({
where: {
publishedAt: {$notNull: true},
bokanteStatusId: {$null: true}
},
orderBy: {publishedAt: 'asc'},
limit: 1
});
if (!parole) {
return {backfilled: false};
}
try {
const status = await bokanteMastodon.createStatus(
`"${parole.titre}" — à (re)découvrir sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${parole.slug}`
);
await strapi.db.query('api::parole.parole').updateMany({
where: {documentId: parole.documentId},
data: {bokanteStatusId: status.id}
});
return {backfilled: true, slug: parole.slug};
} catch (err) {
strapi.log.error(`Backfill bokante (${parole.slug}) : ${err.message}`);
return {backfilled: false};
}
}
module.exports = {backfillBokanteMirror};
+44
View File
@@ -0,0 +1,44 @@
'use strict';
function getConfig() {
return {
instanceUrl: process.env.BOKANTE_INSTANCE_URL || 'https://bokante.o-k-i.net',
accessToken: process.env.BOKANTE_ACCESS_TOKEN
};
}
async function mastodonFetch(url, options) {
const {accessToken} = getConfig();
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${accessToken}`,
...options.headers
}
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Mastodon ${response.status}: ${body}`);
}
return response.json();
}
async function createStatus(text, {visibility = 'public'} = {}) {
const {instanceUrl} = getConfig();
return mastodonFetch(`${instanceUrl}/api/v1/statuses`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: text, visibility})
});
}
async function getStatusContext(statusId) {
const {instanceUrl} = getConfig();
return mastodonFetch(`${instanceUrl}/api/v1/statuses/${statusId}/context`, {
method: 'GET'
});
}
module.exports = {createStatus, getStatusContext};
+71
View File
@@ -0,0 +1,71 @@
'use strict';
const bokanteMastodon = require('./bokante-mastodon');
function stripHtml(html) {
return (html || '').replace(/<[^>]*>/g, '').trim();
}
async function importBokanteComments({strapi}) {
const paroles = await strapi.db.query('api::parole.parole').findMany({
where: {bokanteStatusId: {$notNull: true}}
});
let imported = 0;
for (const parole of paroles) {
let context;
try {
context = await bokanteMastodon.getStatusContext(parole.bokanteStatusId);
} catch (err) {
strapi.log.error(`Import bokante (${parole.slug}) : ${err.message}`);
continue;
}
const newCommentIds = [];
for (const status of context.descendants || []) {
if (status.sensitive) {
continue;
}
const exists = await strapi.db.query('api::commentaire.commentaire').findOne({
where: {remoteId: status.id}
});
if (exists) {
continue;
}
const commentaire = await strapi.documents('api::commentaire.commentaire').create({
status: 'published',
data: {
contenu: stripHtml(status.content),
datePublication: status.created_at,
origine: 'activitypub',
auteurNom: status.account?.display_name,
auteurHandle: status.account?.acct ? `@${status.account.acct}` : undefined,
auteurAvatarUrl: status.account?.avatar,
auteurProfilUrl: status.account?.url,
remoteId: status.id,
remoteUrl: status.url,
parole: parole.id
}
});
newCommentIds.push(commentaire.id);
imported += 1;
}
if (newCommentIds.length > 0) {
await strapi.documents('api::parole.parole').update({
documentId: parole.documentId,
status: 'published',
data: {commentaires: {connect: newCommentIds}}
});
}
}
return {imported};
}
module.exports = {importBokanteComments};
+14
View File
@@ -109,12 +109,26 @@ export interface TradTraductions extends Struct.ComponentSchema {
attributes: {
allemand: Schema.Attribute.RichText;
anglais: Schema.Attribute.RichText;
arabe: Schema.Attribute.RichText;
coreen: Schema.Attribute.RichText;
espagnol: Schema.Attribute.RichText;
francais: Schema.Attribute.RichText;
hausa: Schema.Attribute.RichText;
igbo: Schema.Attribute.RichText;
italien: Schema.Attribute.RichText;
japonais: Schema.Attribute.RichText;
lingala: Schema.Attribute.RichText;
malgache: Schema.Attribute.RichText;
oromo: Schema.Attribute.RichText;
portugais: Schema.Attribute.RichText;
sesotho: Schema.Attribute.RichText;
swahili: Schema.Attribute.RichText;
tsonga: Schema.Attribute.RichText;
tswana: Schema.Attribute.RichText;
wolof: Schema.Attribute.RichText;
xhosa: Schema.Attribute.RichText;
yoruba: Schema.Attribute.RichText;
zoulou: Schema.Attribute.RichText;
};
}
+11 -1
View File
@@ -488,6 +488,10 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
draftAndPublish: true;
};
attributes: {
auteurAvatarUrl: Schema.Attribute.String;
auteurHandle: Schema.Attribute.String;
auteurNom: Schema.Attribute.String;
auteurProfilUrl: Schema.Attribute.String;
contenu: Schema.Attribute.RichText & Schema.Attribute.Required;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
@@ -499,8 +503,13 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
'api::commentaire.commentaire'
> &
Schema.Attribute.Private;
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>;
origine: Schema.Attribute.Enumeration<['local', 'activitypub']> &
Schema.Attribute.Required &
Schema.Attribute.DefaultTo<'local'>;
parole: Schema.Attribute.Relation<'manyToOne', 'api::parole.parole'>;
publishedAt: Schema.Attribute.DateTime;
remoteId: Schema.Attribute.String & Schema.Attribute.Unique;
remoteUrl: Schema.Attribute.String;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
@@ -526,6 +535,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
attributes: {
annee: Schema.Attribute.Integer;
artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>;
bokanteStatusId: Schema.Attribute.String;
commentaires: Schema.Attribute.Relation<
'oneToMany',
'api::commentaire.commentaire'