From a4497739fa9be0f1fdc011178fa171e6dd71f740 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Thu, 1 Aug 2024 16:10:34 +0200 Subject: [PATCH] Search user messages WIP (#145) --- CHANGELOG.md | 1 + conversejs/custom/index.js | 2 + conversejs/custom/plugins/mam-search/api.js | 100 ++++++++++++ .../custom/plugins/mam-search/constants.js | 5 + conversejs/custom/plugins/mam-search/index.js | 71 ++++++++ conversejs/custom/plugins/notes/utils.js | 54 +++--- conversejs/loc.keys.js | 3 +- languages/en.yml | 2 + .../mod_muc_mam_search/README.markdown | 23 +++ .../mod_muc_mam_search/archive.lib.lua | 25 +++ .../mod_muc_mam_search/filter.lib.lua | 24 +++ .../mod_muc_mam_search/mod_muc_mam_search.lua | 154 ++++++++++++++++++ server/lib/prosody/config/content.ts | 2 + 13 files changed, 439 insertions(+), 27 deletions(-) create mode 100644 conversejs/custom/plugins/mam-search/api.js create mode 100644 conversejs/custom/plugins/mam-search/constants.js create mode 100644 conversejs/custom/plugins/mam-search/index.js create mode 100644 prosody-modules/mod_muc_mam_search/README.markdown create mode 100644 prosody-modules/mod_muc_mam_search/archive.lib.lua create mode 100644 prosody-modules/mod_muc_mam_search/filter.lib.lua create mode 100644 prosody-modules/mod_muc_mam_search/mod_muc_mam_search.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 24d5ae94..00debd69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * #146: copy message button for moderators. * #137: option to hide moderator name who made actions (kick, ban, message moderation, ...). * #144: [moderator notes](https://livingston.frama.io/peertube-plugin-livechat/documentation/user/streamers/moderation_notes/). +* #145: action for moderators to find all messages from a given participant. ### Minor changes and fixes diff --git a/conversejs/custom/index.js b/conversejs/custom/index.js index 29de15ce..08033a19 100644 --- a/conversejs/custom/index.js +++ b/conversejs/custom/index.js @@ -44,6 +44,7 @@ import './plugins/singleton/index.js' import './plugins/fullscreen/index.js' import '../custom/plugins/size/index.js' +import '../custom/plugins/mam-search/index.js' import '../custom/plugins/notes/index.js' import '../custom/plugins/tasks/index.js' import '../custom/plugins/terms/index.js' @@ -61,6 +62,7 @@ CORE_PLUGINS.push('livechat-converse-tasks') CORE_PLUGINS.push('livechat-converse-terms') CORE_PLUGINS.push('livechat-converse-poll') CORE_PLUGINS.push('livechat-converse-notes') +CORE_PLUGINS.push('livechat-converse-mam-search') // We must also add our custom ROOM_FEATURES, so that they correctly resets // (see headless/plugins/muc, getDiscoInfoFeatures, which loops on this const) ROOM_FEATURES.push('x_peertubelivechat_mute_anonymous') diff --git a/conversejs/custom/plugins/mam-search/api.js b/conversejs/custom/plugins/mam-search/api.js new file mode 100644 index 00000000..93f7e2be --- /dev/null +++ b/conversejs/custom/plugins/mam-search/api.js @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { api, converse } from '../../../src/headless/index.js' +import { XMLNS_MAM_SEARCH } from './constants.js' + +const env = converse.env +const { + $iq, + Strophe, + sizzle, + log, + TimeoutError, + __, + u +} = env +const NS = Strophe.NS + +async function query (options) { + if (!api.connection.connected()) { + throw new Error('Can\'t call `api.livechat_mam_search.query` before having established an XMPP session') + } + + if (!options?.room) { + throw new Error('api.livechat_mam_search.query: Missing room parameter.') + } + + const attrs = { + type: 'set', + to: options.room + } + + const jid = attrs.to + const supported = await api.disco.supports(XMLNS_MAM_SEARCH, jid) + if (!supported) { + log.warn(`Did not search MAM archive for ${jid} because it doesn't support ${XMLNS_MAM_SEARCH}`) + return { messages: [] } + } + + const queryid = u.getUniqueId() + const stanza = $iq(attrs).c('query', { xmlns: XMLNS_MAM_SEARCH, queryid: queryid }) + + stanza.c('x', { xmlns: NS.XFORM, type: 'submit' }) + .c('field', { var: 'FORM_TYPE', type: 'hidden' }) + .c('value').t(XMLNS_MAM_SEARCH).up().up() + + if (options.from) { + stanza.c('field', { var: 'from' }).c('value') + .t(options.from).up().up() + } + if (options.occupant_id) { + stanza.c('field', { var: 'occupant_id' }).c('value') + .t(options.occupant_id).up().up() + } + stanza.up() + + // TODO: handle RSM (pagination.) + + const connection = api.connection.get() + + const messages = [] + const messageHandler = connection.addHandler((stanza) => { + const result = sizzle(`message > result[xmlns="${NS.MAM}"]`, stanza).pop() + if (result === undefined || result.getAttribute('queryid') !== queryid) { + return true + } + const from = stanza.getAttribute('from') + if (from !== attrs.to) { + log.warn(`Ignoring alleged groupchat MAM message from ${from}`) + return true + } + messages.push(stanza) + return true + }, NS.MAM) + + let error + const timeout = api.settings.get('message_archiving_timeout') + const iqResult = await api.sendIQ(stanza, timeout, false) + + if (iqResult === null) { + const errMsg = __('Timeout while trying to fetch archived messages.') + log.error(errMsg) + error = new TimeoutError(errMsg) + return { messages, error } + } else if (u.isErrorStanza(iqResult)) { + const errMsg = __('An error occurred while querying for archived messages.') + log.error(errMsg) + log.error(iqResult) + error = new Error(errMsg) + return { messages, error } + } + connection.deleteHandler(messageHandler) + + return { messages } +} + +export default { + query +} diff --git a/conversejs/custom/plugins/mam-search/constants.js b/conversejs/custom/plugins/mam-search/constants.js new file mode 100644 index 00000000..c92dd4a2 --- /dev/null +++ b/conversejs/custom/plugins/mam-search/constants.js @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +export const XMLNS_MAM_SEARCH = 'urn:xmpp:mam:2#x-search' diff --git a/conversejs/custom/plugins/mam-search/index.js b/conversejs/custom/plugins/mam-search/index.js new file mode 100644 index 00000000..17ca2551 --- /dev/null +++ b/conversejs/custom/plugins/mam-search/index.js @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { api, converse } from '../../../src/headless/index.js' +import { XMLNS_MAM_SEARCH } from './constants.js' +import mamSearchApi from './api.js' +import { __ } from 'i18n' + +converse.plugins.add('livechat-converse-mam-search', { + dependencies: ['converse-muc', 'converse-muc-views'], + async initialize () { + const _converse = this._converse + + Object.assign(api, { + livechat_mam_search: mamSearchApi + }) + + // Adding buttons on message: + _converse.api.listen.on('getMessageActionButtons', getMessageActionButtons) + + // FIXME: should we listen to any event (feature/affiliation change?, mam_enabled?) to refresh messageActionButtons? + } +}) + +function getMessageActionButtons (messageActionsEl, buttons) { + const messageModel = messageActionsEl.model + if (messageModel.get('type') !== 'groupchat') { + // only on groupchat message. + return buttons + } + + if (!messageModel.occupant) { + return buttons + } + + const muc = messageModel.collection?.chatbox + if (!muc) { + return buttons + } + + if (!muc.features?.get?.(XMLNS_MAM_SEARCH)) { + return buttons + } + + const myself = muc.getOwnOccupant() + if (!myself || !['admin', 'owner'].includes(myself.get('affiliation'))) { + return buttons + } + + // eslint-disable-next-line no-undef + const i18nSearch = __(LOC_search_occupant_message) + + buttons.push({ + i18n_text: i18nSearch, + handler: async (ev) => { + ev.preventDefault() + console.log(await api.livechat_mam_search.query({ + room: muc.get('jid'), + // FIXME: shouldn't we escape the nick? cant see any code that escapes it in Converse. + from: messageModel.occupant.get('from') || muc.get('jid') + '/' + (messageModel.occupant.get('nick') ?? ''), + occupant_id: messageModel.occupant.get('occupant_id') + })) + }, + button_class: '', + icon_class: 'fa fa-magnifying-glass', + name: 'muc-mam-search' + }) + + return buttons +} diff --git a/conversejs/custom/plugins/notes/utils.js b/conversejs/custom/plugins/notes/utils.js index 2cd4bf0e..d1c49311 100644 --- a/conversejs/custom/plugins/notes/utils.js +++ b/conversejs/custom/plugins/notes/utils.js @@ -43,39 +43,41 @@ export function getMessageActionButtons (messageActionsEl, buttons) { return buttons } + if (!messageModel.occupant) { + return buttons + } + const muc = messageModel.collection?.chatbox if (!muc?.notes) { return buttons } - if (messageModel.occupant) { - // eslint-disable-next-line no-undef - const i18nCreate = __(LOC_moderator_note_create_for_participant) - // eslint-disable-next-line no-undef - const i18nSearch = __(LOC_moderator_note_search_for_participant) + // eslint-disable-next-line no-undef + const i18nCreate = __(LOC_moderator_note_create_for_participant) + // eslint-disable-next-line no-undef + const i18nSearch = __(LOC_moderator_note_search_for_participant) - buttons.push({ - i18n_text: i18nCreate, - handler: async (ev) => { - ev.preventDefault() - await api.livechat_notes.openCreateNoteForm(messageModel.occupant) - }, - button_class: '', - icon_class: 'fa fa-note-sticky', - name: 'muc-note-create-for-occupant' - }) + buttons.push({ + i18n_text: i18nCreate, + handler: async (ev) => { + ev.preventDefault() + await api.livechat_notes.openCreateNoteForm(messageModel.occupant) + }, + button_class: '', + icon_class: 'fa fa-note-sticky', + name: 'muc-note-create-for-occupant' + }) - buttons.push({ - i18n_text: i18nSearch, - handler: async (ev) => { - ev.preventDefault() - await api.livechat_notes.searchNotesAbout(messageModel.occupant) - }, - button_class: '', - icon_class: 'fa fa-magnifying-glass', - name: 'muc-note-search-for-occupant' - }) - } + buttons.push({ + i18n_text: i18nSearch, + handler: async (ev) => { + ev.preventDefault() + await api.livechat_notes.searchNotesAbout(messageModel.occupant) + }, + button_class: '', + icon_class: 'fa fa-magnifying-glass', + name: 'muc-note-search-for-occupant' + }) return buttons } diff --git a/conversejs/loc.keys.js b/conversejs/loc.keys.js index f5290ffd..e761bdc6 100644 --- a/conversejs/loc.keys.js +++ b/conversejs/loc.keys.js @@ -59,7 +59,8 @@ const locKeys = [ 'moderator_note_create_for_participant', 'moderator_note_search_for_participant', 'moderator_note_filters', - 'moderator_note_original_nick' + 'moderator_note_original_nick', + 'search_occupant_message' ] module.exports = locKeys diff --git a/languages/en.yml b/languages/en.yml index ba9b46e1..4db677b6 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -604,3 +604,5 @@ moderator_note_create_for_participant: 'Create a new note about this participant moderator_note_search_for_participant: 'Search notes about this participant' moderator_note_filters: 'Search filters' moderator_note_original_nick: 'Nickname of the participant at the time of the note creation' + +search_occupant_message: 'Search all message from this participant' diff --git a/prosody-modules/mod_muc_mam_search/README.markdown b/prosody-modules/mod_muc_mam_search/README.markdown new file mode 100644 index 00000000..0ca73b79 --- /dev/null +++ b/prosody-modules/mod_muc_mam_search/README.markdown @@ -0,0 +1,23 @@ + +# mod_muc_mam_search + +With this module you can make some advanced search in MAM (MUC Archive Management - XEP-0313). + +This module is part of peertube-plugin-livechat, and is under AGPL-3.0.-only license. +This module can work on any Prosody server (version >= 0.12.x). +This module is still experimental. + +## Configuration + +Just enable the module on your MUC component. + +The feature will be announced using Service Discovery (XEP-0030). + +TODO: document the Disco namespace. + +You can then query archives using query similar to those for XEP-0313. + +TODO: document available queries. diff --git a/prosody-modules/mod_muc_mam_search/archive.lib.lua b/prosody-modules/mod_muc_mam_search/archive.lib.lua new file mode 100644 index 00000000..d26be1e6 --- /dev/null +++ b/prosody-modules/mod_muc_mam_search/archive.lib.lua @@ -0,0 +1,25 @@ +-- SPDX-FileCopyrightText: 2024 John Livingston +-- SPDX-License-Identifier: AGPL-3.0-only + +-- FIXME: these imports are copied from mod_muc_mam, we should avoid that. +local log_all_rooms = module:get_option_boolean("muc_log_all_rooms", false); +local log_by_default = module:get_option_boolean("muc_log_by_default", true); + +-- FIXME: this function is copied from mod_muc_mam. We should not do so, and use directly the original function. +local function archiving_enabled(room) + if log_all_rooms then + module:log("debug", "Archiving all rooms"); + return true; + end + local enabled = room._data.archiving; + if enabled == nil then + module:log("debug", "Default is %s (for %s)", log_by_default, room.jid); + return log_by_default; + end + module:log("debug", "Logging in room %s is %s", room.jid, enabled); + return enabled; +end + +return { + archiving_enabled = archiving_enabled; +}; diff --git a/prosody-modules/mod_muc_mam_search/filter.lib.lua b/prosody-modules/mod_muc_mam_search/filter.lib.lua new file mode 100644 index 00000000..e202c54f --- /dev/null +++ b/prosody-modules/mod_muc_mam_search/filter.lib.lua @@ -0,0 +1,24 @@ +-- SPDX-FileCopyrightText: 2024 John Livingston +-- SPDX-License-Identifier: AGPL-3.0-only + +-- Perform the search criteria. +-- Returns true if the item match. +-- Note: there is a logical OR between search_from and search_occupant_id +local function item_match(id, item, search_from, search_occupant_id) + if (search_from ~= nil) then + if (search_from == item.attr.from) then + return true; + end + end + if (search_occupant_id ~= nil) then + local occupant_id = item:get_child("occupant-id", "urn:xmpp:occupant-id:0"); + if (occupant_id and occupant_id.attr.id == search_occupant_id) then + return true; + end + end + return false; +end + +return { + item_match = item_match; +}; diff --git a/prosody-modules/mod_muc_mam_search/mod_muc_mam_search.lua b/prosody-modules/mod_muc_mam_search/mod_muc_mam_search.lua new file mode 100644 index 00000000..6a92c2aa --- /dev/null +++ b/prosody-modules/mod_muc_mam_search/mod_muc_mam_search.lua @@ -0,0 +1,154 @@ +-- mod_muc_mam_search +-- +-- SPDX-FileCopyrightText: 2024 John Livingston +-- SPDX-License-Identifier: AGPL-3.0-only +-- + +local archiving_enabled = module:require("archive").archiving_enabled; +local item_match = module:require("filter").item_match; +local jid_split = require "util.jid".split; +local jid_bare = require "util.jid".bare; +local st = require "util.stanza"; +local datetime = require"util.datetime"; +local dataform = require "util.dataforms".new; +local get_form_type = require "util.dataforms".get_type; + +local mod_muc = module:depends"muc"; +local get_room_from_jid = mod_muc.get_room_from_jid; +local muc_log_archive = module:open_store("muc_log", "archive"); + +local xmlns_mam = "urn:xmpp:mam:2"; +local xmlns_mam_search = "urn:xmpp:mam:2#x-search"; +local xmlns_delay = "urn:xmpp:delay"; +local xmlns_forward = "urn:xmpp:forward:0"; + +module:hook("muc-disco#info", function(event) + if archiving_enabled(event.room) then + event.reply:tag("feature", {var=xmlns_mam_search}):up(); + end +end); + +local query_form = dataform { + { name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam_search }; + { name = "from"; type = "jid-single" }; + { name = "occupant_id"; type = "text-single" }; +}; + +-- Serve form +module:hook("iq-get/bare/"..xmlns_mam_search..":query", function(event) + local origin, stanza = event.origin, event.stanza; + origin.send(st.reply(stanza):query(xmlns_mam_search):add_child(query_form:form())); + return true; +end); + +-- Handle archive queries +module:hook("iq-set/bare/"..xmlns_mam_search..":query", function(event) + local origin, stanza = event.origin, event.stanza; + local room_jid = stanza.attr.to; + local room_node = jid_split(room_jid); + local orig_from = stanza.attr.from; + local query = stanza.tags[1]; + + local room = get_room_from_jid(room_jid); + if not room then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")) + return true; + end + local from = jid_bare(orig_from); + + -- Must be room admin or owner. + local from_affiliation = room:get_affiliation(from); + if (from_affiliation ~= "owner" and from_affiliation ~= "admin") then + origin.send(st.error_reply(stanza, "auth", "forbidden")) + return true; + end + + local qid = query.attr.queryid; + + -- Search query parameters + local search_from; + local search_occupant_id; + local form = query:get_child("x", "jabber:x:data"); + if form then + local form_type, err = get_form_type(form); + if not form_type then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err)); + return true; + elseif form_type ~= xmlns_mam_search then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_mam_search.."'")); + return true; + end + form, err = query_form:data(form); + if err then + origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err)))); + return true; + end + + search_from = form["from"]; + search_occupant_id = form["occupant_id"]; + else + module:log("debug", "Missing query form, forbidden.") + origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing dataform")); + return true; + end + + -- TODO: handle RSM (pagination)? + module:log("debug", "Archive query by %s id=%s", from, qid); + + -- Load all the data! + local data, err = muc_log_archive:find(room_node, { + start = nil; ["end"] = nil; + with = "message tag, containing the original senders JID, unless the room makes this public. + -- but we only allow this feature to owner and admin, so we don't need to remove this. + + item.attr.to = nil; + item.attr.xmlns = "jabber:client"; + fwd_st:add_child(item); + + origin.send(fwd_st); + end + end + + origin.send(st.reply(stanza) + -- The result uses xmlns_mam and not xmlns_mam_search, so that the frontend handles this in the same way than xmlns_mam. + :tag("fin", { xmlns = xmlns_mam, complete = "true" })); + + -- That's all folks! + module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, count); + return true; +end); diff --git a/server/lib/prosody/config/content.ts b/server/lib/prosody/config/content.ts index b827e329..21081a9b 100644 --- a/server/lib/prosody/config/content.ts +++ b/server/lib/prosody/config/content.ts @@ -258,6 +258,8 @@ class ProsodyConfigContent { this.muc.add('modules_enabled', 'muc_anonymize_moderation_actions') this.muc.set('anonymize_moderation_actions_form_position', 117) + + this.muc.add('modules_enabled', 'muc_mam_search') } useAnonymous (autoBanIP: boolean): void {