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 {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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -158,6 +158,9 @@
|
||||
"type": "component",
|
||||
"component": "kit.lyen",
|
||||
"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;
|
||||
};
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user