2024-06-30 13:30:33 +00:00
-- SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
-- SPDX-License-Identifier: AGPL-3.0-only
2024-06-30 15:19:14 +00:00
local id = require " util.id " ;
local st = require " util.stanza " ;
2024-06-30 15:57:33 +00:00
local timer = require " util.timer " ;
2024-06-30 15:19:14 +00:00
local xmlns_occupant_id = " urn:xmpp:occupant-id:0 " ;
2024-06-30 15:57:33 +00:00
local xmlns_replace = " urn:xmpp:message-correct:0 " ;
2024-07-01 10:36:32 +00:00
local xmlns_poll_message = module : require ( " constants " ) . xmlns_poll_message ;
local poll_message_tag = module : require ( " constants " ) . poll_message_tag ;
local poll_question_tag = module : require ( " constants " ) . poll_question_tag ;
local poll_choice_tag = module : require ( " constants " ) . poll_choice_tag ;
2024-06-30 15:19:14 +00:00
2024-06-30 15:57:33 +00:00
local mod_muc = module : depends " muc " ;
local get_room_from_jid = mod_muc.get_room_from_jid ;
2024-07-01 10:04:03 +00:00
local debounce_delay = 5 ; -- number of seconds during which we must group votes to avoid flood.
2024-06-30 15:57:33 +00:00
local scheduled_updates = { } ;
2024-07-04 12:04:33 +00:00
local string_poll_over = module : get_option_string ( " poll_string_over " ) or " This poll is now over. " ;
local string_poll_vote_instructions = module : get_option_string ( " poll_string_vote_instructions " ) or " Send a message with an exclamation mark followed by your choice number to vote. Example: !1 " ;
2024-07-05 08:39:38 +00:00
-- Build the content for poll start and end messages (that will go to the message <body>)
local function build_poll_message_content ( room , is_end_message )
2024-06-30 15:19:14 +00:00
local current_poll = room._data . current_poll ;
if not current_poll then
return nil ;
end
local content = current_poll [ " muc#roompoll_question " ] .. " \n " ;
2024-06-30 15:57:33 +00:00
if is_end_message then
2024-07-04 12:04:33 +00:00
content = content .. string_poll_over .. " \n " ;
2024-06-30 15:57:33 +00:00
end
2024-06-30 15:19:14 +00:00
local total = 0 ;
for choice , nb in pairs ( current_poll.votes_by_choices ) do
total = total + nb ;
end
2024-07-01 10:04:03 +00:00
for _ , choice_desc in ipairs ( current_poll.choices_ordered ) do
local choice , label = choice_desc.number , choice_desc.label ;
2024-06-30 15:19:14 +00:00
content = content .. choice .. ' : ' .. label ;
2024-07-05 08:39:38 +00:00
-- if vote over, and at least 1 vote, we add the results.
if is_end_message and total > 0 then
2024-07-01 10:36:32 +00:00
local nb = current_poll.votes_by_choices [ choice ] or 0 ;
2024-06-30 15:57:33 +00:00
local percent = string.format ( " %.2f " , nb * 100 / total ) ;
2024-06-30 15:19:14 +00:00
content = content .. " ( " .. nb .. " / " .. total .. " = " .. percent .. " %) " ;
end
content = content .. " \n " ;
end
2024-06-30 15:57:33 +00:00
if not is_end_message then
2024-07-04 12:04:33 +00:00
content = content .. string_poll_vote_instructions .. " \n " ;
2024-06-30 15:57:33 +00:00
end
2024-06-30 15:19:14 +00:00
2024-07-05 08:39:38 +00:00
return content ;
end
-- construct the poll message stanza.
-- Note: content can be nil, for updates messages.
local function build_poll_message ( room , content )
local current_poll = room._data . current_poll ;
if not current_poll then
return nil ;
end
local from = current_poll.occupant_nick ; -- this is in fact room.jid/nickname
2024-06-30 15:19:14 +00:00
local msg = st.message ( {
type = " groupchat " ,
from = from ,
2024-07-05 08:39:38 +00:00
id = id.long ( )
2024-06-30 15:19:14 +00:00
} , content ) ;
msg : tag ( " occupant-id " , {
xmlns = xmlns_occupant_id ,
id = current_poll.occupant_id
} ) : up ( ) ;
2024-07-05 08:39:38 +00:00
if content == nil then
-- No content, this is an update message.
-- Adding some hints (XEP-0334):
msg : tag ( " no-copy " , { xmlns = " urn:xmpp:hints " } ) : up ( ) ;
msg : tag ( " no-store " , { xmlns = " urn:xmpp:hints " } ) : up ( ) ;
msg : tag ( " no-permanent-store " , { xmlns = " urn:xmpp:hints " } ) : up ( ) ;
end
2024-07-01 10:36:32 +00:00
-- now we must add some custom XML data, so that compatible clients can display the poll as they want:
2024-07-01 15:45:11 +00:00
-- <x-poll xmlns="http://jabber.org/protocol/muc#x-poll-message" id="I9UWyoxsz4BN" votes="1" end="1719842224" over="">
2024-07-01 10:36:32 +00:00
-- <x-poll-question>Poll question</x-poll-question>
-- <x-poll-choice choice="1" votes="0">Choice 1 label</x-poll-choice>
-- <x-poll-choice choice="2" votes="1">Choice 2 label</x-poll-choice>
-- <x-poll-choice choice="3" votes="0">Choice 3 label</x-poll-choice>
-- <x-poll-choice choice="4" votes="0">Choice 4 label</x-poll-choice>
-- </x-poll>
2024-07-05 08:39:38 +00:00
local total = 0 ;
for choice , nb in pairs ( current_poll.votes_by_choices ) do
total = total + nb ;
end
2024-07-01 10:36:32 +00:00
local message_attrs = {
xmlns = xmlns_poll_message ,
id = current_poll.poll_id ,
votes = " " .. total
} ;
2024-07-01 15:45:11 +00:00
message_attrs [ " end " ] = string.format ( " %i " , current_poll.end_timestamp ) ;
2024-07-01 10:36:32 +00:00
if current_poll.already_ended then
message_attrs [ " over " ] = " " ;
end
msg : tag ( poll_message_tag , message_attrs ) : text_tag ( poll_question_tag , current_poll [ " muc#roompoll_question " ] , { } ) ;
for _ , choice_desc in ipairs ( current_poll.choices_ordered ) do
local choice , label = choice_desc.number , choice_desc.label ;
local nb = current_poll.votes_by_choices [ choice ] or 0 ;
2024-07-05 08:39:38 +00:00
total = total + nb ;
2024-07-01 10:36:32 +00:00
msg : text_tag ( poll_choice_tag , label , {
votes = " " .. nb ,
choice = choice
} ) ;
end
msg : up ( ) ;
2024-06-30 15:19:14 +00:00
return msg ;
end
2024-06-30 15:57:33 +00:00
-- sends a message when the poll starts.
2024-06-30 13:30:33 +00:00
local function poll_start_message ( room )
2024-06-30 15:19:14 +00:00
if not room._data . current_poll then
return nil ;
end
module : log ( " debug " , " Sending the start message for room %s poll " , room.jid ) ;
2024-07-05 08:39:38 +00:00
local content = build_poll_message_content ( room , false ) ;
local msg = build_poll_message ( room , content ) ;
2024-06-30 15:19:14 +00:00
room : broadcast_message ( msg ) ;
2024-06-30 13:30:33 +00:00
end
2024-06-30 15:57:33 +00:00
-- Send the poll update message
local function send_poll_update_message ( room )
if not room._data . current_poll then
return nil ;
end
2024-07-01 10:04:03 +00:00
if room._data . current_poll.already_ended then
module : log ( " debug " , " Cancelling the update message for room %s poll, because already_ended==true. " , room.jid ) ;
return nil ;
end
2024-06-30 15:57:33 +00:00
module : log ( " debug " , " Sending an update message for room %s poll " , room.jid ) ;
2024-07-05 08:39:38 +00:00
local msg = build_poll_message ( room , nil ) ;
2024-06-30 15:57:33 +00:00
room : broadcast_message ( msg ) ;
end
-- Schedule an update of the start message.
-- We do not send this update each time someone vote,
-- to avoid flooding.
local function schedule_poll_update_message ( room_jid )
if scheduled_updates [ room_jid ] then
-- already a running timer, we can ignore to debounce.
return ;
end
scheduled_updates [ room_jid ] = timer.add_task ( debounce_delay , function ( )
scheduled_updates [ room_jid ] = nil ;
-- We dont pass room, because here it could have been removed from memory.
-- So we must relad the room from the JID in any case.
local room = get_room_from_jid ( room_jid ) ;
if not room then
return ;
end
send_poll_update_message ( room ) ;
end ) ;
2024-06-30 13:30:33 +00:00
end
2024-06-30 15:57:33 +00:00
-- Send a new message when the poll is over, with the result.
2024-06-30 13:30:33 +00:00
local function poll_end_message ( room )
2024-06-30 15:57:33 +00:00
if not room._data . current_poll then
return nil ;
end
module : log ( " debug " , " Sending the end message for room %s poll " , room.jid ) ;
2024-07-04 13:34:32 +00:00
if scheduled_updates [ room.jid ] then
module : log ( " debug " , " Cancelling an update message for the poll %s " , room.jid ) ;
timer.stop ( scheduled_updates [ room.jid ] ) ;
scheduled_updates [ room.jid ] = nil ;
end
2024-07-05 08:39:38 +00:00
local content = build_poll_message_content ( room , true ) ;
local msg = build_poll_message ( room , content ) ;
2024-06-30 15:57:33 +00:00
room : broadcast_message ( msg ) ;
2024-06-30 13:30:33 +00:00
end
2024-07-01 13:01:30 +00:00
-- security check: we must remove all specific tags, to be sure nobody tries to spoof polls!
local function remove_specific_tags_from_groupchat ( event )
event.stanza : maptags ( function ( child )
if child.name == poll_message_tag then
return nil ;
end
if child.name == poll_question_tag then
return nil ;
end
if child.name == poll_choice_tag then
return nil ;
end
return child ;
end ) ;
end
2024-07-01 15:45:11 +00:00
-- when a new session is opened, we must send the current poll to the client
local function handle_new_occupant_session ( event )
local room = event.room ;
2024-07-05 08:39:38 +00:00
local occupant = event.occupant ;
local origin = event.origin ;
if not occupant then
return ;
end
2024-07-01 15:45:11 +00:00
if not room._data . current_poll then
return ;
end
if room._data . current_poll.already_ended then
return ;
end
2024-07-05 08:39:38 +00:00
-- Sending an update message to the new occupant.
module : log ( " debug " , " Sending a poll update message to new occupant %s " , occupant.jid ) ;
local msg = build_poll_message ( room , nil ) ;
msg.attr . to = occupant.jid ;
origin.send ( msg ) ;
2024-07-01 15:45:11 +00:00
end
2024-06-30 13:30:33 +00:00
return {
poll_start_message = poll_start_message ;
poll_end_message = poll_end_message ;
schedule_poll_update_message = schedule_poll_update_message ;
2024-07-01 13:01:30 +00:00
remove_specific_tags_from_groupchat = remove_specific_tags_from_groupchat ;
2024-07-01 15:45:11 +00:00
handle_new_occupant_session = handle_new_occupant_session ;
2024-06-30 13:30:33 +00:00
} ;