Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821b9b96aa
|
||
|
|
842e87543c
|
||
|
|
ecbcf5d86e
|
||
|
|
128c027af1
|
||
|
|
29bdf72640
|
||
|
|
fb60226bd4
|
||
|
|
34f3a7783b
|
||
|
|
03960cc952 |
@@ -163,6 +163,37 @@ curl -H "Authorization: Bearer <token>" \
|
|||||||
-o dataset.jsonl
|
-o dataset.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Commentaires fédérés (Mastodon / bokante.o-k-i.net)
|
||||||
|
|
||||||
|
Chaque parole publiée peut obtenir un statut miroir sur l'instance Mastodon de
|
||||||
|
l'organisation, [bokante.o-k-i.net](https://bokante.o-k-i.net), et les réponses reçues
|
||||||
|
sur ce statut sont importées comme commentaires. Voir le RFC dédié pour le détail de la
|
||||||
|
conception (`RFC-commentaires-activitypub-2026-07-04.md`, à la racine du dossier
|
||||||
|
`PAWOL.NU`).
|
||||||
|
|
||||||
|
**Variables d'environnement :**
|
||||||
|
|
||||||
|
| Variable | Obligatoire | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `BOKANTE_ACCESS_TOKEN` | Oui, pour activer la fonctionnalité | Jeton d'application Mastodon (scopes `write:statuses`, `read:statuses`) d'un compte bot dédié sur bokante.o-k-i.net |
|
||||||
|
| `BOKANTE_INSTANCE_URL` | Non | Instance Mastodon cible (défaut : `https://bokante.o-k-i.net`) |
|
||||||
|
| `BOKANTE_BACKFILL_CRON` | Non | Règle cron du backfill du catalogue existant (défaut : `0 * * * *`, une parole/heure) |
|
||||||
|
|
||||||
|
Sans `BOKANTE_ACCESS_TOKEN`, toute la fonctionnalité (publication du miroir, import des
|
||||||
|
réponses, backfill) est un no-op silencieux — comme les intégrations Telegram/Revolt.
|
||||||
|
|
||||||
|
**Comment obtenir le jeton :** créer un compte bot dédié sur bokante.o-k-i.net, puis dans
|
||||||
|
*Préférences → Développement → Nouvelle application*, cocher uniquement `write:statuses`
|
||||||
|
et `read:statuses`. Le jeton d'accès est affiché directement sur la page de l'application
|
||||||
|
créée.
|
||||||
|
|
||||||
|
**Tâches planifiées (cron) :**
|
||||||
|
- Publication du miroir : déclenchée à chaque publication d'une parole (pas de cron).
|
||||||
|
- Import des réponses : toutes les 20 minutes.
|
||||||
|
- Backfill du catalogue existant : une parole par exécution (la plus ancienne sans
|
||||||
|
miroir en premier), au rythme défini par `BOKANTE_BACKFILL_CRON`. S'arrête de
|
||||||
|
lui-même une fois le catalogue rattrapé.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (C) 2024 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
|
Copyright (C) 2024 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
|
||||||
|
|||||||
@@ -27,11 +27,33 @@ describe('commentaire afterCreate — notification email', () => {
|
|||||||
|
|
||||||
const {afterCreate} = await loadLifecycles(strapiMock);
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
await afterCreate({params: {data: {user: 1, parole: 7, contenu: '<img src=x onerror=alert(1)>'}}});
|
await afterCreate({params: {data: {user: {connect: [{id: 1}]}, parole: {connect: [{id: 7}]}, contenu: '<img src=x onerror=alert(1)>'}}});
|
||||||
|
|
||||||
expect(emailSend).toHaveBeenCalledTimes(1);
|
expect(emailSend).toHaveBeenCalledTimes(1);
|
||||||
const [payload] = emailSend.mock.calls[0];
|
const [payload] = emailSend.mock.calls[0];
|
||||||
expect(payload.text).toBe('<img src=x onerror=alert(1)>');
|
expect(payload.text).toBe('<img src=x onerror=alert(1)>');
|
||||||
expect(payload.html).toBeUndefined();
|
expect(payload.html).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extrait l\'id de la relation quelle que soit sa forme (connect, set, id brut)', async () => {
|
||||||
|
const paroleFindOne = vi.fn(async ({where}) => ({id: where.id, titre: 'Mon titre'}));
|
||||||
|
const strapiMock = {
|
||||||
|
db: {
|
||||||
|
query: vi.fn(uid => {
|
||||||
|
if (uid === 'plugin::users-permissions.user') return {findOne: vi.fn(async () => null)};
|
||||||
|
if (uid === 'api::parole.parole') return {findOne: paroleFindOne};
|
||||||
|
throw new Error(`unexpected uid: ${uid}`);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
plugins: {email: {services: {email: {send: vi.fn()}}}}
|
||||||
|
};
|
||||||
|
|
||||||
|
const {afterCreate} = await loadLifecycles(strapiMock);
|
||||||
|
|
||||||
|
await afterCreate({params: {data: {parole: {set: [{id: 7}]}, contenu: 'ok'}}});
|
||||||
|
expect(paroleFindOne).toHaveBeenCalledWith({where: {id: 7}});
|
||||||
|
|
||||||
|
await afterCreate({params: {data: {parole: 7, contenu: 'ok'}}});
|
||||||
|
expect(paroleFindOne).toHaveBeenCalledWith({where: {id: 7}});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
const { ApplicationError, NotFoundError } = require('@strapi/utils').errors;
|
const { ApplicationError, NotFoundError } = require('@strapi/utils').errors;
|
||||||
|
|
||||||
|
// Le Document Service transforme les relations en { connect: [{id}] } ou
|
||||||
|
// { set: [{id}] } avant que les hooks bas niveau (beforeCreate/afterCreate)
|
||||||
|
// ne reçoivent `data` : un id brut n'est plus systématiquement garanti ici.
|
||||||
|
const idRelasyonAn = valè => {
|
||||||
|
if (valè == null || typeof valè !== 'object') {
|
||||||
|
return valè;
|
||||||
|
}
|
||||||
|
|
||||||
|
return valè.connect?.[0]?.id ?? valè.set?.[0]?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
const jwennUserEpiId = async userId => {
|
const jwennUserEpiId = async userId => {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -47,8 +58,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
afterCreate: async event => {
|
afterCreate: async event => {
|
||||||
const {data} = event.params;
|
const {data} = event.params;
|
||||||
const user = await jwennUserEpiId(data.user);
|
const user = await jwennUserEpiId(idRelasyonAn(data.user));
|
||||||
const parole = await jwennParoleEpiId(data.parole);
|
const parole = await jwennParoleEpiId(idRelasyonAn(data.parole));
|
||||||
|
|
||||||
if (!parole) {
|
if (!parole) {
|
||||||
throw new NotFoundError('Texte introuvable.');
|
throw new NotFoundError('Texte introuvable.');
|
||||||
|
|||||||
@@ -27,8 +27,9 @@
|
|||||||
},
|
},
|
||||||
"parole": {
|
"parole": {
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"relation": "oneToOne",
|
"relation": "manyToOne",
|
||||||
"target": "api::parole.parole"
|
"target": "api::parole.parole",
|
||||||
|
"inversedBy": "commentaires"
|
||||||
},
|
},
|
||||||
"origine": {
|
"origine": {
|
||||||
"type": "enumeration",
|
"type": "enumeration",
|
||||||
|
|||||||
@@ -82,7 +82,8 @@
|
|||||||
"commentaires": {
|
"commentaires": {
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"relation": "oneToMany",
|
"relation": "oneToMany",
|
||||||
"target": "api::commentaire.commentaire"
|
"target": "api::commentaire.commentaire",
|
||||||
|
"mappedBy": "parole"
|
||||||
},
|
},
|
||||||
"prioriteArtistes": {
|
"prioriteArtistes": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ const { ApplicationError } = require('@strapi/utils').errors;
|
|||||||
|
|
||||||
const LANG_MAP = {
|
const LANG_MAP = {
|
||||||
fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' },
|
fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' },
|
||||||
|
ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' },
|
||||||
|
ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' },
|
||||||
en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English', deeplTarget: 'EN', suffix: '\n\n (Translated by DeepL)' },
|
en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English', deeplTarget: 'EN', suffix: '\n\n (Translated by DeepL)' },
|
||||||
es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' },
|
es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' },
|
||||||
de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch', deeplTarget: 'DE', suffix: '\n\n (Übersetzt von DeepL)' },
|
de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch', deeplTarget: 'DE', suffix: '\n\n (Übersetzt von DeepL)' },
|
||||||
it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano', deeplTarget: 'IT', suffix: '\n\n (Tradotto da DeepL)' },
|
it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano', deeplTarget: 'IT', suffix: '\n\n (Tradotto da DeepL)' },
|
||||||
pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' },
|
pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' },
|
||||||
ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' },
|
|
||||||
ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_LANGS = Object.keys(LANG_MAP);
|
const ALL_LANGS = Object.keys(LANG_MAP);
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
"francais": {
|
"francais": {
|
||||||
"type": "richtext"
|
"type": "richtext"
|
||||||
},
|
},
|
||||||
|
"japonais": {
|
||||||
|
"type": "richtext"
|
||||||
|
},
|
||||||
|
"coreen": {
|
||||||
|
"type": "richtext"
|
||||||
|
},
|
||||||
"anglais": {
|
"anglais": {
|
||||||
"type": "richtext"
|
"type": "richtext"
|
||||||
},
|
},
|
||||||
@@ -24,12 +30,6 @@
|
|||||||
},
|
},
|
||||||
"portugais": {
|
"portugais": {
|
||||||
"type": "richtext"
|
"type": "richtext"
|
||||||
},
|
|
||||||
"japonais": {
|
|
||||||
"type": "richtext"
|
|
||||||
},
|
|
||||||
"coreen": {
|
|
||||||
"type": "richtext"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ describe('importBokanteComments', () => {
|
|||||||
|
|
||||||
expect(bokanteMastodon.getStatusContext).toHaveBeenCalledWith('112233');
|
expect(bokanteMastodon.getStatusContext).toHaveBeenCalledWith('112233');
|
||||||
expect(strapi.documents('api::commentaire.commentaire').create).toHaveBeenCalledWith({
|
expect(strapi.documents('api::commentaire.commentaire').create).toHaveBeenCalledWith({
|
||||||
|
status: 'published',
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
contenu: 'Trè bèl parol !',
|
contenu: 'Trè bèl parol !',
|
||||||
origine: 'activitypub',
|
origine: 'activitypub',
|
||||||
@@ -80,6 +81,7 @@ describe('importBokanteComments', () => {
|
|||||||
});
|
});
|
||||||
expect(strapi.documents('api::parole.parole').update).toHaveBeenCalledWith({
|
expect(strapi.documents('api::parole.parole').update).toHaveBeenCalledWith({
|
||||||
documentId: 'doc-7',
|
documentId: 'doc-7',
|
||||||
|
status: 'published',
|
||||||
data: {commentaires: {connect: expect.any(Array)}}
|
data: {commentaires: {connect: expect.any(Array)}}
|
||||||
});
|
});
|
||||||
expect(result.imported).toBe(1);
|
expect(result.imported).toBe(1);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async function importBokanteComments({strapi}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commentaire = await strapi.documents('api::commentaire.commentaire').create({
|
const commentaire = await strapi.documents('api::commentaire.commentaire').create({
|
||||||
|
status: 'published',
|
||||||
data: {
|
data: {
|
||||||
contenu: stripHtml(status.content),
|
contenu: stripHtml(status.content),
|
||||||
datePublication: status.created_at,
|
datePublication: status.created_at,
|
||||||
@@ -58,6 +59,7 @@ async function importBokanteComments({strapi}) {
|
|||||||
if (newCommentIds.length > 0) {
|
if (newCommentIds.length > 0) {
|
||||||
await strapi.documents('api::parole.parole').update({
|
await strapi.documents('api::parole.parole').update({
|
||||||
documentId: parole.documentId,
|
documentId: parole.documentId,
|
||||||
|
status: 'published',
|
||||||
data: {commentaires: {connect: newCommentIds}}
|
data: {commentaires: {connect: newCommentIds}}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -506,7 +506,7 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
|
|||||||
origine: Schema.Attribute.Enumeration<['local', 'activitypub']> &
|
origine: Schema.Attribute.Enumeration<['local', 'activitypub']> &
|
||||||
Schema.Attribute.Required &
|
Schema.Attribute.Required &
|
||||||
Schema.Attribute.DefaultTo<'local'>;
|
Schema.Attribute.DefaultTo<'local'>;
|
||||||
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>;
|
parole: Schema.Attribute.Relation<'manyToOne', 'api::parole.parole'>;
|
||||||
publishedAt: Schema.Attribute.DateTime;
|
publishedAt: Schema.Attribute.DateTime;
|
||||||
remoteId: Schema.Attribute.String & Schema.Attribute.Unique;
|
remoteId: Schema.Attribute.String & Schema.Attribute.Unique;
|
||||||
remoteUrl: Schema.Attribute.String;
|
remoteUrl: Schema.Attribute.String;
|
||||||
|
|||||||
Reference in New Issue
Block a user