From 0043a07ec130c8cd9d8d5a15678d724665906ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sat, 4 Jul 2026 23:29:46 +0400 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20sch=C3=A9ma=20pour=20les=20commenta?= =?UTF-8?q?ires=20f=C3=A9d=C3=A9r=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content-types/commentaire/schema.json | 25 +++++++++++++++++++ .../parole/content-types/parole/schema.json | 3 +++ 2 files changed, 28 insertions(+) diff --git a/src/api/commentaire/content-types/commentaire/schema.json b/src/api/commentaire/content-types/commentaire/schema.json index 4ffe8bc..c3887bb 100644 --- a/src/api/commentaire/content-types/commentaire/schema.json +++ b/src/api/commentaire/content-types/commentaire/schema.json @@ -29,6 +29,31 @@ "type": "relation", "relation": "oneToOne", "target": "api::parole.parole" + }, + "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" } } } diff --git a/src/api/parole/content-types/parole/schema.json b/src/api/parole/content-types/parole/schema.json index 0f6c54e..6083ba1 100644 --- a/src/api/parole/content-types/parole/schema.json +++ b/src/api/parole/content-types/parole/schema.json @@ -158,6 +158,9 @@ "type": "component", "component": "kit.lyen", "repeatable": true + }, + "bokanteStatusId": { + "type": "string" } } } -- 2.39.5 From 648e51fe3c72654672fbea1962efadc0251dddb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sat, 4 Jul 2026 23:33:57 +0400 Subject: [PATCH 2/8] feat: client HTTP minimal pour l'API Mastodon de bokante --- src/utils/__tests__/bokante-mastodon.test.js | 73 ++++++++++++++++++++ src/utils/bokante-mastodon.js | 44 ++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/utils/__tests__/bokante-mastodon.test.js create mode 100644 src/utils/bokante-mastodon.js diff --git a/src/utils/__tests__/bokante-mastodon.test.js b/src/utils/__tests__/bokante-mastodon.test.js new file mode 100644 index 0000000..8bf735a --- /dev/null +++ b/src/utils/__tests__/bokante-mastodon.test.js @@ -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: '

coucou

'}]}; + 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'); + }); + }); +}); diff --git a/src/utils/bokante-mastodon.js b/src/utils/bokante-mastodon.js new file mode 100644 index 0000000..362ef8f --- /dev/null +++ b/src/utils/bokante-mastodon.js @@ -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}; -- 2.39.5 From 2a4cea08549daafb3a4efc0c8d43b13637912f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sat, 4 Jul 2026 23:37:15 +0400 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20publier=20un=20statut=20miroir=20bo?= =?UTF-8?q?kante=20=C3=A0=20la=20publication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parole/__tests__/lifecycles.test.js | 97 +++++++++++++++++++ .../parole/content-types/parole/lifecycles.js | 12 +++ 2 files changed, 109 insertions(+) diff --git a/src/api/parole/content-types/parole/__tests__/lifecycles.test.js b/src/api/parole/content-types/parole/__tests__/lifecycles.test.js index 4d394e3..10b2c3a 100644 --- a/src/api/parole/content-types/parole/__tests__/lifecycles.test.js +++ b/src/api/parole/content-types/parole/__tests__/lifecycles.test.js @@ -118,6 +118,103 @@ describe('beforeUpdate — notifications Telegram/Revolt', () => { }); }); +describe('beforeUpdate — publication miroir bokante', () => { + const originalEnv = {...process.env}; + const bokanteMastodon = require('../../../../../utils/bokante-mastodon'); + const originalCreateStatus = bokanteMastodon.createStatus; + + function buildStrapi(previous) { + const dbQuery = { + findOne: vi.fn(async () => previous), + updateMany: vi.fn() + }; + + return { + db: {query: vi.fn(() => dbQuery)}, + plugins: {email: {services: {email: {send: vi.fn()}}}}, + log: {error: vi.fn()} + }; + } + + function buildEvent(overrides = {}) { + return { + state: {}, + params: { + data: {documentId: 'doc-1', publishedAt: '2026-07-04T00:00:00.000Z', ...overrides} + } + }; + } + + afterEach(() => { + process.env = {...originalEnv}; + bokanteMastodon.createStatus = originalCreateStatus; + delete global.strapi; + }); + + it('publie un statut miroir et stocke bokanteStatusId sur data', async () => { + process.env.BOKANTE_ACCESS_TOKEN = 'fake-token'; + bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'})); + + const previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: [], bokanteStatusId: null}; + const strapiMock = buildStrapi(previous); + + const {beforeUpdate} = await loadLifecycles(strapiMock); + const event = buildEvent(); + + await beforeUpdate(event); + + expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1); + expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Mon titre'); + expect(event.params.data.bokanteStatusId).toBe('112233'); + }); + + 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 previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: [], bokanteStatusId: '112233'}; + const strapiMock = buildStrapi(previous); + + const {beforeUpdate} = await loadLifecycles(strapiMock); + + await beforeUpdate(buildEvent()); + + 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 previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: [], bokanteStatusId: null}; + const strapiMock = buildStrapi(previous); + + const {beforeUpdate} = await loadLifecycles(strapiMock); + + await beforeUpdate(buildEvent()); + + expect(bokanteMastodon.createStatus).not.toHaveBeenCalled(); + }); + + it('n\'interrompt pas la publication quand bokante échoue', async () => { + process.env.BOKANTE_ACCESS_TOKEN = 'fake-token'; + bokanteMastodon.createStatus = vi.fn(async () => { + throw new Error('boom'); + }); + + const previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: [], bokanteStatusId: null}; + const strapiMock = buildStrapi(previous); + + const {beforeUpdate} = await loadLifecycles(strapiMock); + const event = buildEvent(); + + await expect(beforeUpdate(event)).resolves.not.toThrow(); + + expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('bokante')); + expect(event.params.data.bokanteStatusId).toBeUndefined(); + }); +}); + describe('beforeUpdate — createdBy/updatedBy', () => { afterEach(() => { delete global.strapi; diff --git a/src/api/parole/content-types/parole/lifecycles.js b/src/api/parole/content-types/parole/lifecycles.js index b0ce667..0d5abc5 100644 --- a/src/api/parole/content-types/parole/lifecycles.js +++ b/src/api/parole/content-types/parole/lifecycles.js @@ -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; @@ -191,6 +192,17 @@ module.exports = { const previousPublishedAt = previousData.publishedAt; const currentPublished_at = data.publishedAt; if (currentPublished_at != previousPublishedAt) { + if (!previousData.bokanteStatusId && process.env.BOKANTE_ACCESS_TOKEN) { + try { + const status = await bokanteMastodon.createStatus( + `"${previousData.titre}" — nouvelle parole sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${previousData.slug}` + ); + data.bokanteStatusId = status.id; + } catch (err) { + strapi.log.error(`Publication bokante : ${err.message}`); + } + } + const message = `Nouvelle publication ❤️ \n${process.env.WEBSITE_URL}/paroles/${previousData.slug}`; if (previousData.user) { -- 2.39.5 From 6c828a23942c6565514b9c9cee81c21702e2e6d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sat, 4 Jul 2026 23:38:41 +0400 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20importer=20les=20r=C3=A9ponses=20bo?= =?UTF-8?q?kante=20en=20commentaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/import-bokante-comments.test.js | 131 ++++++++++++++++++ src/utils/import-bokante-comments.js | 69 +++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/utils/__tests__/import-bokante-comments.test.js create mode 100644 src/utils/import-bokante-comments.js diff --git a/src/utils/__tests__/import-bokante-comments.test.js b/src/utils/__tests__/import-bokante-comments.test.js new file mode 100644 index 0000000..14384aa --- /dev/null +++ b/src/utils/__tests__/import-bokante-comments.test.js @@ -0,0 +1,131 @@ +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: '

Trè bèl parol !

', + 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({ + 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', + 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); + }); +}); diff --git a/src/utils/import-bokante-comments.js b/src/utils/import-bokante-comments.js new file mode 100644 index 0000000..f2d9676 --- /dev/null +++ b/src/utils/import-bokante-comments.js @@ -0,0 +1,69 @@ +'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({ + 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, + data: {commentaires: {connect: newCommentIds}} + }); + } + } + + return {imported}; +} + +module.exports = {importBokanteComments}; -- 2.39.5 From 861e75fde277e2287dff82441f9b1932bd2e99c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sat, 4 Jul 2026 23:39:25 +0400 Subject: [PATCH 5/8] feat: planifier l'import des commentaires bokante (20 min) --- config/cron-task.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/config/cron-task.js b/config/cron-task.js index fc910a8..d6bd687 100644 --- a/config/cron-task.js +++ b/config/cron-task.js @@ -1,5 +1,6 @@ const path = require('path'); const {backupDatabase} = require('../src/utils/backup-database'); +const {importBokanteComments} = require('../src/utils/import-bokante-comments'); module.exports = { myJob: { @@ -19,4 +20,21 @@ 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', + }, + }, }; -- 2.39.5 From 26471d8b0ec717a91ad657053c095f46e44801dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sun, 5 Jul 2026 00:59:59 +0400 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20d=C3=A9placer=20le=20miroir=20bokant?= =?UTF-8?q?e=20de=20beforeUpdate=20vers=20afterCreate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../parole/__tests__/lifecycles.test.js | 76 +++++++++++-------- .../parole/content-types/parole/lifecycles.js | 32 +++++--- 2 files changed, 64 insertions(+), 44 deletions(-) diff --git a/src/api/parole/content-types/parole/__tests__/lifecycles.test.js b/src/api/parole/content-types/parole/__tests__/lifecycles.test.js index 10b2c3a..e6252d1 100644 --- a/src/api/parole/content-types/parole/__tests__/lifecycles.test.js +++ b/src/api/parole/content-types/parole/__tests__/lifecycles.test.js @@ -118,14 +118,14 @@ describe('beforeUpdate — notifications Telegram/Revolt', () => { }); }); -describe('beforeUpdate — publication miroir bokante', () => { +describe('afterCreate — publication miroir bokante', () => { const originalEnv = {...process.env}; const bokanteMastodon = require('../../../../../utils/bokante-mastodon'); const originalCreateStatus = bokanteMastodon.createStatus; - function buildStrapi(previous) { + function buildStrapi() { const dbQuery = { - findOne: vi.fn(async () => previous), + findOne: vi.fn(async () => null), updateMany: vi.fn() }; @@ -136,11 +136,16 @@ describe('beforeUpdate — publication miroir bokante', () => { }; } - function buildEvent(overrides = {}) { + function buildEvent(resultOverrides = {}) { return { - state: {}, - params: { - data: {documentId: 'doc-1', publishedAt: '2026-07-04T00:00:00.000Z', ...overrides} + 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 } }; } @@ -151,33 +156,43 @@ describe('beforeUpdate — publication miroir bokante', () => { delete global.strapi; }); - it('publie un statut miroir et stocke bokanteStatusId sur data', async () => { + 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 previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: [], bokanteStatusId: null}; - const strapiMock = buildStrapi(previous); + const strapiMock = buildStrapi(); + const {afterCreate} = await loadLifecycles(strapiMock); - const {beforeUpdate} = await loadLifecycles(strapiMock); - const event = buildEvent(); - - await beforeUpdate(event); + await afterCreate(buildEvent()); expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1); expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Mon titre'); - expect(event.params.data.bokanteStatusId).toBe('112233'); + 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 previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: [], bokanteStatusId: '112233'}; - const strapiMock = buildStrapi(previous); + const strapiMock = buildStrapi(); + const {afterCreate} = await loadLifecycles(strapiMock); - const {beforeUpdate} = await loadLifecycles(strapiMock); - - await beforeUpdate(buildEvent()); + await afterCreate(buildEvent({bokanteStatusId: '112233'})); expect(bokanteMastodon.createStatus).not.toHaveBeenCalled(); }); @@ -186,32 +201,27 @@ describe('beforeUpdate — publication miroir bokante', () => { delete process.env.BOKANTE_ACCESS_TOKEN; bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'})); - const previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: [], bokanteStatusId: null}; - const strapiMock = buildStrapi(previous); + const strapiMock = buildStrapi(); + const {afterCreate} = await loadLifecycles(strapiMock); - const {beforeUpdate} = await loadLifecycles(strapiMock); - - await beforeUpdate(buildEvent()); + await afterCreate(buildEvent()); expect(bokanteMastodon.createStatus).not.toHaveBeenCalled(); }); - it('n\'interrompt pas la publication quand bokante échoue', async () => { + 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 previous = {publishedAt: null, slug: 'mon-titre', titre: 'Mon titre', user: null, userAdmin: null, artistes: [], bokanteStatusId: null}; - const strapiMock = buildStrapi(previous); + const strapiMock = buildStrapi(); + const {afterCreate} = await loadLifecycles(strapiMock); - const {beforeUpdate} = await loadLifecycles(strapiMock); - const event = buildEvent(); - - await expect(beforeUpdate(event)).resolves.not.toThrow(); + await expect(afterCreate(buildEvent())).resolves.not.toThrow(); expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('bokante')); - expect(event.params.data.bokanteStatusId).toBeUndefined(); + expect(strapiMock.db.query('api::parole.parole').updateMany).not.toHaveBeenCalled(); }); }); diff --git a/src/api/parole/content-types/parole/lifecycles.js b/src/api/parole/content-types/parole/lifecycles.js index 0d5abc5..3718327 100644 --- a/src/api/parole/content-types/parole/lifecycles.js +++ b/src/api/parole/content-types/parole/lifecycles.js @@ -192,17 +192,6 @@ module.exports = { const previousPublishedAt = previousData.publishedAt; const currentPublished_at = data.publishedAt; if (currentPublished_at != previousPublishedAt) { - if (!previousData.bokanteStatusId && process.env.BOKANTE_ACCESS_TOKEN) { - try { - const status = await bokanteMastodon.createStatus( - `"${previousData.titre}" — nouvelle parole sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${previousData.slug}` - ); - data.bokanteStatusId = status.id; - } catch (err) { - strapi.log.error(`Publication bokante : ${err.message}`); - } - } - const message = `Nouvelle publication ❤️ \n${process.env.WEBSITE_URL}/paroles/${previousData.slug}`; if (previousData.user) { @@ -284,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); -- 2.39.5 From e416f94061147b9db68028ebd2cf6c617507e5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sun, 5 Jul 2026 01:07:31 +0400 Subject: [PATCH 7/8] feat: backfiller le catalogue existant, une parole/heure --- config/cron-task.js | 18 +++++ .../backfill-bokante-mirrors.test.js | 75 +++++++++++++++++++ src/utils/backfill-bokante-mirrors.js | 36 +++++++++ 3 files changed, 129 insertions(+) create mode 100644 src/utils/__tests__/backfill-bokante-mirrors.test.js create mode 100644 src/utils/backfill-bokante-mirrors.js diff --git a/config/cron-task.js b/config/cron-task.js index d6bd687..75799f7 100644 --- a/config/cron-task.js +++ b/config/cron-task.js @@ -1,6 +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: { @@ -37,4 +38,21 @@ module.exports = { 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', + }, + }, }; diff --git a/src/utils/__tests__/backfill-bokante-mirrors.test.js b/src/utils/__tests__/backfill-bokante-mirrors.test.js new file mode 100644 index 0000000..478df28 --- /dev/null +++ b/src/utils/__tests__/backfill-bokante-mirrors.test.js @@ -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}); + }); +}); diff --git a/src/utils/backfill-bokante-mirrors.js b/src/utils/backfill-bokante-mirrors.js new file mode 100644 index 0000000..17441c8 --- /dev/null +++ b/src/utils/backfill-bokante-mirrors.js @@ -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}; -- 2.39.5 From 719b4c7905436e01f1943668a1203b82d3e6ca1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sun, 5 Jul 2026 01:18:44 +0400 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20r=C3=A9g=C3=A9n=C3=A9rer=20les=20t?= =?UTF-8?q?ypes=20Strapi=20(champs=20bokante)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- types/generated/contentTypes.d.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/types/generated/contentTypes.d.ts b/types/generated/contentTypes.d.ts index 527838e..da8a7d3 100644 --- a/types/generated/contentTypes.d.ts +++ b/types/generated/contentTypes.d.ts @@ -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; + origine: Schema.Attribute.Enumeration<['local', 'activitypub']> & + Schema.Attribute.Required & + Schema.Attribute.DefaultTo<'local'>; parole: Schema.Attribute.Relation<'oneToOne', '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' -- 2.39.5