Merge pull request 'Commentaires fédérés via Mastodon (bokante.o-k-i.net) — backend' (#5) from feat/comment-fedi into master
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const {backupDatabase} = require('../src/utils/backup-database');
|
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 = {
|
module.exports = {
|
||||||
myJob: {
|
myJob: {
|
||||||
@@ -19,4 +21,38 @@ module.exports = {
|
|||||||
tz: 'Indian/Reunion',
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,31 @@
|
|||||||
"type": "relation",
|
"type": "relation",
|
||||||
"relation": "oneToOne",
|
"relation": "oneToOne",
|
||||||
"target": "api::parole.parole"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
describe('beforeUpdate — createdBy/updatedBy', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete global.strapi;
|
delete global.strapi;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const slugify = require('slugify');
|
const slugify = require('slugify');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const bokanteMastodon = require('../../../../utils/bokante-mastodon');
|
||||||
|
|
||||||
const utils = require('@strapi/utils');
|
const utils = require('@strapi/utils');
|
||||||
const { ApplicationError } = utils.errors;
|
const { ApplicationError } = utils.errors;
|
||||||
@@ -272,6 +273,27 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
afterCreate: async event => {
|
afterCreate: async event => {
|
||||||
const {data} = event.params;
|
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 user = await jwennUserEpiId(data?.user?.id);
|
||||||
const userAdmin = await jwennUserAdminEpiId(data?.createdBy);
|
const userAdmin = await jwennUserAdminEpiId(data?.createdBy);
|
||||||
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
|
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
|
||||||
|
|||||||
@@ -158,6 +158,9 @@
|
|||||||
"type": "component",
|
"type": "component",
|
||||||
"component": "kit.lyen",
|
"component": "kit.lyen",
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
|
},
|
||||||
|
"bokanteStatusId": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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: '<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({
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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};
|
||||||
@@ -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};
|
||||||
@@ -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};
|
||||||
Vendored
+10
@@ -488,6 +488,10 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
|
|||||||
draftAndPublish: true;
|
draftAndPublish: true;
|
||||||
};
|
};
|
||||||
attributes: {
|
attributes: {
|
||||||
|
auteurAvatarUrl: Schema.Attribute.String;
|
||||||
|
auteurHandle: Schema.Attribute.String;
|
||||||
|
auteurNom: Schema.Attribute.String;
|
||||||
|
auteurProfilUrl: Schema.Attribute.String;
|
||||||
contenu: Schema.Attribute.RichText & Schema.Attribute.Required;
|
contenu: Schema.Attribute.RichText & Schema.Attribute.Required;
|
||||||
createdAt: Schema.Attribute.DateTime;
|
createdAt: Schema.Attribute.DateTime;
|
||||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
@@ -499,8 +503,13 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
|
|||||||
'api::commentaire.commentaire'
|
'api::commentaire.commentaire'
|
||||||
> &
|
> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
|
origine: Schema.Attribute.Enumeration<['local', 'activitypub']> &
|
||||||
|
Schema.Attribute.Required &
|
||||||
|
Schema.Attribute.DefaultTo<'local'>;
|
||||||
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>;
|
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>;
|
||||||
publishedAt: Schema.Attribute.DateTime;
|
publishedAt: Schema.Attribute.DateTime;
|
||||||
|
remoteId: Schema.Attribute.String & Schema.Attribute.Unique;
|
||||||
|
remoteUrl: Schema.Attribute.String;
|
||||||
updatedAt: Schema.Attribute.DateTime;
|
updatedAt: Schema.Attribute.DateTime;
|
||||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
@@ -526,6 +535,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
|
|||||||
attributes: {
|
attributes: {
|
||||||
annee: Schema.Attribute.Integer;
|
annee: Schema.Attribute.Integer;
|
||||||
artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>;
|
artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>;
|
||||||
|
bokanteStatusId: Schema.Attribute.String;
|
||||||
commentaires: Schema.Attribute.Relation<
|
commentaires: Schema.Attribute.Relation<
|
||||||
'oneToMany',
|
'oneToMany',
|
||||||
'api::commentaire.commentaire'
|
'api::commentaire.commentaire'
|
||||||
|
|||||||
Reference in New Issue
Block a user