-- SPDX-FileCopyrightText: 2024 John Livingston -- SPDX-License-Identifier: AGPL-3.0-only local id = require "util.id"; local st = require "util.stanza"; local get_time = require "util.time".now; local timer = require "util.timer"; local mod_muc = module:depends"muc"; local get_room_from_jid = mod_muc.get_room_from_jid; local poll_start_message = module:require("message").poll_start_message; local poll_end_message = module:require("message").poll_end_message; local schedule_poll_update_message = module:require("message").schedule_poll_update_message; local string_poll_invalid_choice = module:get_option_string("poll_string_invalid_choice") or "This choice is not valid."; local string_poll_anonymous_vote_ok = module:get_option_string("poll_string_anonymous_vote_ok") or "Your vote is taken into account. Votes are anonymous, they will not be shown to other participants."; local string_poll_over = module:get_option_string("poll_string_over") or "This poll is now over."; local scheduled_end = {}; local function schedule_poll_purge(room_jid) module:log("debug", "Scheduling a purge for poll %s", room_jid); timer.add_task(30, function () module:log("info", "Must purge poll for room %s", room_jid); -- 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 module:log("debug", "Room %s not found, was maybe destroyed", room_jid); return; end -- we must check if the poll is ended (could be a new poll!) if not room._data.current_poll then module:log("debug", "Room %s has no current poll to purge", room_jid); return; end if not room._data.current_poll.already_ended then module:log("debug", "Room %s has has a poll that is not ended, must be a new one", room_jid); return; end module:log("info", "Purging poll for room %s", room_jid); room._data.current_poll = nil; end); end local function end_current_poll (room) if not room._data.current_poll then return; end if room._data.current_poll.already_ended then -- this can happen if the server was restarted before the purge schedule_poll_purge(room.jid); return; end module:log("debug", "Ending the current poll for room %s", room.jid); room._data.current_poll.already_ended = true; if scheduled_end[room.jid] then module:log("debug", "Stopping the end timer for the poll"); timer.stop(scheduled_end[room_jid]); scheduled_end[room_jid] = nil; end poll_end_message(room); -- TODO: store the result somewhere, to keep track? -- We don't remove the poll immediatly. Indeed, if the vote is anonymous, -- we don't want to expose votes from late users. schedule_poll_purge(room.jid); end local function schedule_poll_end (room_jid, timestamp) local delay = timestamp - math.floor(get_time()); if delay <= 0 then delay = 1; end module:log("debug", "Must schedule a poll end in %i for room %s", delay, room_jid); if scheduled_end[room_jid] then module:log("debug", "There is already a timer for the %s poll end, rescheduling", room_jid); timer.reschedule(scheduled_end[room_jid], delay); return; end scheduled_end[room_jid] = timer.add_task(delay, function () module:log("debug", "Its time to end the poll for room %s", room_jid); scheduled_end[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 module:log("debug", "Room %s not found, was probably destroyed", room_jid); return; -- room was probably destroyed end end_current_poll(room); end); end local function create_poll(room, fields, occupant) module:log("debug", "Creating a new poll for room %s, by %s", room.jid, occupant.bare_jid); room._data.current_poll = fields; room._data.current_poll.poll_id = id.short(); room._data.current_poll.end_timestamp = math.floor(get_time()) + (60 * fields["muc#roompoll_duration"]); room._data.current_poll.votes_by_occupant = {}; room._data.current_poll.votes_by_choices = {}; room._data.current_poll.choices_ordered = {}; -- choices labels with numerical index, so we can have correct order room._data.current_poll.already_ended = false; room._data.current_poll.occupant_bare_jid = occupant.bare_jid; room._data.current_poll.occupant_nick = occupant.nick; room._data.current_poll.occupant_id = room:get_occupant_id(occupant); for field, _ in pairs(fields) do local c = field:match("^muc#roompoll_choice(%d+)$"); if c then if fields["muc#roompoll_choice" .. c]:find("%S") then room._data.current_poll.votes_by_choices[c] = 0; table.insert(room._data.current_poll.choices_ordered, { number = c; label = fields["muc#roompoll_choice" .. c]; }); end end end table.sort(room._data.current_poll.choices_ordered, function(a, b) if a.number == b.number then return 0; end return tonumber(a.number) < tonumber(b.number); end); poll_start_message(room); schedule_poll_end(room.jid, room._data.current_poll.end_timestamp); end local function handle_groupchat(event) local origin, stanza = event.origin, event.stanza; local room = event.room; if not room._data.current_poll then return; end -- There is a current poll. Is this a vote? local body = stanza:get_child_text("body") if not body or #body < 1 then return; end local choice = body:match("^%s*!(%d+)%s*$"); if not choice then return; end -- Ok, seems it is a vote. if math.floor(get_time()) >= room._data.current_poll.end_timestamp then module:log("debug", "Got a vote for a finished poll, not counting it."); -- Note: we keep bouncing messages a few seconds/minutes after the poll end -- to be sure any user that send the vote too late won't expose his choice. origin.send(st.error_reply( stanza, -- error_type = 'cancel' (see descriptions in RFC 6120 https://xmpp.org/rfcs/rfc6120.html#stanzas-error-syntax) "cancel", -- error_condition = 'not-allowed' (see RFC 6120 Defined Error Conditions https://xmpp.org/rfcs/rfc6120.html#stanzas-error-conditions) "not-allowed", string_poll_over )); return true; -- stop! end -- We must check that the choice is valid: if room._data.current_poll.votes_by_choices[choice] == nil then module:log("debug", "Invalid vote, bouncing it."); origin.send(st.error_reply( stanza, -- error_type = 'cancel' (see descriptions in RFC 6120 https://xmpp.org/rfcs/rfc6120.html#stanzas-error-syntax) "cancel", -- error_condition = 'not-allowed' (see RFC 6120 Defined Error Conditions https://xmpp.org/rfcs/rfc6120.html#stanzas-error-conditions) "bad-request", string_poll_invalid_choice )); return true; -- stop! end -- Ok, we can count the vote. local occupant = event.occupant; if not occupant then module:log("warn", "No occupant in the event, dont know how to count the vote"); return end local occupant_bare_id = occupant.bare_jid; module:log("debug", "Counting a new vote for room %s: choice %i, voter %s", room.jid, choice, occupant_bare_id); -- counting the vote: if room._data.current_poll.votes_by_occupant[occupant_bare_id] ~= nil then module:log("debug", "Occupant %s has already voted for current room %s vote, reassigning his vote.", occupant_bare_id, room.jid); room._data.current_poll.votes_by_choices[room._data.current_poll.votes_by_occupant[occupant_bare_id]] = room._data.current_poll.votes_by_choices[room._data.current_poll.votes_by_occupant[occupant_bare_id]] - 1; end room._data.current_poll.votes_by_choices[choice] = room._data.current_poll.votes_by_choices[choice] + 1; room._data.current_poll.votes_by_occupant[occupant_bare_id] = choice; schedule_poll_update_message(room.jid); -- When the poll is anonymous, we bounce the messages (but count the votes). local must_bounce = room._data.current_poll["muc#roompoll_anonymous"] == true; if must_bounce then module:log("debug", "Anonymous votes, bouncing it."); origin.send(st.error_reply( stanza, -- error_type "continue", -- error_condition "undefined-condition", string_poll_anonymous_vote_ok )); return true; -- stop! end end local function room_restored(event) local room = event.room; if not room._data.current_poll then return; end module:log("info", "Restoring room %s with current ongoing poll.", room.jid); local now = math.floor(get_time()); if now >= room._data.current_poll.end_timestamp then module:log("info", "Current poll is over for room %s, ending it", room.jid); end_current_poll(room); return; end if scheduled_end[room.jid] then module:log("info", "Poll for room %s is not finished yet, the end is still scheduled", room.jid); else module:log("info", "Poll for room %s is not finished yet, rescheduling the end", room.jid); schedule_poll_end(room.jid, room._data.current_poll.end_timestamp); end -- just in case, we can also reschedule an update message schedule_poll_update_message(room.jid); end return { end_current_poll = end_current_poll; create_poll = create_poll; handle_groupchat = handle_groupchat; room_restored = room_restored; };