diff --git a/backend_node/server.js b/backend_node/server.js index 4fb51b5..ae9462a 100644 --- a/backend_node/server.js +++ b/backend_node/server.js @@ -1,313 +1,344 @@ -// server.js -const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, delay, jidNormalizedUser, getAggregateVotesInPollMessage, proto } = require('@whiskeysockets/baileys'); // Added getAggregateVotesInPollMessage and proto -const { Boom } = require('@hapi/boom'); -const express = require('express'); -const http = require('http'); -const { Server } = require('socket.io'); -const pino = require('pino'); -const fs = require('fs').promises; -const path = require('path'); -const crypto = require('crypto'); - -const app = express(); -const server = http.createServer(app); -const io = new Server(server, { - cors: { origin: "*", methods: ["GET", "POST"] } -}); -const PORT = 3000; -app.use(express.json()); - -let sock; -let clientReady = false; -let qrCodeData = null; - -let activePolls = {}; // Store for polls sent in the current session - -function generateOptionSha256(optionText) { - return crypto.createHash('sha256').update(Buffer.from(optionText)).digest('hex'); -} - -async function connectToWhatsApp() { - console.log('Initializing Baileys WhatsApp Client (Poll Focus)...'); - const { state, saveCreds } = await useMultiFileAuthState('baileys_auth_info'); - const { version, isLatest } = await fetchLatestBaileysVersion(); - console.log(`using Baileys version ${version.join('.')}`); - - sock = makeWASocket({ - auth: state, - printQRInTerminal: true, // QR code එක terminal එකේ පෙන්වයි - browser: ['WhatsApp Poll Enhanced', 'Chrome', '1.0'], - logger: pino({ level: 'debug' }) // DEBUG level to see more logs - }); - - sock.ev.on('connection.update', async (update) => { - const { connection, lastDisconnect, qr } = update; - if (connection === 'open') { - console.log('Baileys WhatsApp Client is ready! (Poll Focus)'); - clientReady = true; - qrCodeData = null; - io.emit('client_status', 'ready'); - io.emit('whatsapp_user', sock.user); // Send user info - } else if (connection === 'close') { - clientReady = false; - qrCodeData = null; // Clear QR on close - const shouldReconnect = (lastDisconnect?.error instanceof Boom)?.output?.statusCode !== DisconnectReason.loggedOut; - console.log('Connection closed due to ', lastDisconnect?.error, ', reconnecting ', shouldReconnect); - io.emit('client_status', 'disconnected'); - if (shouldReconnect) { - connectToWhatsApp(); - } else { - console.log('Logged out, not reconnecting. Please delete baileys_auth_info and restart.'); - // Optionally, inform GUI about permanent logout - io.emit('client_status', 'logged_out'); - } - } - if (qr) { - qrCodeData = qr; - io.emit('qr_code', qr); - io.emit('client_status', 'qr_pending'); - console.log('QR code generated. Scan it.'); - } - }); - - sock.ev.on('creds.update', saveCreds); - - sock.ev.on('messages.upsert', async ({ messages, type }) => { - if (type !== 'notify') return; - - const msg = messages[0]; - if (!msg.message) return; // Ignore if message content is empty - - // console.log('Received message:', JSON.stringify(msg, undefined, 2)); // Detailed log for incoming messages - - if (msg.message.pollUpdateMessage) { - const pollUpdate = msg.message.pollUpdateMessage; - const originalPollMsgKey = pollUpdate.pollCreationMessageKey; - // voterJid can be from msg.key.participant (group) or msg.key.remoteJid (DM, if direct poll update) - // However, poll updates in groups are usually from the group jid with a participant field inside msg. - const voterJid = msg.key.participant || msg.participant || msg.key.remoteJid; - - - if (!originalPollMsgKey || !originalPollMsgKey.id) { - console.warn("Poll update received without original poll message key ID. Skipping. Details:", JSON.stringify(originalPollMsgKey)); - return; - } - const pollMsgId = originalPollMsgKey.id; - - console.log(`Poll Update for Poll ID: ${pollMsgId} from Voter: ${voterJid}`); - // console.log('Poll Update Raw Details:', JSON.stringify(pollUpdate, undefined, 2)); - - if (activePolls[pollMsgId]) { - const poll = activePolls[pollMsgId]; - let selectedOptionHashes = []; - - // --- TypeError නිවැරදි කිරීම මෙතන --- - if (pollUpdate.votes && Array.isArray(pollUpdate.votes)) { - selectedOptionHashes = pollUpdate.votes.map(voteBuffer => { - if (Buffer.isBuffer(voteBuffer)) { - return voteBuffer.toString('hex'); - } else { - console.warn(`Item in pollUpdate.votes for poll ${pollMsgId} is not a Buffer. Item:`, voteBuffer); - return null; - } - }).filter(hash => hash !== null); - } else { - console.log(`Poll update for ${pollMsgId} (voter: ${voterJid}) did not contain a valid 'votes' array or it's empty. Current votes data:`, pollUpdate.votes); - } - // --- නිවැරදි කිරීම අවසන් --- - - // Recalculate entire poll results based on all stored voter responses for this poll - // This is more robust for handling vote changes and ensuring count accuracy. - - // 1. Update this voter's current selection - if (selectedOptionHashes.length > 0) { - poll.voters[voterJid] = selectedOptionHashes; // Store/update this voter's current selection - } else { - // If selectedOptionHashes is empty, it means the voter deselected all their options (if possible) - // or the update didn't contain votes. We might remove their entry or handle as no vote. - delete poll.voters[voterJid]; // Voter retracted their vote(s) - console.log(`Voter ${voterJid} retracted votes for poll ${pollMsgId}`); - } - - // 2. Recalculate all results for the poll - // Reset current results to 0 - for (const optionText in poll.results) { - poll.results[optionText] = 0; - } - - // Iterate through all stored voters and their selections - for (const singleVoterJid in poll.voters) { - const voterSelections = poll.voters[singleVoterJid]; // This is an array of hashes - if (Array.isArray(voterSelections)) { - voterSelections.forEach(hash => { - const optionText = poll.optionHashes[hash]; - if (optionText && poll.results.hasOwnProperty(optionText)) { - poll.results[optionText]++; - } - }); - } - } - // --- End of recalculation logic --- - - console.log(`Updated poll results for ${pollMsgId}:`, poll.results); - console.log(`Voters for ${pollMsgId}:`, poll.voters) - io.emit('poll_update_to_gui', { - pollMsgId: pollMsgId, - results: poll.results, - question: poll.question, - options: poll.options, // Pass original options array - voters: poll.voters, // Pass updated voters object - selectableCount: poll.selectableCount // Pass selectableCount for context - }); - - } else { - console.warn(`Received poll update for an unknown or inactive poll ID: ${pollMsgId}. Active polls:`, Object.keys(activePolls)); - } - } - }); -} - -connectToWhatsApp(); - -io.on('connection', (socket) => { - console.log('GUI connected via Socket.IO:', socket.id); - socket.emit('client_status', clientReady ? 'ready' : (qrCodeData ? 'qr_pending' : 'disconnected')); - if (clientReady && sock.user) socket.emit('whatsapp_user', sock.user); - if (qrCodeData) socket.emit('qr_code', qrCodeData); - socket.emit('initial_poll_data', activePolls); // Send all current poll data -}); - -app.get('/status', (req, res) => res.json({ status: clientReady ? 'ready' : (qrCodeData ? 'qr_pending' : 'disconnected'), qrCode: qrCodeData, user: clientReady && sock ? sock.user : null })); - -app.post('/send-poll', async (req, res) => { - if (!clientReady || !sock) return res.status(400).json({ success: false, message: 'Baileys client not ready.' }); - - const { chatId, question, options, allowMultipleAnswers } = req.body; - - if (!chatId || !question || !options || !Array.isArray(options) || options.length < 1) { - return res.status(400).json({ success: false, message: 'chatId, question, and at least one option required.' }); - } - if (options.length > 12) { - return res.status(400).json({ success: false, message: 'Maximum of 12 poll options allowed.' }); - } - - try { - // await delay(500 + Math.random() * 1000); // Optional delay - - const pollMessagePayload = { - name: question, - values: options, - selectableCount: allowMultipleAnswers ? 0 : 1, - }; - - const sentMsg = await sock.sendMessage(chatId, { poll: pollMessagePayload }); - const pollMsgId = sentMsg.key.id; - - const optionHashes = {}; - const initialResults = {}; - options.forEach(opt => { - const hash = generateOptionSha256(opt); // Use the same hash function - optionHashes[hash] = opt; - initialResults[opt] = 0; - }); - - activePolls[pollMsgId] = { - question: question, - options: options, // Store original option strings - optionHashes: optionHashes, // Store mapping from hash to option string - results: initialResults, // Store results by option string - voters: {}, // Store votes by voter JID -> array of selected hashes - chatId: chatId, - timestamp: typeof sentMsg.messageTimestamp === 'number' ? sentMsg.messageTimestamp * 1000 : Date.now(), // Ensure JS timestamp - selectableCount: pollMessagePayload.selectableCount, - // messageDetails: sentMsg // Optional: store full sent message - }; - - console.log(`Poll sent successfully to ${chatId}, Msg ID: ${pollMsgId}`); - console.log("Active Polls now:", activePolls); - // Emit the newly created poll data for GUI to update its list - io.emit('new_poll_sent', { pollMsgId: pollMsgId, pollData: activePolls[pollMsgId] }); - res.json({ success: true, message: 'Poll sent successfully!', pollMsgId: pollMsgId }); - - } catch (error) { - console.error('Error sending poll:', error); - res.status(500).json({ success: false, message: 'Failed to send poll.', error: error.message }); - } -}); - -app.get('/get-chats', async (req, res) => { - if (!clientReady || !sock) { - return res.status(400).json({ success: false, message: 'Baileys WhatsApp client is not ready.' }); - } - try { - const simplifiedChats = []; - const groups = await sock.groupFetchAllParticipating(); - for (const [jid, group] of Object.entries(groups)) { - if (group.subject) { - simplifiedChats.push({ id: jid, name: group.subject, isGroup: true }); - } - } - // sock.contacts might not be populated immediately or in all Baileys versions by default - // It's better to rely on specific functions if needed, or ensure it's populated - // For now, this might return an empty list or be unreliable. - // Consider using sock.getContacts() or similar if you need a full contact list. - - simplifiedChats.sort((a, b) => (a.name || "").localeCompare(b.name || "")); - res.json({ success: true, chats: simplifiedChats }); - } catch (error) { - console.error('Error fetching chats:', error); - res.status(500).json({ success: false, message: 'Failed to fetch chats.', error: error.message }); - } -}); - -app.post('/logout', async (req, res) => { - console.log('Received logout request.'); - if (sock) { - try { - await sock.logout(); // This logs out from WhatsApp Web - console.log('Baileys client logged out successfully from WhatsApp.'); - } catch (error) { - console.error('Error during Baileys logout from WhatsApp:', error); - } finally { - // Clean up local session state - if (sock && typeof sock.end === 'function') { - sock.end(new Error('Logged out by user request')); // Properly close the socket connection - } - const sessionPath = path.join(__dirname, 'baileys_auth_info'); - try { - await fs.rm(sessionPath, { recursive: true, force: true }); - console.log('Session folder "baileys_auth_info" deleted.'); - } catch (err) { - console.error('Error deleting session folder:', err.code === 'ENOENT' ? 'Session folder not found.' : err); - } - clientReady = false; - qrCodeData = null; - activePolls = {}; // Clear active polls on logout - sock = undefined; // Clear the sock variable - - io.emit('client_status', 'disconnected'); - io.emit('initial_poll_data', activePolls); // Send empty polls - res.json({ success: true, message: 'Logged out and local session cleared. Please restart the server to connect a new account.' }); - } - } else { - // Also clear local session if sock is somehow undefined but user wants to "logout" - const sessionPath = path.join(__dirname, 'baileys_auth_info'); - try { - await fs.rm(sessionPath, { recursive: true, force: true }); - console.log('Session folder "baileys_auth_info" deleted (sock was undefined).'); - } catch (err) { - console.error('Error deleting session folder (sock was undefined):', err.code === 'ENOENT' ? 'Session folder not found.' : err); - } - clientReady = false; qrCodeData = null; activePolls = {}; - io.emit('client_status', 'disconnected'); io.emit('initial_poll_data', activePolls); - res.status(400).json({ success: false, message: 'Client was not active, but attempted to clear session.' }); - } -}); - -app.get('/get-all-poll-data', (req, res) => { - res.json({ success: true, polls: activePolls }); -}); - -server.listen(PORT, () => { - console.log(`Node.js server (Poll Focus) listening on port ${PORT}`); -}); +// server.js +const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, delay, jidNormalizedUser, getAggregateVotesInPollMessage, proto } = require('@whiskeysockets/baileys'); // Added getAggregateVotesInPollMessage and proto +const { Boom } = require('@hapi/boom'); +const express = require('express'); +const http = require('http'); +const { Server } = require('socket.io'); +const pino = require('pino'); +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); + +const POLL_STORAGE_FILE = path.join(__dirname, 'active_polls_storage.json'); + +const app = express(); +const server = http.createServer(app); +const io = new Server(server, { + cors: { origin: "*", methods: ["GET", "POST"] } +}); +const PORT = 3000; +app.use(express.json()); + +let sock; +let clientReady = false; +let qrCodeData = null; + +let activePolls = {}; // Store for polls sent in the current session + +async function saveActivePolls() { + try { + await fs.writeFile(POLL_STORAGE_FILE, JSON.stringify(activePolls, null, 2)); + console.log('Active polls saved to storage.'); + } catch (error) { + console.error('Error saving active polls:', error); + } +} + +async function loadActivePolls() { + try { + if (await fs.stat(POLL_STORAGE_FILE).then(() => true).catch(() => false)) { + const data = await fs.readFile(POLL_STORAGE_FILE, 'utf-8'); + activePolls = JSON.parse(data); + console.log('Active polls loaded from storage.'); + } else { + console.log('No active polls storage file found. Starting fresh.'); + activePolls = {}; + } + } catch (error) { + console.error('Error loading active polls:', error); + activePolls = {}; // Reset on error to prevent issues + } +} + +function generateOptionSha256(optionText) { + return crypto.createHash('sha256').update(Buffer.from(optionText)).digest('hex'); +} + +async function connectToWhatsApp() { + await loadActivePolls(); // Load polls before starting connection + console.log('Initializing Baileys WhatsApp Client (Poll Focus)...'); + const { state, saveCreds } = await useMultiFileAuthState('baileys_auth_info'); + const { version, isLatest } = await fetchLatestBaileysVersion(); + console.log(`using Baileys version ${version.join('.')}`); + + sock = makeWASocket({ + auth: state, + printQRInTerminal: true, // QR code එක terminal එකේ පෙන්වයි + browser: ['WhatsApp Poll Enhanced', 'Chrome', '1.0'], + logger: pino({ level: 'debug' }) // DEBUG level to see more logs + }); + + sock.ev.on('connection.update', async (update) => { + const { connection, lastDisconnect, qr } = update; + if (connection === 'open') { + console.log('Baileys WhatsApp Client is ready! (Poll Focus)'); + clientReady = true; + qrCodeData = null; + io.emit('client_status', 'ready'); + io.emit('whatsapp_user', sock.user); // Send user info + } else if (connection === 'close') { + clientReady = false; + qrCodeData = null; // Clear QR on close + const shouldReconnect = (lastDisconnect?.error instanceof Boom)?.output?.statusCode !== DisconnectReason.loggedOut; + console.log('Connection closed due to ', lastDisconnect?.error, ', reconnecting ', shouldReconnect); + io.emit('client_status', 'disconnected'); + if (shouldReconnect) { + connectToWhatsApp(); + } else { + console.log('Logged out, not reconnecting. Please delete baileys_auth_info and restart.'); + // Optionally, inform GUI about permanent logout + io.emit('client_status', 'logged_out'); + } + } + if (qr) { + qrCodeData = qr; + io.emit('qr_code', qr); + io.emit('client_status', 'qr_pending'); + console.log('QR code generated. Scan it.'); + } + }); + + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('messages.upsert', async ({ messages, type }) => { + if (type !== 'notify') return; + + const msg = messages[0]; + if (!msg.message) return; // Ignore if message content is empty + + // console.log('Received message:', JSON.stringify(msg, undefined, 2)); // Detailed log for incoming messages + + if (msg.message.pollUpdateMessage) { + const pollUpdate = msg.message.pollUpdateMessage; + const originalPollMsgKey = pollUpdate.pollCreationMessageKey; + // voterJid can be from msg.key.participant (group) or msg.key.remoteJid (DM, if direct poll update) + // However, poll updates in groups are usually from the group jid with a participant field inside msg. + const voterJid = msg.key.participant || msg.participant || msg.key.remoteJid; + + + if (!originalPollMsgKey || !originalPollMsgKey.id) { + console.warn("Poll update received without original poll message key ID. Skipping. Details:", JSON.stringify(originalPollMsgKey)); + return; + } + const pollMsgId = originalPollMsgKey.id; + + console.log(`Poll Update for Poll ID: ${pollMsgId} from Voter: ${voterJid}`); + // console.log('Poll Update Raw Details:', JSON.stringify(pollUpdate, undefined, 2)); + + if (activePolls[pollMsgId]) { + const poll = activePolls[pollMsgId]; + let selectedOptionHashes = []; + + // --- TypeError නිවැරදි කිරීම මෙතන --- + if (pollUpdate.votes && Array.isArray(pollUpdate.votes)) { + selectedOptionHashes = pollUpdate.votes.map(voteBuffer => { + if (Buffer.isBuffer(voteBuffer)) { + return voteBuffer.toString('hex'); + } else { + console.warn(`Item in pollUpdate.votes for poll ${pollMsgId} is not a Buffer. Item:`, voteBuffer); + return null; + } + }).filter(hash => hash !== null); + } else { + console.log(`Poll update for ${pollMsgId} (voter: ${voterJid}) did not contain a valid 'votes' array or it's empty. Current votes data:`, pollUpdate.votes); + } + // --- නිවැරදි කිරීම අවසන් --- + + // Recalculate entire poll results based on all stored voter responses for this poll + // This is more robust for handling vote changes and ensuring count accuracy. + + // 1. Update this voter's current selection + if (selectedOptionHashes.length > 0) { + poll.voters[voterJid] = selectedOptionHashes; // Store/update this voter's current selection + } else { + // If selectedOptionHashes is empty, it means the voter deselected all their options (if possible) + // or the update didn't contain votes. We might remove their entry or handle as no vote. + delete poll.voters[voterJid]; // Voter retracted their vote(s) + console.log(`Voter ${voterJid} retracted votes for poll ${pollMsgId}`); + } + + // 2. Recalculate all results for the poll + // Reset current results to 0 + for (const optionText in poll.results) { + poll.results[optionText] = 0; + } + + // Iterate through all stored voters and their selections + for (const singleVoterJid in poll.voters) { + const voterSelections = poll.voters[singleVoterJid]; // This is an array of hashes + if (Array.isArray(voterSelections)) { + voterSelections.forEach(hash => { + const optionText = poll.optionHashes[hash]; + if (optionText && poll.results.hasOwnProperty(optionText)) { + poll.results[optionText]++; + } + }); + } + } + // --- End of recalculation logic --- + + console.log(`Updated poll results for ${pollMsgId}:`, poll.results); + console.log(`Voters for ${pollMsgId}:`, poll.voters) + io.emit('poll_update_to_gui', { + pollMsgId: pollMsgId, + results: poll.results, + question: poll.question, + options: poll.options, // Pass original options array + voters: poll.voters, // Pass updated voters object + selectableCount: poll.selectableCount // Pass selectableCount for context + }); + await saveActivePolls(); // Save polls after update + } else { + console.warn(`Received poll update for an unknown or inactive poll ID: ${pollMsgId}. Active polls:`, Object.keys(activePolls)); + } + } + }); +} + +connectToWhatsApp(); + +io.on('connection', (socket) => { + console.log('GUI connected via Socket.IO:', socket.id); + socket.emit('client_status', clientReady ? 'ready' : (qrCodeData ? 'qr_pending' : 'disconnected')); + if (clientReady && sock.user) socket.emit('whatsapp_user', sock.user); + if (qrCodeData) socket.emit('qr_code', qrCodeData); + socket.emit('initial_poll_data', activePolls); // Send all current poll data +}); + +app.get('/status', (req, res) => res.json({ status: clientReady ? 'ready' : (qrCodeData ? 'qr_pending' : 'disconnected'), qrCode: qrCodeData, user: clientReady && sock ? sock.user : null })); + +app.post('/send-poll', async (req, res) => { + if (!clientReady || !sock) return res.status(400).json({ success: false, message: 'Baileys client not ready.' }); + + const { chatId, question, options, allowMultipleAnswers } = req.body; + + if (!chatId || !question || !options || !Array.isArray(options) || options.length < 1) { + return res.status(400).json({ success: false, message: 'chatId, question, and at least one option required.' }); + } + if (options.length > 12) { + return res.status(400).json({ success: false, message: 'Maximum of 12 poll options allowed.' }); + } + + try { + // await delay(500 + Math.random() * 1000); // Optional delay + + const pollMessagePayload = { + name: question, + values: options, + selectableCount: allowMultipleAnswers ? 0 : 1, + }; + + const sentMsg = await sock.sendMessage(chatId, { poll: pollMessagePayload }); + const pollMsgId = sentMsg.key.id; + + const optionHashes = {}; + const initialResults = {}; + options.forEach(opt => { + const hash = generateOptionSha256(opt); // Use the same hash function + optionHashes[hash] = opt; + initialResults[opt] = 0; + }); + + activePolls[pollMsgId] = { + question: question, + options: options, // Store original option strings + optionHashes: optionHashes, // Store mapping from hash to option string + results: initialResults, // Store results by option string + voters: {}, // Store votes by voter JID -> array of selected hashes + chatId: chatId, + timestamp: typeof sentMsg.messageTimestamp === 'number' ? sentMsg.messageTimestamp * 1000 : Date.now(), // Ensure JS timestamp + selectableCount: pollMessagePayload.selectableCount, + // messageDetails: sentMsg // Optional: store full sent message + }; + + console.log(`Poll sent successfully to ${chatId}, Msg ID: ${pollMsgId}`); + console.log("Active Polls now:", activePolls); + // Emit the newly created poll data for GUI to update its list + io.emit('new_poll_sent', { pollMsgId: pollMsgId, pollData: activePolls[pollMsgId] }); + await saveActivePolls(); // Save polls after sending a new one + res.json({ success: true, message: 'Poll sent successfully!', pollMsgId: pollMsgId }); + + } catch (error) { + console.error('Error sending poll:', error); + res.status(500).json({ success: false, message: 'Failed to send poll.', error: error.message }); + } +}); + +app.get('/get-chats', async (req, res) => { + if (!clientReady || !sock) { + return res.status(400).json({ success: false, message: 'Baileys WhatsApp client is not ready.' }); + } + try { + const simplifiedChats = []; + const groups = await sock.groupFetchAllParticipating(); + for (const [jid, group] of Object.entries(groups)) { + if (group.subject) { + simplifiedChats.push({ id: jid, name: group.subject, isGroup: true }); + } + } + // sock.contacts might not be populated immediately or in all Baileys versions by default + // It's better to rely on specific functions if needed, or ensure it's populated + // For now, this might return an empty list or be unreliable. + // Consider using sock.getContacts() or similar if you need a full contact list. + + simplifiedChats.sort((a, b) => (a.name || "").localeCompare(b.name || "")); + res.json({ success: true, chats: simplifiedChats }); + } catch (error) { + console.error('Error fetching chats:', error); + res.status(500).json({ success: false, message: 'Failed to fetch chats.', error: error.message }); + } +}); + +app.post('/logout', async (req, res) => { + console.log('Received logout request.'); + if (sock) { + try { + await sock.logout(); // This logs out from WhatsApp Web + console.log('Baileys client logged out successfully from WhatsApp.'); + } catch (error) { + console.error('Error during Baileys logout from WhatsApp:', error); + } finally { + // Clean up local session state + if (sock && typeof sock.end === 'function') { + sock.end(new Error('Logged out by user request')); // Properly close the socket connection + } + const sessionPath = path.join(__dirname, 'baileys_auth_info'); + try { + await fs.rm(sessionPath, { recursive: true, force: true }); + console.log('Session folder "baileys_auth_info" deleted.'); + } catch (err) { + console.error('Error deleting session folder:', err.code === 'ENOENT' ? 'Session folder not found.' : err); + } + clientReady = false; + qrCodeData = null; + activePolls = {}; // Clear active polls on logout + await saveActivePolls(); // Save the cleared state + sock = undefined; // Clear the sock variable + + io.emit('client_status', 'disconnected'); + io.emit('initial_poll_data', activePolls); // Send empty polls + res.json({ success: true, message: 'Logged out and local session cleared. Please restart the server to connect a new account.' }); + } + } else { + // Also clear local session if sock is somehow undefined but user wants to "logout" + const sessionPath = path.join(__dirname, 'baileys_auth_info'); + try { + await fs.rm(sessionPath, { recursive: true, force: true }); + console.log('Session folder "baileys_auth_info" deleted (sock was undefined).'); + } catch (err) { + console.error('Error deleting session folder (sock was undefined):', err.code === 'ENOENT' ? 'Session folder not found.' : err); + } + clientReady = false; qrCodeData = null; activePolls = {}; + await saveActivePolls(); // Save the cleared state even if sock was undefined + io.emit('client_status', 'disconnected'); io.emit('initial_poll_data', activePolls); + res.status(400).json({ success: false, message: 'Client was not active, but attempted to clear session.' }); + } +}); + +app.get('/get-all-poll-data', (req, res) => { + res.json({ success: true, polls: activePolls }); +}); + +server.listen(PORT, () => { + console.log(`Node.js server (Poll Focus) listening on port ${PORT}`); +}); diff --git a/frontend_python/app.py b/frontend_python/app.py index 1e6f7c1..1a1fd6b 100644 --- a/frontend_python/app.py +++ b/frontend_python/app.py @@ -1,856 +1,987 @@ -import tkinter as tk -from tkinter import messagebox, scrolledtext, ttk, simpledialog -from PIL import Image, ImageTk # Ensure Pillow is available -import requests -import socketio -import threading -import time -import random # For anti-ban delay -import json -import os -import qrcode # For QR code generation - -# --- Configuration --- -NODE_SERVER_URL = "http://localhost:3000" -NODE_API_STATUS = f"{NODE_SERVER_URL}/status" -# NODE_API_QR = f"{NODE_SERVER_URL}/qr" # Not used if QR comes via socket -NODE_API_SEND_POLL = f"{NODE_SERVER_URL}/send-poll" -NODE_API_GET_CHATS = f"{NODE_SERVER_URL}/get-chats" -NODE_API_LOGOUT = f"{NODE_SERVER_URL}/logout" -NODE_API_GET_ALL_POLL_DATA = f"{NODE_SERVER_URL}/get-all-poll-data" - -TEMPLATES_FILE = "poll_templates.json" - -# --- Global Variables --- -sio_connected = False -chat_mapping = {} -active_polls_data_from_server = {} -whatsapp_client_actually_ready = False # අලුතින් එකතු කළ flag එක -sio_connected = False -chat_mapping = {} # Stores display_name -> chat_id -active_polls_data_from_server = {} # Stores {poll_msg_id: poll_data_object} - -# --- Socket.IO Client --- -sio = socketio.Client(reconnection_attempts=10, reconnection_delay=3, logger=False, engineio_logger=False) # Added logger flags - -@sio.event -def connect(): - global sio_connected - sio_connected = True - print('Socket.IO connected!') - if 'status_label' in globals() and status_label.winfo_exists(): - update_status_label("Socket.IO Connected. Checking WhatsApp...", "blue") - check_whatsapp_status() # Check WhatsApp status once socket is up - -@sio.event -def connect_error(data): - global sio_connected - sio_connected = False - print(f"Socket.IO connection failed: {data}") - if 'status_label' in globals() and status_label.winfo_exists(): - update_status_label(f"Socket.IO Connection Error. Retrying...", "red") - -@sio.event -def disconnect(): - global sio_connected - sio_connected = False - print('Socket.IO disconnected.') - if 'status_label' in globals() and status_label.winfo_exists(): - update_status_label("Socket.IO Disconnected. Retrying connection...", "orange") - if 'qr_display_label' in globals() and qr_display_label.winfo_exists(): - qr_display_label.config(image='', text="QR Code (Disconnected)") - # Do not clear chat/poll list on temporary socket disconnect if WA might still be connected - -@sio.event -def qr_code(qr_data_from_socket): # Renamed to avoid conflict with qrcode module - print(f"Received QR Code via Socket.IO.") - if 'qr_display_label' in globals() and qr_display_label.winfo_exists(): - display_qr_code(qr_data_from_socket) # Use the received data - update_status_label("QR Code Received. Please scan.", "#DBA800") # Dark yellow - if 'notebook' in globals() and 'connection_tab' in globals(): - notebook.select(connection_tab) - -@sio.event -def client_status(status): # Server emits 'client_status' - global whatsapp_client_actually_ready # Global විදියට declare කරන්න - print(f"WhatsApp Client Status from Socket.IO: {status}") - if 'status_label' in globals() and status_label.winfo_exists(): - if status == 'ready': - whatsapp_client_actually_ready = True # Flag එක True කරන්න - update_status_label("WhatsApp Client is READY!", "green") - if 'qr_display_label' in globals() and qr_display_label.winfo_exists(): - qr_display_label.config(image='', text="WhatsApp Client READY!") - fetch_chats() - fetch_all_poll_data_from_server() - elif status == 'qr_pending': - whatsapp_client_actually_ready = False # Flag එක False කරන්න - update_status_label("Waiting for QR scan (check Connection Tab)...", "orange") - elif status == 'logged_out': - whatsapp_client_actually_ready = False # Flag එක False කරන්න - update_status_label(f"WhatsApp: Logged Out. Delete 'baileys_auth_info' & restart Node server to connect new.", "red") - clear_session_gui_elements() - elif status in ['disconnected', 'auth_failure']: - whatsapp_client_actually_ready = False # Flag එක False කරන්න - update_status_label(f"WhatsApp: {status}. Please connect/reconnect.", "red") - # clear_session_gui_elements() # තාවකාලිකව disconnect වෙනකොට clear කරන එක ගැන සැලකිලිමත් වෙන්න - -def clear_session_gui_elements(): - global active_polls_data_from_server, whatsapp_client_actually_ready # Flag එක මෙතනටත් add කරන්න - whatsapp_client_actually_ready = False # Logout/clear වලදී False කරන්න - global active_polls_data_from_server - active_polls_data_from_server = {} - if 'qr_display_label' in globals() and qr_display_label.winfo_exists(): qr_display_label.config(image='', text="QR Code (Logged Out)") - if 'poll_chat_listbox' in globals() and poll_chat_listbox.winfo_exists(): poll_chat_listbox.delete(0, tk.END) - if 'poll_results_listbox' in globals() and poll_results_listbox.winfo_exists(): poll_results_listbox.delete(0, tk.END) - if 'poll_results_label' in globals() and poll_results_label.winfo_exists(): - poll_results_label.config(state=tk.NORMAL) - poll_results_label.delete('1.0', tk.END) - poll_results_label.insert('1.0', "Logged out. Select a poll after reconnecting.") - poll_results_label.config(state=tk.DISABLED) - - -@sio.event -def whatsapp_user(user_data): # If server sends user info - if user_data and user_data.get('id'): - print(f"Connected as: {user_data.get('name') or user_data.get('id')}") - # Optionally display this info in the GUI - -@sio.event -def poll_update_to_gui(data): - global active_polls_data_from_server - print(f"GUI received poll_update_to_gui: {data}") - poll_msg_id = data.get('pollMsgId') - - if poll_msg_id: - # Update or add the poll data - if poll_msg_id not in active_polls_data_from_server: # If it's a new poll not initiated by this GUI - active_polls_data_from_server[poll_msg_id] = { - 'question': data.get('question', 'Unknown Question'), - 'options': data.get('options', []), - 'results': data.get('results', {}), - 'voters': data.get('voters', {}), - 'timestamp': data.get('timestamp', time.time()*1000), # Fallback timestamp - 'selectableCount': data.get('selectableCount', 1) - } - populate_poll_results_listbox() # New poll, refresh the list - else: # Existing poll, just update results and voters - active_polls_data_from_server[poll_msg_id]['results'] = data.get('results', {}) - active_polls_data_from_server[poll_msg_id]['voters'] = data.get('voters', {}) - - - # If this poll is currently selected in the results tab, refresh its display - if 'poll_results_listbox' in globals() and poll_results_listbox.winfo_exists(): - try: - selected_indices = poll_results_listbox.curselection() - if selected_indices: - selected_poll_display_text = poll_results_listbox.get(selected_indices[0]) - # Extract msg_id from "Question (ID: ...msg_id_suffix)" - if f"(ID: ...{poll_msg_id[-6:]})" in selected_poll_display_text: - display_selected_poll_results() # Refresh display - except Exception as e: - print(f"Error updating selected poll display from poll_update_to_gui: {e}") - update_status_label(f"Poll '{active_polls_data_from_server.get(poll_msg_id, {}).get('question', poll_msg_id)}' updated!", "cyan") - -@sio.event -def new_poll_sent(data): # Server sends { pollMsgId: 'xyz', pollData: {...} } - global active_polls_data_from_server - print(f"GUI received new_poll_sent: {data}") - poll_msg_id = data.get('pollMsgId') - poll_data_obj = data.get('pollData') - if poll_msg_id and poll_data_obj: - active_polls_data_from_server[poll_msg_id] = poll_data_obj - populate_poll_results_listbox() # Refresh the listbox with the new poll - update_status_label(f"New poll '{poll_data_obj.get('question', 'N/A')}' added to results tab.", "magenta") - else: - # Fallback if data structure is different, refetch all - fetch_all_poll_data_from_server() - - -@sio.event -def initial_poll_data(data): # When GUI connects, server sends all current poll data - global active_polls_data_from_server - print("GUI received initial_poll_data") - active_polls_data_from_server = data if isinstance(data, dict) else {} # Ensure it's a dict - populate_poll_results_listbox() - # --- නිවැරදි කළ පේළිය --- - update_status_label(f"Loaded {len(active_polls_data_from_server)} existing polls.", "blue") # "info" වෙනුවට "blue" - -# --- GUI Functions --- -def update_status_label(message, color_name="blue"): # Standardized color_name - if 'status_label' in globals() and status_label.winfo_exists(): - try: - status_label.config(text=f"Status: {message}", fg=color_name) - if 'root' in globals() and root.winfo_exists(): root.update_idletasks() - except tk.TclError as e: - print(f"Error setting color '{color_name}': {e}. Using default.") - status_label.config(text=f"Status: {message}", fg="black") - - -def check_whatsapp_status(): - if 'status_label' not in globals() or not status_label.winfo_exists(): return - update_status_label("Checking WhatsApp status via HTTP...", "blue") - try: - response = requests.get(NODE_API_STATUS, timeout=3) # Shorter timeout - response.raise_for_status() - data = response.json() - api_status = data.get('status') - api_qr = data.get('qrCode') - # This HTTP check is a fallback; primary updates should come via Socket.IO client_status event - if api_status == 'ready': - # client_status('ready') # Let socket event handle this primarily - if not sio_connected: update_status_label("HTTP: WA Ready (Socket disconnected)", "orange") - else: update_status_label("HTTP: WA Ready (Socket connected)", "green") - elif api_status == 'qr_pending' and api_qr: - # client_status('qr_pending') # Let socket event handle this - # display_qr_code(api_qr) - if not sio_connected: update_status_label("HTTP: WA QR Pending (Socket disconnected)", "orange") - - elif api_status == 'disconnected': - # client_status('disconnected') - if not sio_connected: update_status_label("HTTP: WA Disconnected (Socket disconnected)", "red") - - except requests.exceptions.RequestException as e: - update_status_label(f"Node server check failed: {type(e).__name__}", "red") - print(f"HTTP status check failed: {e}") - - -def display_qr_code(qr_data_str): - if 'qr_display_label' not in globals() or not qr_display_label.winfo_exists(): return - try: - # qrcode library is already imported at the top - img = qrcode.make(qr_data_str) - # Ensure it's a PIL Image object for resize - if not hasattr(img, 'resize'): # If qrcode.make returns something else - qr_code_obj = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L) - qr_code_obj.add_data(qr_data_str) - qr_code_obj.make(fit=True) - img = qr_code_obj.make_image(fill_color="black", back_color="white").convert('RGB') - - img_resized = img.resize((250, 250), Image.Resampling.LANCZOS) - photo = ImageTk.PhotoImage(img_resized) - qr_display_label.config(image=photo, text="") - qr_display_label.image = photo # Keep a reference! - except Exception as e: - update_status_label(f"Error displaying QR: {e}", "red") - qr_display_label.config(image='', text=f"QR Display Error: {e}") - print(f"QR display error: {e}") - - -def fetch_chats(): - global chat_mapping - if 'status_label' not in globals() or not status_label.winfo_exists(): return - if not client_is_ready(): # Helper function to check actual WA readiness - update_status_label("WhatsApp not ready. Cannot fetch chats.", "orange") - return - - update_status_label("Fetching chats...", "blue") - try: - response = requests.get(NODE_API_GET_CHATS, timeout=10) - response.raise_for_status() - data = response.json() - if data.get('success'): - listboxes_to_update = [] - if 'poll_chat_listbox' in globals() and poll_chat_listbox.winfo_exists(): - listboxes_to_update.append(poll_chat_listbox) - - for lb in listboxes_to_update: lb.delete(0, tk.END) - chat_mapping.clear() - fetched_chats_count = 0 - if 'chats' in data and data['chats'] is not None: - for chat in data['chats']: - display_name = f"{chat.get('name', 'Unknown Name')} ({'Group' if chat.get('isGroup') else 'Contact'})" - chat_id_val = chat.get('id') - if chat_id_val: # Ensure chat_id is not None or empty - chat_mapping[display_name] = chat_id_val - for lb in listboxes_to_update: lb.insert(tk.END, display_name) - fetched_chats_count +=1 - update_status_label(f"Fetched {fetched_chats_count} chats.", "green") - else: - update_status_label(f"Failed to fetch chats: {data.get('message', 'No message')}", "red") - except requests.exceptions.RequestException as e: - update_status_label(f"Error fetching chats (HTTP): {e}", "red") - print(f"Fetch chats error: {e}") - except Exception as e: # Catch other potential errors - update_status_label(f"Unexpected error fetching chats: {e}", "red") - print(f"Unexpected fetch chats error: {e}") - -def client_is_ready(): # Helper - global whatsapp_client_actually_ready - # print(f"Debug: client_is_ready() called. Flag is: {whatsapp_client_actually_ready}") # Debugging සඳහා - return whatsapp_client_actually_ready - -# --- Poll Sender Functions --- -def send_poll_message(): - if 'poll_question_entry' not in globals(): return - if not client_is_ready(): - messagebox.showerror("Error", "WhatsApp client is not ready to send polls.") - return - - question = poll_question_entry.get().strip() - options = [opt.strip() for opt in poll_options_listbox.get(0, tk.END) if opt.strip()] # Ensure no empty options - selected_indices = poll_chat_listbox.curselection() - allow_multiple = allow_multiple_answers_var.get() - - if not question: messagebox.showerror("Error", "Poll question cannot be empty."); return - if not options or len(options) < 1: messagebox.showerror("Error", "Poll must have at least one option."); return - if len(options) > 12: messagebox.showerror("Error", "Maximum of 12 poll options allowed by WhatsApp."); return - if not selected_indices: messagebox.showerror("Error", "Please select at least one chat/group to send the poll to."); return - - selected_chat_display_names = [poll_chat_listbox.get(i) for i in selected_indices] - selected_chat_ids = [chat_mapping[name] for name in selected_chat_display_names if name in chat_mapping] - - if not selected_chat_ids: messagebox.showerror("Error", "No valid chats selected (ID mapping failed). Please refresh chats."); return - if not messagebox.askyesno("Confirm Poll Submission", f"Are you sure you want to send this poll to {len(selected_chat_ids)} selected chat(s)?"): return - - update_status_label(f"Initiating poll send to {len(selected_chat_ids)} chat(s)...", "blue") - # Non-blocking send using a thread - threading.Thread(target=_send_polls_threaded, args=(selected_chat_ids, question, options, allow_multiple), daemon=True).start() - -def _send_polls_threaded(chat_ids, question, options, allow_multiple_bool): - success_count = 0 - fail_count = 0 - for i, chat_id in enumerate(chat_ids): - current_status_msg = f"Sending poll ({i+1}/{len(chat_ids)}) to {chat_id}..." - root.after(0, update_status_label, current_status_msg, "cyan") # Update GUI from thread - try: - payload = { - "chatId": chat_id, - "question": question, - "options": options, - "allowMultipleAnswers": allow_multiple_bool - } - response = requests.post(NODE_API_SEND_POLL, json=payload, timeout=15) # Increased timeout slightly - response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) - result = response.json() - - if result.get('success'): - success_count += 1 - final_msg_for_chat = f"Poll sent to {chat_id} (ID: {result.get('pollMsgId', 'N/A')})" - root.after(0, update_status_label, final_msg_for_chat, "green") - else: - fail_count += 1 - final_msg_for_chat = f"Failed poll to {chat_id}: {result.get('message', 'Unknown error')}" - root.after(0, update_status_label, final_msg_for_chat, "red") - # Anti-ban delay - delay_s = random.uniform(anti_ban_delay_min.get(), anti_ban_delay_max.get()) - time.sleep(delay_s) - except requests.exceptions.HTTPError as httperr: - fail_count +=1 - err_msg = f"HTTP Error poll to {chat_id}: {httperr.response.status_code} - {httperr.response.text}" - root.after(0, update_status_label, err_msg, "red") - print(err_msg) - except requests.exceptions.RequestException as reqerr: # Timeout, ConnectionError etc. - fail_count += 1 - err_msg = f"Request Error poll to {chat_id}: {reqerr}" - root.after(0, update_status_label, err_msg, "red") - print(err_msg) - except Exception as e: # Other unexpected errors - fail_count +=1 - err_msg = f"Unexpected Error poll to {chat_id}: {e}" - root.after(0, update_status_label, err_msg, "red") - print(err_msg) - - final_summary = f"Poll sending finished. Success: {success_count}, Failed: {fail_count}." - root.after(0, update_status_label, final_summary, "blue" if fail_count == 0 else "orange") - - -def add_poll_option(): - option = poll_option_entry.get().strip() - if option: - current_options = poll_options_listbox.get(0, tk.END) - if option in current_options: - messagebox.showwarning("Duplicate Option", "This option already exists in the list.") - return - if len(current_options) >= 12: - messagebox.showwarning("Option Limit", "WhatsApp allows a maximum of 12 options per poll.") - return - poll_options_listbox.insert(tk.END, option) - poll_option_entry.delete(0, tk.END) - else: - messagebox.showinfo("Add Option", "Please enter a non-empty option text.") - -def edit_poll_option(): - selected_indices = poll_options_listbox.curselection() - if not selected_indices: - messagebox.showinfo("Edit Option", "Please select an option from the list to edit.") - return - idx = selected_indices[0] - current_val = poll_options_listbox.get(idx) - new_val = simpledialog.askstring("Edit Option", "Enter new text for the option:", initialvalue=current_val, parent=root) - if new_val is not None: # User provided input (could be empty string) - new_val_stripped = new_val.strip() - if not new_val_stripped: - messagebox.showwarning("Edit Option", "Option text cannot be empty.") - return - if new_val_stripped != current_val and new_val_stripped in poll_options_listbox.get(0, tk.END): - messagebox.showwarning("Edit Option", "This option text already exists in the list.") - return - poll_options_listbox.delete(idx) - poll_options_listbox.insert(idx, new_val_stripped) - -def delete_poll_option(): - selected_indices = poll_options_listbox.curselection() - if selected_indices: - poll_options_listbox.delete(selected_indices[0]) - else: - messagebox.showinfo("Delete Option", "Please select an option from the list to delete.") - -def clear_poll_options(): - poll_options_listbox.delete(0, tk.END) - -# --- Poll Template Management --- -def load_poll_templates(): - if os.path.exists(TEMPLATES_FILE): - try: - with open(TEMPLATES_FILE, 'r', encoding='utf-8') as f: - return json.load(f) - except json.JSONDecodeError: - messagebox.showerror("Template Error", f"Error decoding {TEMPLATES_FILE}. It might be corrupted.") - return {} - except Exception as e: - messagebox.showerror("Template Error", f"Error loading templates: {e}") - return {} - return {} - -def save_poll_templates(data): - try: - with open(TEMPLATES_FILE, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4, ensure_ascii=False) - except Exception as e: - messagebox.showerror("Template Error", f"Error saving templates: {e}") - -def update_poll_template_dropdown(): - if 'poll_template_combobox' not in globals() or not poll_template_combobox.winfo_exists(): return - templates = load_poll_templates() - names = list(templates.keys()) - poll_template_combobox['values'] = names - if names: - poll_template_combobox.current(0) # Select first item - else: - poll_template_combobox.set("") # Clear if no templates - -def save_current_poll_as_template(): - question_text = poll_question_entry.get().strip() - options_list = list(poll_options_listbox.get(0, tk.END)) - if not question_text and not options_list: # Allow saving even if only one is present - messagebox.showinfo("Save Template", "Please enter a poll question and/or options to save as a template.") - return - - template_name = simpledialog.askstring("Save Poll Template", "Enter a name for this template:", parent=root) - if template_name and template_name.strip(): - templates = load_poll_templates() - templates[template_name.strip()] = { - "question": question_text, - "options": "\n".join(options_list) # Store options as newline separated string - } - save_poll_templates(templates) - update_poll_template_dropdown() - messagebox.showinfo("Save Template", f"Poll template '{template_name.strip()}' saved successfully!") - elif template_name is not None: # User entered empty string - messagebox.showwarning("Save Template", "Template name cannot be empty.") - - -def load_selected_poll_template(event=None): # event is passed by combobox selection - selected_name = poll_template_combobox.get() - templates = load_poll_templates() - if selected_name in templates: - template_data = templates[selected_name] - poll_question_entry.delete(0, tk.END) - poll_question_entry.insert(0, template_data.get("question", "")) - - clear_poll_options() - options_str = template_data.get("options", "") - if isinstance(options_str, str): # Ensure it's a string - for opt in options_str.split('\n'): - if opt.strip(): # Add only non-empty options - poll_options_listbox.insert(tk.END, opt.strip()) - update_status_label(f"Poll template '{selected_name}' loaded.", "blue") - - -def delete_selected_poll_template(): - selected_name = poll_template_combobox.get() - if not selected_name: - messagebox.showinfo("Delete Template", "Please select a template from the dropdown to delete.") - return - - if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete the template '{selected_name}'?", parent=root): - templates = load_poll_templates() - if selected_name in templates: - del templates[selected_name] - save_poll_templates(templates) - update_poll_template_dropdown() # Refresh dropdown - poll_template_combobox.set('') # Clear selection - # Clear current poll fields if the deleted template was loaded - poll_question_entry.delete(0, tk.END) - clear_poll_options() - messagebox.showinfo("Delete Template", f"Poll template '{selected_name}' deleted successfully.") - else: - messagebox.showerror("Delete Template", "Selected template not found (it may have been already deleted).") - -# --- Poll Results Functions --- -def fetch_all_poll_data_from_server(): - global active_polls_data_from_server - # No need to check sio_connected here, as HTTP GET might work even if socket is temp down - update_status_label("Fetching all poll data via HTTP...", "blue") - try: - response = requests.get(NODE_API_GET_ALL_POLL_DATA, timeout=10) - response.raise_for_status() - data = response.json() - if data.get('success'): - active_polls_data_from_server = data.get('polls', {}) # Expects a dict - if not isinstance(active_polls_data_from_server, dict): # Basic type check - print("Warning: Poll data from server is not a dictionary. Resetting.") - active_polls_data_from_server = {} - populate_poll_results_listbox() - update_status_label(f"Fetched/Refreshed {len(active_polls_data_from_server)} polls.", "green") - else: - update_status_label(f"Failed to fetch poll data: {data.get('message', 'No error message')}", "red") - except requests.exceptions.RequestException as e: - update_status_label(f"Error fetching poll data (HTTP): {e}", "red") - print(f"Error fetching poll data: {e}") - except json.JSONDecodeError as je: - update_status_label(f"Error decoding poll data JSON: {je}", "red") - print(f"JSON Decode Error for poll data: {je}") - - -def populate_poll_results_listbox(): - if 'poll_results_listbox' not in globals() or not poll_results_listbox.winfo_exists(): return - poll_results_listbox.delete(0, tk.END) # Clear existing items - - if not active_polls_data_from_server: - poll_results_listbox.insert(tk.END, "No active polls found or fetched yet.") - return - - # Sort polls by timestamp (newest first) - # Ensure timestamp exists and is a number for sorting - sorted_poll_items = sorted( - active_polls_data_from_server.items(), - key=lambda item: item[1].get('timestamp', 0) if isinstance(item[1].get('timestamp'), (int, float)) else 0, - reverse=True - ) - - for poll_msg_id, poll_info in sorted_poll_items: - question = poll_info.get('question', 'Unnamed Poll') - # Use last 6 chars of ID for display, more readable - display_text = f"{question[:50]}{'...' if len(question) > 50 else ''} (ID: ...{poll_msg_id[-6:]})" - poll_results_listbox.insert(tk.END, display_text) - - -def display_selected_poll_results(event=None): # Bound to listbox selection - if 'poll_results_listbox' not in globals() or not poll_results_listbox.winfo_exists(): return - if 'poll_results_label' not in globals() or not poll_results_label.winfo_exists(): return - - selected_indices = poll_results_listbox.curselection() - - poll_results_label.config(state=tk.NORMAL) # Enable editing - poll_results_label.delete('1.0', tk.END) # Clear previous content - - if not selected_indices: - poll_results_label.insert('1.0', "Select a poll from the list above to see its results.") - poll_results_label.config(state=tk.DISABLED) - return - - selected_item_display_text = poll_results_listbox.get(selected_indices[0]) - actual_poll_msg_id = None - - # Robustly find the poll_msg_id based on the display text suffix - try: - if "(ID: ..." in selected_item_display_text and selected_item_display_text.endswith(")"): - id_suffix_with_ellipsis = selected_item_display_text.split('(ID: ...')[-1] - id_suffix = id_suffix_with_ellipsis[:-1] # Remove trailing ')' - for pid_key in active_polls_data_from_server.keys(): - if pid_key.endswith(id_suffix): - actual_poll_msg_id = pid_key - break - if not actual_poll_msg_id: - raise ValueError("Could not match listbox item to a poll ID.") - except Exception as e: - print(f"Error parsing poll ID from listbox item '{selected_item_display_text}': {e}") - poll_results_label.insert('1.0', f"Error finding poll data for: {selected_item_display_text}") - poll_results_label.config(state=tk.DISABLED) - return - - poll_info = active_polls_data_from_server.get(actual_poll_msg_id) - if not poll_info: - poll_results_label.insert('1.0', f"Poll data not found for ID: {actual_poll_msg_id}") - poll_results_label.config(state=tk.DISABLED) - return - - # Build the results string - results_str = f"Poll Question: {poll_info.get('question', 'N/A')}\n" - results_str += f"Message ID: {actual_poll_msg_id}\n" - ts = poll_info.get('timestamp') - results_str += f"Sent Timestamp: {ts} ({time.ctime(ts/1000) if isinstance(ts, (int, float)) and ts > 0 else 'N/A'})\n" - selectable_count = poll_info.get('selectableCount', 1) # Default to 1 if not present - results_str += f"Allows Multiple Answers: {'Yes (Any number)' if selectable_count == 0 else f'No (Single Choice, selectable: {selectable_count})'}\n" - results_str += "------------------------------------\nResults:\n" - - poll_option_results_map = poll_info.get('results', {}) # Keyed by option TEXT - original_options_list = poll_info.get('options', []) # List of option TEXTS - - total_votes_on_options = sum(poll_option_results_map.values()) - - # Display results based on the original option order - for opt_text in original_options_list: - votes_for_option = poll_option_results_map.get(opt_text, 0) - percentage = (votes_for_option / total_votes_on_options * 100) if total_votes_on_options > 0 else 0 - results_str += f" - \"{opt_text}\": {votes_for_option} votes ({percentage:.1f}%)\n" - - results_str += "------------------------------------\n" - voters_data = poll_info.get('voters', {}) # Keyed by voterJid, value is array of selected hashes - unique_voter_jids = list(voters_data.keys()) - results_str += f"Total Unique Voters Participated: {len(unique_voter_jids)}\n" - # total_individual_selections = sum(len(v_hashes) for v_hashes in voters_data.values()) # Sum of all selected hashes by all voters - # results_str += f"Total Individual Option Selections Made: {total_individual_selections}\n" - results_str += f"(Note: Total votes on options ({total_votes_on_options}) might differ from unique voters if multiple selections are allowed or votes changed.)\n" - - # Optionally display who voted for what (can be very long) - # if unique_voter_jids: - # results_str += "\nVoter Breakdown (JID -> Voted Option Text(s)):\n" - # option_hashes_to_text = poll_info.get('optionHashes', {}) # hash -> text - # for voter_jid, selected_hashes_arr in voters_data.items(): - # voted_texts = [option_hashes_to_text.get(h, f"UnknownHash:{h[:6]}") for h in selected_hashes_arr] - # results_str += f" - {voter_jid}: {', '.join(voted_texts)}\n" - - - poll_results_label.insert('1.0', results_str) - poll_results_label.config(state=tk.DISABLED) # Make read-only - - -# --- Logout Function --- -def logout_and_reconnect(): - if messagebox.askyesno("Logout & Connect New Account", - "This will log out the current WhatsApp account from the server " - "and clear the local 'baileys_auth_info' session folder on the server. " - "You will need to restart the Node.js server script manually " - "if you want it to pick up a new QR scan for a new account after this. " - "Continue?", parent=root): - update_status_label("Attempting logout...", "orange") - threading.Thread(target=_logout_threaded, daemon=True).start() - -def _logout_threaded(): - global active_polls_data_from_server, whatsapp_client_actually_ready # Flag එක මෙතනටත් add කරන්න - - # Logout උත්සාහය පටන් ගන්නකොටම Client එක not ready විදියට සලකන්න - root.after(0, lambda: globals().update(whatsapp_client_actually_ready=False)) - # GUI update එක main thread එකෙන් කරන්න root.after භාවිතා කරනවා - - try: - response = requests.post(NODE_API_LOGOUT, timeout=15) # Slightly longer timeout for logout - response.raise_for_status() - result = response.json() - if result.get('success'): - # Don't show messagebox from thread, update GUI via root.after or status_label - root.after(0, update_status_label, result.get('message', "Logout successful. Restart Node server for new QR."), "blue") - root.after(0, clear_session_gui_elements) # Clear GUI elements related to session - else: - err_msg = result.get('message', "Failed to logout from server.") - root.after(0, update_status_label, f"Logout Error: {err_msg}", "red") - root.after(0, messagebox.showerror, "Logout Error", err_msg, parent=root) - - except requests.exceptions.RequestException as e: - err_msg = f"Logout request error: {e}" - root.after(0, update_status_label, err_msg, "red") - root.after(0, messagebox.showerror, "Logout Error", err_msg, parent=root) - print(err_msg) - except Exception as e: # Catch any other unexpected error - err_msg = f"Unexpected error during logout: {e}" - root.after(0, update_status_label, err_msg, "red") - root.after(0, messagebox.showerror, "Logout Error", err_msg, parent=root) - print(err_msg) - - -# --- GUI Setup --- -root = tk.Tk() -root.title("WhatsApp Poll Master Deluxe") # New name! -root.geometry("950x800") # Slightly larger - -status_label = tk.Label(root, text="Status: Initializing GUI...", bd=1, relief=tk.SUNKEN, anchor=tk.W, font=("Segoe UI", 10)) -status_label.pack(side=tk.BOTTOM, fill=tk.X, ipady=3) - -main_frame = tk.Frame(root, padx=10, pady=10) -main_frame.pack(fill=tk.BOTH, expand=True) - -# Styling for ttk widgets -style = ttk.Style() -style.configure("TNotebook.Tab", font=("Segoe UI", 10, "bold"), padding=[10, 5]) -style.configure("TLabelFrame.Label", font=("Segoe UI", 10, "bold")) -style.configure("TButton", font=("Segoe UI", 9), padding=5) -style.configure("Bold.TButton", font=("Segoe UI", 10, "bold")) - - -notebook = ttk.Notebook(main_frame, style="TNotebook") -notebook.pack(fill=tk.BOTH, expand=True) - -# == Connection Tab == -connection_tab = ttk.Frame(notebook, padding=10) -notebook.add(connection_tab, text="📶 Connection") -connection_tab.columnconfigure(0, weight=1) -connection_tab.rowconfigure(1, weight=1) # QR Label should expand - -tk.Label(connection_tab, text="WhatsApp Web Connection", font=("Segoe UI", 14, "bold")).grid(row=0, column=0, pady=(0,10), sticky="ew") -qr_display_label = tk.Label(connection_tab, text="QR Code Area (Connecting...)", bg="lightgrey", relief=tk.GROOVE, height=15, width=40, font=("Courier New", 8)) -qr_display_label.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - -connection_button_frame = tk.Frame(connection_tab) -connection_button_frame.grid(row=2, column=0, pady=(10,0)) -#ttk.Button(connection_button_frame, text="🔄 Check Status / Connect", command=check_whatsapp_status, style="Bold.TButton").pack(side=tk.LEFT, padx=5) -ttk.Button(connection_button_frame, text="🔄 Refresh Chats", command=fetch_chats, style="Bold.TButton").pack(side=tk.LEFT, padx=5) -ttk.Button(connection_button_frame, text="🚪 Logout & Clear Session", command=logout_and_reconnect, style="Bold.TButton").pack(side=tk.LEFT, padx=5) - - -# == Poll Sender Tab == -poll_sender_tab = ttk.Frame(notebook, padding=10) -notebook.add(poll_sender_tab, text="📊 Poll Sender") - -# Poll Templates section -poll_template_frame = ttk.LabelFrame(poll_sender_tab, text="Poll Templates", padding=10) -poll_template_frame.pack(fill=tk.X, padx=5, pady=(5,10)) -poll_template_combobox = ttk.Combobox(poll_template_frame, state="readonly", width=40, font=("Segoe UI", 9)) -poll_template_combobox.pack(side=tk.LEFT, padx=(0,5), pady=5, ipady=2) -poll_template_combobox.bind("<>", load_selected_poll_template) -ptb_frame = tk.Frame(poll_template_frame) # Button frame for templates -ptb_frame.pack(side=tk.LEFT, padx=5) -ttk.Button(ptb_frame, text="💾 Save Current", command=save_current_poll_as_template).pack(side=tk.LEFT, padx=2) -ttk.Button(ptb_frame, text="🗑️ Delete Selected", command=delete_selected_poll_template).pack(side=tk.LEFT, padx=2) - - -# Chat/Group Selection -tk.Label(poll_sender_tab, text="Select Chats/Groups for Poll:", font=("Segoe UI", 9, "bold")).pack(pady=(5,2), anchor=tk.W, padx=5) -poll_chat_listbox_frame = tk.Frame(poll_sender_tab) -poll_chat_listbox_frame.pack(fill=tk.X, padx=5, pady=2, ipady=2) -poll_chat_listbox_scrollbar = ttk.Scrollbar(poll_chat_listbox_frame, orient=tk.VERTICAL) -poll_chat_listbox = tk.Listbox(poll_chat_listbox_frame, selectmode=tk.EXTENDED, yscrollcommand=poll_chat_listbox_scrollbar.set, exportselection=False, font=("Segoe UI", 9), height=6) -poll_chat_listbox_scrollbar.config(command=poll_chat_listbox.yview) -poll_chat_listbox_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) -poll_chat_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - -# Poll Question -tk.Label(poll_sender_tab, text="Poll Question:", anchor=tk.W, font=("Segoe UI", 9)).pack(fill=tk.X, padx=5, pady=(8,0)) -poll_question_entry = ttk.Entry(poll_sender_tab, width=60, font=("Segoe UI", 10)) -poll_question_entry.pack(fill=tk.X, padx=5, pady=2, ipady=2) - -# Allow Multiple Answers Checkbox -allow_multiple_answers_var = tk.BooleanVar(value=False) -allow_multiple_checkbox = ttk.Checkbutton(poll_sender_tab, text="Allow multiple answers", variable=allow_multiple_answers_var) -allow_multiple_checkbox.pack(padx=5, pady=(2,5), anchor=tk.W) - -# Poll Options Management -pom_frame = ttk.LabelFrame(poll_sender_tab, text="Poll Options (Enter one by one, max 12)", padding=10) -pom_frame.pack(fill=tk.X, padx=5, pady=5) -pom_left_frame = tk.Frame(pom_frame); pom_left_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,10)) -poll_option_entry = ttk.Entry(pom_left_frame, width=40, font=("Segoe UI", 10)) -poll_option_entry.pack(fill=tk.X, pady=(0,5), ipady=2) -poll_options_listbox_outer_frame = tk.Frame(pom_left_frame) # Frame for listbox + scrollbar -poll_options_listbox_outer_frame.pack(fill=tk.X, expand=True) -opt_scrollbar = ttk.Scrollbar(poll_options_listbox_outer_frame, orient=tk.VERTICAL) -poll_options_listbox = tk.Listbox(poll_options_listbox_outer_frame, height=5, font=("Segoe UI", 9), yscrollcommand=opt_scrollbar.set) -opt_scrollbar.config(command=poll_options_listbox.yview); opt_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) -poll_options_listbox.pack(fill=tk.BOTH, expand=True) - -pob_frame = tk.Frame(pom_frame) # Button frame for options -pob_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5) -btn_width = 8 -ttk.Button(pob_frame, text="Add", command=add_poll_option, width=btn_width).pack(pady=2, fill=tk.X) -ttk.Button(pob_frame, text="Edit", command=edit_poll_option, width=btn_width).pack(pady=2, fill=tk.X) -ttk.Button(pob_frame, text="Delete", command=delete_poll_option, width=btn_width).pack(pady=2, fill=tk.X) -ttk.Button(pob_frame, text="Clear All", command=clear_poll_options, width=btn_width).pack(pady=2, fill=tk.X) - - -# Anti-Ban Settings -anti_ban_frame = ttk.LabelFrame(poll_sender_tab, text="Send Delay (seconds between messages)", padding=10) -anti_ban_frame.pack(fill=tk.X, padx=5, pady=(10,5)) -anti_ban_delay_min = tk.DoubleVar(value=2.0) -anti_ban_delay_max = tk.DoubleVar(value=4.0) -tk.Label(anti_ban_frame, text="Min:", font=("Segoe UI",9)).pack(side=tk.LEFT, padx=(0,2)) -ttk.Entry(anti_ban_frame, textvariable=anti_ban_delay_min, width=5, font=("Segoe UI",9)).pack(side=tk.LEFT, padx=(0,10)) -tk.Label(anti_ban_frame, text="Max:", font=("Segoe UI",9)).pack(side=tk.LEFT, padx=(0,2)) -ttk.Entry(anti_ban_frame, textvariable=anti_ban_delay_max, width=5, font=("Segoe UI",9)).pack(side=tk.LEFT, padx=(0,10)) - -# Send Poll Button -send_poll_button = ttk.Button(poll_sender_tab, text="🚀 Send Poll to Selected Chats", command=send_poll_message, style="Bold.TButton") -send_poll_button.pack(pady=(10,5), ipady=5, fill=tk.X, padx=5) - - -# == Poll Results Tab == -poll_results_tab = ttk.Frame(notebook, padding=10) -notebook.add(poll_results_tab, text="📈 Poll Results") - -# Frame for listing polls and refreshing -poll_list_management_frame = tk.Frame(poll_results_tab) -poll_list_management_frame.pack(fill=tk.X, pady=(0,10)) -tk.Label(poll_list_management_frame, text="Previously Sent Polls (Newest First):", font=("Segoe UI", 10, "bold")).pack(side=tk.LEFT, anchor=tk.W) -refresh_polls_button = ttk.Button(poll_list_management_frame, text="🔄 Refresh Poll List & Results", command=fetch_all_poll_data_from_server) -refresh_polls_button.pack(side=tk.RIGHT) - -# Listbox for polls -poll_results_listbox_frame = tk.Frame(poll_results_tab) -poll_results_listbox_frame.pack(fill=tk.X, pady=5) -pr_scrollbar = ttk.Scrollbar(poll_results_listbox_frame, orient=tk.VERTICAL) -poll_results_listbox = tk.Listbox(poll_results_listbox_frame, yscrollcommand=pr_scrollbar.set, exportselection=False, font=("Segoe UI", 9), height=10) -pr_scrollbar.config(command=poll_results_listbox.yview); pr_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) -poll_results_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) -poll_results_listbox.bind("<>", display_selected_poll_results) - -# Frame for displaying results of the selected poll -poll_results_display_outer_frame = ttk.LabelFrame(poll_results_tab, text="Selected Poll Details & Results", padding=10) -poll_results_display_outer_frame.pack(fill=tk.BOTH, expand=True, pady=5) - -poll_results_label = scrolledtext.ScrolledText( - poll_results_display_outer_frame, wrap=tk.WORD, font=("Courier New", 9), - state=tk.DISABLED, relief=tk.SOLID, borderwidth=1, height=15 -) -poll_results_label.pack(fill=tk.BOTH, expand=True, padx=2, pady=2) -# Initial text set in display_selected_poll_results or populate_poll_results_listbox if none selected - -# --- Socket.IO Connection Thread --- -def attempt_sio_connection(): - """Attempt to connect to Socket.IO server in a loop.""" - if not sio.connected: - try: - print("Attempting to connect to Socket.IO server...") - sio.connect(NODE_SERVER_URL, wait_timeout=5) # Shorter wait for individual attempt - except socketio.exceptions.ConnectionError as e: - # This error is expected if server is down, will be handled by sio's reconnection logic - print(f"Socket.IO connection attempt failed (will retry via client): {e}") - if 'status_label' in globals() and status_label.winfo_exists(): - root.after(0, update_status_label, "Socket.IO connection failed. Retrying...", "red") - except Exception as e: - print(f"Unexpected error during Socket.IO connection attempt: {e}") - if 'status_label' in globals() and status_label.winfo_exists(): - root.after(0, update_status_label, f"Socket.IO error: {e}", "red") - -def sio_connection_thread_func(): - while True: - if not sio.connected: - attempt_sio_connection() - time.sleep(10) # Interval between connection attempts if not connected - -# --- Initializations & Main Loop --- -def initial_gui_setup(): - update_poll_template_dropdown() - # Initial fetch of poll data from server if it's already running - # Do this slightly after GUI is up to ensure labels exist - root.after(1000, fetch_all_poll_data_from_server) - # Initial check of WhatsApp status via HTTP as a fallback - root.after(500, check_whatsapp_status) - - -def on_closing(): - if messagebox.askokcancel("Quit", "Do you want to quit the Poll Master application?"): - if sio.connected: - print("Disconnecting Socket.IO client...") - sio.disconnect() - root.destroy() - print("Application closed.") - -if __name__ == "__main__": - root.protocol("WM_DELETE_WINDOW", on_closing) - # Start the Socket.IO connection manager thread - sio_thread = threading.Thread(target=sio_connection_thread_func, daemon=True) - sio_thread.start() - - # Schedule initial GUI setup tasks - root.after(100, initial_gui_setup) - - root.mainloop() +import tkinter as tk +from tkinter import messagebox, scrolledtext, ttk, simpledialog +from PIL import Image, ImageTk # Ensure Pillow is available +import requests +import socketio +import threading +import time +import random # For anti-ban delay +import json +import os +import qrcode # For QR code generation + +# --- Configuration --- +NODE_SERVER_URL = "http://localhost:3000" +NODE_API_STATUS = f"{NODE_SERVER_URL}/status" +# NODE_API_QR = f"{NODE_SERVER_URL}/qr" # Not used if QR comes via socket +NODE_API_SEND_POLL = f"{NODE_SERVER_URL}/send-poll" +NODE_API_GET_CHATS = f"{NODE_SERVER_URL}/get-chats" +NODE_API_LOGOUT = f"{NODE_SERVER_URL}/logout" +NODE_API_GET_ALL_POLL_DATA = f"{NODE_SERVER_URL}/get-all-poll-data" + +TEMPLATES_FILE = "poll_templates.json" + +# --- Global Variables --- +sio_connected = False +chat_mapping = {} +active_polls_data_from_server = {} +whatsapp_client_actually_ready = False +sio_connected = False +chat_mapping = {} +active_polls_data_from_server = {} +editing_template_name = None # Stores the name of the template being edited + + +# --- Socket.IO Client --- +sio = socketio.Client(reconnection_attempts=10, reconnection_delay=3, logger=False, engineio_logger=False) + +@sio.event +def connect(): + global sio_connected + sio_connected = True + print('Socket.IO connected!') + if 'status_label' in globals() and status_label.winfo_exists(): + update_status_label("Socket.IO Connected. Checking WhatsApp...", "blue") + check_whatsapp_status() + +@sio.event +def connect_error(data): + global sio_connected + sio_connected = False + print(f"Socket.IO connection failed: {data}") + if 'status_label' in globals() and status_label.winfo_exists(): + update_status_label(f"Socket.IO Connection Error. Retrying...", "red") + +@sio.event +def disconnect(): + global sio_connected + sio_connected = False + print('Socket.IO disconnected.') + if 'status_label' in globals() and status_label.winfo_exists(): + update_status_label("Socket.IO Disconnected. Retrying connection...", "orange") + if 'qr_display_label' in globals() and qr_display_label.winfo_exists(): + qr_display_label.config(image='', text="QR Code (Disconnected)") + + +@sio.event +def qr_code(qr_data_from_socket): + print(f"Received QR Code via Socket.IO.") + if 'qr_display_label' in globals() and qr_display_label.winfo_exists(): + display_qr_code(qr_data_from_socket) + update_status_label("QR Code Received. Please scan.", "#DBA800") + if 'notebook' in globals() and 'connection_tab' in globals(): + notebook.select(connection_tab) + +@sio.event +def client_status(status): + global whatsapp_client_actually_ready + print(f"WhatsApp Client Status from Socket.IO: {status}") + if 'status_label' in globals() and status_label.winfo_exists(): + if status == 'ready': + whatsapp_client_actually_ready = True + update_status_label("WhatsApp Client is READY!", "green") + if 'qr_display_label' in globals() and qr_display_label.winfo_exists(): + qr_display_label.config(image='', text="WhatsApp Client READY!") + fetch_chats() + fetch_all_poll_data_from_server() + elif status == 'qr_pending': + whatsapp_client_actually_ready = False + update_status_label("Waiting for QR scan (check Connection Tab)...", "orange") + elif status == 'logged_out': + whatsapp_client_actually_ready = False + update_status_label(f"WhatsApp: Logged Out. Delete 'baileys_auth_info' & restart Node server to connect new.", "red") + clear_session_gui_elements() + elif status in ['disconnected', 'auth_failure']: + whatsapp_client_actually_ready = False + update_status_label(f"WhatsApp: {status}. Please connect/reconnect.", "red") + + +def clear_session_gui_elements(): + global active_polls_data_from_server, whatsapp_client_actually_ready + whatsapp_client_actually_ready = False + active_polls_data_from_server = {} + if 'qr_display_label' in globals() and qr_display_label.winfo_exists(): qr_display_label.config(image='', text="QR Code (Logged Out)") + if 'poll_chat_listbox' in globals() and poll_chat_listbox.winfo_exists(): poll_chat_listbox.delete(0, tk.END) + if 'poll_results_listbox' in globals() and poll_results_listbox.winfo_exists(): poll_results_listbox.delete(0, tk.END) + if 'poll_results_label' in globals() and poll_results_label.winfo_exists(): + poll_results_label.config(state=tk.NORMAL) + poll_results_label.delete('1.0', tk.END) + poll_results_label.insert('1.0', "Logged out. Select a poll after reconnecting.") + poll_results_label.config(state=tk.DISABLED) + + +@sio.event +def whatsapp_user(user_data): + if user_data and user_data.get('id'): + print(f"Connected as: {user_data.get('name') or user_data.get('id')}") + + +@sio.event +def poll_update_to_gui(data): + global active_polls_data_from_server + print(f"GUI received poll_update_to_gui: {data}") + poll_msg_id = data.get('pollMsgId') + + if poll_msg_id: + + if poll_msg_id not in active_polls_data_from_server: + active_polls_data_from_server[poll_msg_id] = { + 'question': data.get('question', 'Unknown Question'), + 'options': data.get('options', []), + 'results': data.get('results', {}), + 'voters': data.get('voters', {}), + 'timestamp': data.get('timestamp', time.time()*1000), + 'selectableCount': data.get('selectableCount', 1) + } + populate_poll_results_listbox() + else: + active_polls_data_from_server[poll_msg_id]['results'] = data.get('results', {}) + active_polls_data_from_server[poll_msg_id]['voters'] = data.get('voters', {}) + + + + if 'poll_results_listbox' in globals() and poll_results_listbox.winfo_exists(): + try: + selected_indices = poll_results_listbox.curselection() + if selected_indices: + selected_poll_display_text = poll_results_listbox.get(selected_indices[0]) + + if f"(ID: ...{poll_msg_id[-6:]})" in selected_poll_display_text: + display_selected_poll_results() + except Exception as e: + print(f"Error updating selected poll display from poll_update_to_gui: {e}") + update_status_label(f"Poll '{active_polls_data_from_server.get(poll_msg_id, {}).get('question', poll_msg_id)}' updated!", "cyan") + +@sio.event +def new_poll_sent(data): + global active_polls_data_from_server + print(f"GUI received new_poll_sent: {data}") + poll_msg_id = data.get('pollMsgId') + poll_data_obj = data.get('pollData') + if poll_msg_id and poll_data_obj: + active_polls_data_from_server[poll_msg_id] = poll_data_obj + populate_poll_results_listbox() + update_status_label(f"New poll '{poll_data_obj.get('question', 'N/A')}' added to results tab.", "magenta") + else: + + fetch_all_poll_data_from_server() + + +@sio.event +def initial_poll_data(data): + global active_polls_data_from_server + print("GUI received initial_poll_data") + active_polls_data_from_server = data if isinstance(data, dict) else {} + populate_poll_results_listbox() + + update_status_label(f"Loaded {len(active_polls_data_from_server)} existing polls.", "blue") + +# --- GUI Functions --- +def update_status_label(message, color_name="blue"): + if 'status_label' in globals() and status_label.winfo_exists(): + try: + status_label.config(text=f"Status: {message}", fg=color_name) + if 'root' in globals() and root.winfo_exists(): root.update_idletasks() + except tk.TclError as e: + print(f"Error setting color '{color_name}': {e}. Using default.") + status_label.config(text=f"Status: {message}", fg="black") + + +def check_whatsapp_status(): + if 'status_label' not in globals() or not status_label.winfo_exists(): return + update_status_label("Checking WhatsApp status via HTTP...", "blue") + try: + response = requests.get(NODE_API_STATUS, timeout=3) + response.raise_for_status() + data = response.json() + api_status = data.get('status') + api_qr = data.get('qrCode') + + if api_status == 'ready': + + if not sio_connected: update_status_label("HTTP: WA Ready (Socket disconnected)", "orange") + else: update_status_label("HTTP: WA Ready (Socket connected)", "green") + elif api_status == 'qr_pending' and api_qr: + + + if not sio_connected: update_status_label("HTTP: WA QR Pending (Socket disconnected)", "orange") + + elif api_status == 'disconnected': + + if not sio_connected: update_status_label("HTTP: WA Disconnected (Socket disconnected)", "red") + + except requests.exceptions.RequestException as e: + update_status_label(f"Node server check failed: {type(e).__name__}", "red") + print(f"HTTP status check failed: {e}") + + +def display_qr_code(qr_data_str): + if 'qr_display_label' not in globals() or not qr_display_label.winfo_exists(): return + try: + + img = qrcode.make(qr_data_str) + + if not hasattr(img, 'resize'): + qr_code_obj = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L) + qr_code_obj.add_data(qr_data_str) + qr_code_obj.make(fit=True) + img = qr_code_obj.make_image(fill_color="black", back_color="white").convert('RGB') + + img_resized = img.resize((250, 250), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(img_resized) + qr_display_label.config(image=photo, text="") + qr_display_label.image = photo + except Exception as e: + update_status_label(f"Error displaying QR: {e}", "red") + qr_display_label.config(image='', text=f"QR Display Error: {e}") + print(f"QR display error: {e}") + + +def fetch_chats(): + global chat_mapping + if 'status_label' not in globals() or not status_label.winfo_exists(): return + if not client_is_ready(): + update_status_label("WhatsApp not ready. Cannot fetch chats.", "orange") + return + + update_status_label("Fetching chats...", "blue") + try: + response = requests.get(NODE_API_GET_CHATS, timeout=10) + response.raise_for_status() + data = response.json() + if data.get('success'): + listboxes_to_update = [] + if 'poll_chat_listbox' in globals() and poll_chat_listbox.winfo_exists(): + listboxes_to_update.append(poll_chat_listbox) + + for lb in listboxes_to_update: lb.delete(0, tk.END) + chat_mapping.clear() + fetched_chats_count = 0 + if 'chats' in data and data['chats'] is not None: + for chat in data['chats']: + display_name = f"{chat.get('name', 'Unknown Name')} ({'Group' if chat.get('isGroup') else 'Contact'})" + chat_id_val = chat.get('id') + if chat_id_val: + chat_mapping[display_name] = chat_id_val + for lb in listboxes_to_update: lb.insert(tk.END, display_name) + fetched_chats_count +=1 + update_status_label(f"Fetched {fetched_chats_count} chats.", "green") + else: + update_status_label(f"Failed to fetch chats: {data.get('message', 'No message')}", "red") + except requests.exceptions.RequestException as e: + update_status_label(f"Error fetching chats (HTTP): {e}", "red") + print(f"Fetch chats error: {e}") + except Exception as e: + update_status_label(f"Unexpected error fetching chats: {e}", "red") + print(f"Unexpected fetch chats error: {e}") + +def client_is_ready(): + global whatsapp_client_actually_ready + + return whatsapp_client_actually_ready + +# --- Poll Sender Functions --- +def send_poll_message(): + if 'poll_question_entry' not in globals(): return + if not client_is_ready(): + messagebox.showerror("Error", "WhatsApp client is not ready to send polls.") + return + + question = poll_question_entry.get().strip() + options = [opt.strip() for opt in poll_options_listbox.get(0, tk.END) if opt.strip()] + selected_indices = poll_chat_listbox.curselection() + allow_multiple = allow_multiple_answers_var.get() + + if not question: messagebox.showerror("Error", "Poll question cannot be empty."); return + if not options or len(options) < 1: messagebox.showerror("Error", "Poll must have at least one option."); return + if len(options) > 12: messagebox.showerror("Error", "Maximum of 12 poll options allowed by WhatsApp."); return + if not selected_indices: messagebox.showerror("Error", "Please select at least one chat/group to send the poll to."); return + + selected_chat_display_names = [poll_chat_listbox.get(i) for i in selected_indices] + selected_chat_ids = [chat_mapping[name] for name in selected_chat_display_names if name in chat_mapping] + + if not selected_chat_ids: messagebox.showerror("Error", "No valid chats selected (ID mapping failed). Please refresh chats."); return + if not messagebox.askyesno("Confirm Poll Submission", f"Are you sure you want to send this poll to {len(selected_chat_ids)} selected chat(s)?"): return + + update_status_label(f"Initiating poll send to {len(selected_chat_ids)} chat(s)...", "blue") + + threading.Thread(target=_send_polls_threaded, args=(selected_chat_ids, question, options, allow_multiple), daemon=True).start() + +def _send_polls_threaded(chat_ids, question, options, allow_multiple_bool): + success_count = 0 + fail_count = 0 + for i, chat_id in enumerate(chat_ids): + current_status_msg = f"Sending poll ({i+1}/{len(chat_ids)}) to {chat_id}..." + root.after(0, update_status_label, current_status_msg, "cyan") + try: + payload = { + "chatId": chat_id, + "question": question, + "options": options, + "allowMultipleAnswers": allow_multiple_bool + } + response = requests.post(NODE_API_SEND_POLL, json=payload, timeout=15) + response.raise_for_status() + result = response.json() + + if result.get('success'): + success_count += 1 + final_msg_for_chat = f"Poll sent to {chat_id} (ID: {result.get('pollMsgId', 'N/A')})" + root.after(0, update_status_label, final_msg_for_chat, "green") + else: + fail_count += 1 + final_msg_for_chat = f"Failed poll to {chat_id}: {result.get('message', 'Unknown error')}" + root.after(0, update_status_label, final_msg_for_chat, "red") + + delay_s = random.uniform(anti_ban_delay_min.get(), anti_ban_delay_max.get()) + time.sleep(delay_s) + except requests.exceptions.HTTPError as httperr: + fail_count +=1 + err_msg = f"HTTP Error poll to {chat_id}: {httperr.response.status_code} - {httperr.response.text}" + root.after(0, update_status_label, err_msg, "red") + print(err_msg) + except requests.exceptions.RequestException as reqerr: + fail_count += 1 + err_msg = f"Request Error poll to {chat_id}: {reqerr}" + root.after(0, update_status_label, err_msg, "red") + print(err_msg) + except Exception as e: + fail_count +=1 + err_msg = f"Unexpected Error poll to {chat_id}: {e}" + root.after(0, update_status_label, err_msg, "red") + print(err_msg) + + final_summary = f"Poll sending finished. Success: {success_count}, Failed: {fail_count}." + root.after(0, update_status_label, final_summary, "blue" if fail_count == 0 else "orange") + + +def add_poll_option(): + option = poll_option_entry.get().strip() + if option: + current_options = poll_options_listbox.get(0, tk.END) + if option in current_options: + messagebox.showwarning("Duplicate Option", "This option already exists in the list.") + return + if len(current_options) >= 12: + messagebox.showwarning("Option Limit", "WhatsApp allows a maximum of 12 options per poll.") + return + poll_options_listbox.insert(tk.END, option) + poll_option_entry.delete(0, tk.END) + else: + messagebox.showinfo("Add Option", "Please enter a non-empty option text.") + +def edit_poll_option(): + selected_indices = poll_options_listbox.curselection() + if not selected_indices: + messagebox.showinfo("Edit Option", "Please select an option from the list to edit.") + return + idx = selected_indices[0] + current_val = poll_options_listbox.get(idx) + new_val = simpledialog.askstring("Edit Option", "Enter new text for the option:", initialvalue=current_val, parent=root) + if new_val is not None: + new_val_stripped = new_val.strip() + if not new_val_stripped: + messagebox.showwarning("Edit Option", "Option text cannot be empty.") + return + if new_val_stripped != current_val and new_val_stripped in poll_options_listbox.get(0, tk.END): + messagebox.showwarning("Edit Option", "This option text already exists in the list.") + return + poll_options_listbox.delete(idx) + poll_options_listbox.insert(idx, new_val_stripped) + +def delete_poll_option(): + selected_indices = poll_options_listbox.curselection() + if selected_indices: + poll_options_listbox.delete(selected_indices[0]) + else: + messagebox.showinfo("Delete Option", "Please select an option from the list to delete.") + +def clear_poll_options(): + poll_options_listbox.delete(0, tk.END) + +def move_option_up(): + if 'poll_options_listbox' not in globals() or not poll_options_listbox.winfo_exists(): return + selected_indices = poll_options_listbox.curselection() + if not selected_indices: + messagebox.showinfo("Move Option", "Please select an option to move up.", parent=root) + return + + idx = selected_indices[0] + if idx == 0: # Already at the top + return + + text = poll_options_listbox.get(idx) + poll_options_listbox.delete(idx) + poll_options_listbox.insert(idx - 1, text) + poll_options_listbox.selection_set(idx - 1) + poll_options_listbox.activate(idx - 1) + +def move_option_down(): + if 'poll_options_listbox' not in globals() or not poll_options_listbox.winfo_exists(): return + selected_indices = poll_options_listbox.curselection() + if not selected_indices: + messagebox.showinfo("Move Option", "Please select an option to move down.", parent=root) + return + + idx = selected_indices[0] + if idx == poll_options_listbox.size() - 1: # Already at the bottom + return + + text = poll_options_listbox.get(idx) + poll_options_listbox.delete(idx) + poll_options_listbox.insert(idx + 1, text) + poll_options_listbox.selection_set(idx + 1) + poll_options_listbox.activate(idx + 1) + +# --- Poll Template Management --- +def load_poll_templates(): + if os.path.exists(TEMPLATES_FILE): + try: + with open(TEMPLATES_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except json.JSONDecodeError: + messagebox.showerror("Template Error", f"Error decoding {TEMPLATES_FILE}. It might be corrupted.") + return {} + except Exception as e: + messagebox.showerror("Template Error", f"Error loading templates: {e}") + return {} + return {} + +def save_poll_templates(data): + try: + with open(TEMPLATES_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4, ensure_ascii=False) + except Exception as e: + messagebox.showerror("Template Error", f"Error saving templates: {e}") + +def update_poll_template_dropdown(): + if 'poll_template_combobox' not in globals() or not poll_template_combobox.winfo_exists(): return + templates = load_poll_templates() + names = list(templates.keys()) + poll_template_combobox['values'] = names + if names: + poll_template_combobox.current(0) + else: + poll_template_combobox.set("") + +def edit_selected_poll_template(): + global editing_template_name + selected_name = poll_template_combobox.get() + if not selected_name: + messagebox.showinfo("Edit Template", "Please select a template from the dropdown to edit.", parent=root) + return + + templates = load_poll_templates() + if selected_name in templates: + template_data = templates[selected_name] + poll_question_entry.delete(0, tk.END) + poll_question_entry.insert(0, template_data.get("question", "")) + + clear_poll_options() + options_str = template_data.get("options", "") + if isinstance(options_str, str): + for opt in options_str.split('\\n'): + opt_stripped = opt.strip() + if opt_stripped: + poll_options_listbox.insert(tk.END, opt_stripped) + + editing_template_name = selected_name + update_status_label(f"Editing template: '{selected_name}'. Modify then use 'Save Current'.", "blue") + else: + messagebox.showerror("Edit Template", "Selected template not found. It might have been deleted.", parent=root) + editing_template_name = None + +def save_current_poll_as_template(): + global editing_template_name + question_text = poll_question_entry.get().strip() + options_list = [opt.strip() for opt in poll_options_listbox.get(0, tk.END) if opt.strip()] + + if not question_text and not options_list and not editing_template_name: + messagebox.showinfo("Save Template", "Please enter a poll question and/or options to save as a template.", parent=root) + return + + current_templates = load_poll_templates() + proposed_template_name = None + + if editing_template_name: + response = messagebox.askyesnocancel("Save Edited Template", + f"You are editing '{editing_template_name}'.\n" + "YES to save changes to this template.\n" + "NO to save as a new template.\n" + "CANCEL to abort saving.", + parent=root) + if response is True: + proposed_template_name = editing_template_name + elif response is False: + proposed_template_name = simpledialog.askstring("Save As New Template", + "Enter a new name for this template:", + initialvalue=f"{editing_template_name}_copy", + parent=root) + else: + update_status_label("Save operation cancelled.", "orange") + return + else: + proposed_template_name = simpledialog.askstring("Save New Poll Template", + "Enter a name for this template:", + parent=root) + + if not proposed_template_name or not proposed_template_name.strip(): + if proposed_template_name is not None: + messagebox.showwarning("Save Template", "Template name cannot be empty.", parent=root) + return + + final_template_name = proposed_template_name.strip() + + if final_template_name != editing_template_name and final_template_name in current_templates: + if not messagebox.askyesno("Confirm Overwrite", + f"A template named '{final_template_name}' already exists. Overwrite it?", + parent=root): + update_status_label(f"Save cancelled. Did not overwrite '{final_template_name}'.", "orange") + return + + current_templates[final_template_name] = { + "question": question_text, + "options": "\\n".join(options_list) + } + save_poll_templates(current_templates) + update_poll_template_dropdown() + + if final_template_name in list(poll_template_combobox['values']): + poll_template_combobox.set(final_template_name) + + messagebox.showinfo("Save Template", f"Poll template '{final_template_name}' saved successfully!") + update_status_label(f"Template '{final_template_name}' saved.", "green") + editing_template_name = None + +def load_selected_poll_template(event=None): + global editing_template_name + editing_template_name = None + + selected_name = poll_template_combobox.get() + templates = load_poll_templates() + if selected_name in templates: + template_data = templates[selected_name] + poll_question_entry.delete(0, tk.END) + poll_question_entry.insert(0, template_data.get("question", "")) + + clear_poll_options() + options_str = template_data.get("options", "") + if isinstance(options_str, str): + for opt in options_str.split('\\n'): + opt_stripped = opt.strip() + if opt_stripped: + poll_options_listbox.insert(tk.END, opt_stripped) + update_status_label(f"Poll template '{selected_name}' loaded.", "blue") + + +def delete_selected_poll_template(): + global editing_template_name + selected_name = poll_template_combobox.get() + if not selected_name: + messagebox.showinfo("Delete Template", "Please select a template from the dropdown to delete.", parent=root) + return + + if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete the template '{selected_name}'?", parent=root): + templates = load_poll_templates() + data_of_template_being_deleted = templates.get(selected_name) + + if selected_name in templates: + del templates[selected_name] + save_poll_templates(templates) + update_poll_template_dropdown() + poll_template_combobox.set('') + + current_question = poll_question_entry.get() + current_options_list = [opt.strip() for opt in poll_options_listbox.get(0, tk.END) if opt.strip()] + + + clear_fields = False + if editing_template_name == selected_name: + clear_fields = True + editing_template_name = None + elif data_of_template_being_deleted: + opts_as_str_from_deleted = data_of_template_being_deleted.get("options", "") + opts_as_list_from_deleted = [opt.strip() for opt in opts_as_str_from_deleted.split('\\n') if opt.strip()] + if current_question == data_of_template_being_deleted.get("question", "") and \ + current_options_list == opts_as_list_from_deleted: + clear_fields = True + + if clear_fields: + poll_question_entry.delete(0, tk.END) + clear_poll_options() + + messagebox.showinfo("Delete Template", f"Poll template '{selected_name}' deleted successfully.", parent=root) + update_status_label(f"Template '{selected_name}' deleted.", "orange") + else: + messagebox.showerror("Delete Template", "Selected template not found (it may have been already deleted).", parent=root) + if editing_template_name == selected_name: + editing_template_name = None + + +# --- Poll Results Functions --- +def fetch_all_poll_data_from_server(): + global active_polls_data_from_server + + update_status_label("Fetching all poll data via HTTP...", "blue") + try: + response = requests.get(NODE_API_GET_ALL_POLL_DATA, timeout=10) + response.raise_for_status() + data = response.json() + if data.get('success'): + active_polls_data_from_server = data.get('polls', {}) + if not isinstance(active_polls_data_from_server, dict): + print("Warning: Poll data from server is not a dictionary. Resetting.") + active_polls_data_from_server = {} + populate_poll_results_listbox() + update_status_label(f"Fetched/Refreshed {len(active_polls_data_from_server)} polls.", "green") + else: + update_status_label(f"Failed to fetch poll data: {data.get('message', 'No error message')}", "red") + except requests.exceptions.RequestException as e: + update_status_label(f"Error fetching poll data (HTTP): {e}", "red") + print(f"Error fetching poll data: {e}") + except json.JSONDecodeError as je: + update_status_label(f"Error decoding poll data JSON: {je}", "red") + print(f"JSON Decode Error for poll data: {je}") + + +def populate_poll_results_listbox(): + if 'poll_results_listbox' not in globals() or not poll_results_listbox.winfo_exists(): return + poll_results_listbox.delete(0, tk.END) + + if not active_polls_data_from_server: + poll_results_listbox.insert(tk.END, "No active polls found or fetched yet.") + return + + + + sorted_poll_items = sorted( + active_polls_data_from_server.items(), + key=lambda item: item[1].get('timestamp', 0) if isinstance(item[1].get('timestamp'), (int, float)) else 0, + reverse=True + ) + + for poll_msg_id, poll_info in sorted_poll_items: + question = poll_info.get('question', 'Unnamed Poll') + + display_text = f"{question[:50]}{'...' if len(question) > 50 else ''} (ID: ...{poll_msg_id[-6:]})" + poll_results_listbox.insert(tk.END, display_text) + + +def display_selected_poll_results(event=None): + if 'poll_results_listbox' not in globals() or not poll_results_listbox.winfo_exists(): return + if 'poll_results_label' not in globals() or not poll_results_label.winfo_exists(): return + + selected_indices = poll_results_listbox.curselection() + + poll_results_label.config(state=tk.NORMAL) + poll_results_label.delete('1.0', tk.END) + + if not selected_indices: + poll_results_label.insert('1.0', "Select a poll from the list above to see its results.") + poll_results_label.config(state=tk.DISABLED) + return + + selected_item_display_text = poll_results_listbox.get(selected_indices[0]) + actual_poll_msg_id = None + + + try: + if "(ID: ..." in selected_item_display_text and selected_item_display_text.endswith(")"): + id_suffix_with_ellipsis = selected_item_display_text.split('(ID: ...')[-1] + id_suffix = id_suffix_with_ellipsis[:-1] + for pid_key in active_polls_data_from_server.keys(): + if pid_key.endswith(id_suffix): + actual_poll_msg_id = pid_key + break + if not actual_poll_msg_id: + raise ValueError("Could not match listbox item to a poll ID.") + except Exception as e: + print(f"Error parsing poll ID from listbox item '{selected_item_display_text}': {e}") + poll_results_label.insert('1.0', f"Error finding poll data for: {selected_item_display_text}") + poll_results_label.config(state=tk.DISABLED) + return + + poll_info = active_polls_data_from_server.get(actual_poll_msg_id) + if not poll_info: + poll_results_label.insert('1.0', f"Poll data not found for ID: {actual_poll_msg_id}") + poll_results_label.config(state=tk.DISABLED) + return + + + results_str = f"Poll Question: {poll_info.get('question', 'N/A')}\\n" + results_str += f"Message ID: {actual_poll_msg_id}\\n" + ts = poll_info.get('timestamp') + results_str += f"Sent Timestamp: {ts} ({time.ctime(ts/1000) if isinstance(ts, (int, float)) and ts > 0 else 'N/A'})\\n" + selectable_count = poll_info.get('selectableCount', 1) + results_str += f"Allows Multiple Answers: {'Yes (Any number)' if selectable_count == 0 else f'No (Single Choice, selectable: {selectable_count})'}\\n" + results_str += "------------------------------------\\nResults:\\n" + + poll_option_results_map = poll_info.get('results', {}) + original_options_list = poll_info.get('options', []) + + total_votes_on_options = sum(poll_option_results_map.values()) + + + for opt_text in original_options_list: + votes_for_option = poll_option_results_map.get(opt_text, 0) + percentage = (votes_for_option / total_votes_on_options * 100) if total_votes_on_options > 0 else 0 + results_str += f" - \"{opt_text}\": {votes_for_option} votes ({percentage:.1f}%)\\n" + + results_str += "------------------------------------\\n" + voters_data = poll_info.get('voters', {}) + unique_voter_jids = list(voters_data.keys()) + results_str += f"Total Unique Voters Participated: {len(unique_voter_jids)}\\n" + + + results_str += f"(Note: Total votes on options ({total_votes_on_options}) might differ from unique voters if multiple selections are allowed or votes changed.)\\n" + + + if unique_voter_jids: + results_str += "\\nVoter Breakdown (JID -> Voted Option Text(s)):\\n" + option_hashes_to_text = poll_info.get('optionHashes', {}) + for voter_jid, selected_hashes_arr in voters_data.items(): + voted_texts = [option_hashes_to_text.get(h, f"UnknownHash:{h[:6]}") for h in selected_hashes_arr] + results_str += f" - {voter_jid}: {', '.join(voted_texts)}\\n" + + + poll_results_label.insert('1.0', results_str) + poll_results_label.config(state=tk.DISABLED) + + +# --- Logout Function --- +def logout_and_reconnect(): + if messagebox.askyesno("Logout & Connect New Account", + "This will log out the current WhatsApp account from the server " + "and clear the local 'baileys_auth_info' session folder on the server. " + "You will need to restart the Node.js server script manually " + "if you want it to pick up a new QR scan for a new account after this. " + "Continue?", parent=root): + update_status_label("Attempting logout...", "orange") + threading.Thread(target=_logout_threaded, daemon=True).start() + +def _logout_threaded(): + global active_polls_data_from_server, whatsapp_client_actually_ready + + + root.after(0, lambda: globals().update(whatsapp_client_actually_ready=False)) + + try: + response = requests.post(NODE_API_LOGOUT, timeout=15) + response.raise_for_status() + result = response.json() + if result.get('success'): + + root.after(0, update_status_label, result.get('message', "Logout successful. Restart Node server for new QR."), "blue") + root.after(0, clear_session_gui_elements) + else: + err_msg = result.get('message', "Failed to logout from server.") + root.after(0, update_status_label, f"Logout Error: {err_msg}", "red") + root.after(0, messagebox.showerror, "Logout Error", err_msg, parent=root) + + except requests.exceptions.RequestException as e: + err_msg = f"Logout request error: {e}" + root.after(0, update_status_label, err_msg, "red") + root.after(0, messagebox.showerror, "Logout Error", err_msg, parent=root) + print(err_msg) + except Exception as e: + err_msg = f"Unexpected error during logout: {e}" + root.after(0, update_status_label, err_msg, "red") + root.after(0, messagebox.showerror, "Logout Error", err_msg, parent=root) + print(err_msg) + + +# --- GUI Setup --- +root = tk.Tk() +root.title("WhatsApp Poll Master Deluxe") +root.geometry("950x800") + +status_label = tk.Label(root, text="Status: Initializing GUI...", bd=1, relief=tk.SUNKEN, anchor=tk.W, font=("Segoe UI", 10)) +status_label.pack(side=tk.BOTTOM, fill=tk.X, ipady=3) + +main_frame = tk.Frame(root, padx=10, pady=10) +main_frame.pack(fill=tk.BOTH, expand=True) + + +style = ttk.Style() +style.configure("TNotebook.Tab", font=("Segoe UI", 10, "bold"), padding=[10, 5]) +style.configure("TLabelFrame.Label", font=("Segoe UI", 10, "bold")) +style.configure("TButton", font=("Segoe UI", 9), padding=5) +style.configure("Bold.TButton", font=("Segoe UI", 10, "bold")) + + +notebook = ttk.Notebook(main_frame, style="TNotebook") +notebook.pack(fill=tk.BOTH, expand=True) + +# == Connection Tab == +connection_tab = ttk.Frame(notebook, padding=10) +notebook.add(connection_tab, text="📶 Connection") +connection_tab.columnconfigure(0, weight=1) +connection_tab.rowconfigure(1, weight=1) + +tk.Label(connection_tab, text="WhatsApp Web Connection", font=("Segoe UI", 14, "bold")).grid(row=0, column=0, pady=(0,10), sticky="ew") +qr_display_label = tk.Label(connection_tab, text="QR Code Area (Connecting...)", bg="lightgrey", relief=tk.GROOVE, height=15, width=40, font=("Courier New", 8)) +qr_display_label.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + +connection_button_frame = tk.Frame(connection_tab) +connection_button_frame.grid(row=2, column=0, pady=(10,0)) + +ttk.Button(connection_button_frame, text="🔄 Refresh Chats", command=fetch_chats, style="Bold.TButton").pack(side=tk.LEFT, padx=5) +ttk.Button(connection_button_frame, text="🚪 Logout & Clear Session", command=logout_and_reconnect, style="Bold.TButton").pack(side=tk.LEFT, padx=5) + + +# == Poll Sender Tab == +poll_sender_tab = ttk.Frame(notebook, padding=10) +notebook.add(poll_sender_tab, text="📊 Poll Sender") + + +poll_template_frame = ttk.LabelFrame(poll_sender_tab, text="Poll Templates", padding=10) +poll_template_frame.pack(fill=tk.X, padx=5, pady=(5,10)) +poll_template_combobox = ttk.Combobox(poll_template_frame, state="readonly", width=40, font=("Segoe UI", 9)) +poll_template_combobox.pack(side=tk.LEFT, padx=(0,5), pady=5, ipady=2) +poll_template_combobox.bind("<>", load_selected_poll_template) +ptb_frame = tk.Frame(poll_template_frame) +ptb_frame.pack(side=tk.LEFT, padx=5) +ttk.Button(ptb_frame, text="💾 Save Current", command=save_current_poll_as_template).pack(side=tk.LEFT, padx=2) +ttk.Button(ptb_frame, text="✏️ Edit Selected", command=edit_selected_poll_template).pack(side=tk.LEFT, padx=2) # ADDED +ttk.Button(ptb_frame, text="🗑️ Delete Selected", command=delete_selected_poll_template).pack(side=tk.LEFT, padx=2) + + +tk.Label(poll_sender_tab, text="Select Chats/Groups for Poll:", font=("Segoe UI", 9, "bold")).pack(pady=(5,2), anchor=tk.W, padx=5) +poll_chat_listbox_frame = tk.Frame(poll_sender_tab) +poll_chat_listbox_frame.pack(fill=tk.X, padx=5, pady=2, ipady=2) +poll_chat_listbox_scrollbar = ttk.Scrollbar(poll_chat_listbox_frame, orient=tk.VERTICAL) +poll_chat_listbox = tk.Listbox(poll_chat_listbox_frame, selectmode=tk.EXTENDED, yscrollcommand=poll_chat_listbox_scrollbar.set, exportselection=False, font=("Segoe UI", 9), height=6) +poll_chat_listbox_scrollbar.config(command=poll_chat_listbox.yview) +poll_chat_listbox_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) +poll_chat_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + +tk.Label(poll_sender_tab, text="Poll Question:", anchor=tk.W, font=("Segoe UI", 9)).pack(fill=tk.X, padx=5, pady=(8,0)) +poll_question_entry = ttk.Entry(poll_sender_tab, width=60, font=("Segoe UI", 10)) +poll_question_entry.pack(fill=tk.X, padx=5, pady=2, ipady=2) + + +allow_multiple_answers_var = tk.BooleanVar(value=False) +allow_multiple_checkbox = ttk.Checkbutton(poll_sender_tab, text="Allow multiple answers", variable=allow_multiple_answers_var) +allow_multiple_checkbox.pack(padx=5, pady=(2,5), anchor=tk.W) + + +pom_frame = ttk.LabelFrame(poll_sender_tab, text="Poll Options (Enter one by one, max 12)", padding=10) +pom_frame.pack(fill=tk.X, padx=5, pady=5) +pom_left_frame = tk.Frame(pom_frame); pom_left_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,10)) +poll_option_entry = ttk.Entry(pom_left_frame, width=40, font=("Segoe UI", 10)) +poll_option_entry.pack(fill=tk.X, pady=(0,5), ipady=2) +poll_options_listbox_outer_frame = tk.Frame(pom_left_frame) +poll_options_listbox_outer_frame.pack(fill=tk.X, expand=True) +opt_scrollbar = ttk.Scrollbar(poll_options_listbox_outer_frame, orient=tk.VERTICAL) +poll_options_listbox = tk.Listbox(poll_options_listbox_outer_frame, height=5, font=("Segoe UI", 9), yscrollcommand=opt_scrollbar.set) +opt_scrollbar.config(command=poll_options_listbox.yview); opt_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) +poll_options_listbox.pack(fill=tk.BOTH, expand=True) + +pob_frame = tk.Frame(pom_frame) +pob_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5) +btn_width = 8 +ttk.Button(pob_frame, text="Add", command=add_poll_option, width=btn_width).pack(pady=2, fill=tk.X) +ttk.Button(pob_frame, text="Edit", command=edit_poll_option, width=btn_width).pack(pady=2, fill=tk.X) +ttk.Button(pob_frame, text="Delete", command=delete_poll_option, width=btn_width).pack(pady=2, fill=tk.X) +ttk.Button(pob_frame, text="Clear All", command=clear_poll_options, width=btn_width).pack(pady=2, fill=tk.X) +ttk.Button(pob_frame, text="⬆️ Up", command=move_option_up, width=btn_width).pack(pady=2, fill=tk.X) +ttk.Button(pob_frame, text="⬇️ Down", command=move_option_down, width=btn_width).pack(pady=2, fill=tk.X) + + +anti_ban_frame = ttk.LabelFrame(poll_sender_tab, text="Send Delay (seconds between messages)", padding=10) +anti_ban_frame.pack(fill=tk.X, padx=5, pady=(10,5)) +anti_ban_delay_min = tk.DoubleVar(value=2.0) +anti_ban_delay_max = tk.DoubleVar(value=4.0) +tk.Label(anti_ban_frame, text="Min:", font=("Segoe UI",9)).pack(side=tk.LEFT, padx=(0,2)) +ttk.Entry(anti_ban_frame, textvariable=anti_ban_delay_min, width=5, font=("Segoe UI",9)).pack(side=tk.LEFT, padx=(0,10)) +tk.Label(anti_ban_frame, text="Max:", font=("Segoe UI",9)).pack(side=tk.LEFT, padx=(0,2)) +ttk.Entry(anti_ban_frame, textvariable=anti_ban_delay_max, width=5, font=("Segoe UI",9)).pack(side=tk.LEFT, padx=(0,10)) + + +send_poll_button = ttk.Button(poll_sender_tab, text="🚀 Send Poll to Selected Chats", command=send_poll_message, style="Bold.TButton") +send_poll_button.pack(pady=(10,5), ipady=5, fill=tk.X, padx=5) + + +# == Poll Results Tab == +poll_results_tab = ttk.Frame(notebook, padding=10) +notebook.add(poll_results_tab, text="📈 Poll Results") + + +poll_list_management_frame = tk.Frame(poll_results_tab) +poll_list_management_frame.pack(fill=tk.X, pady=(0,10)) +tk.Label(poll_list_management_frame, text="Previously Sent Polls (Newest First):", font=("Segoe UI", 10, "bold")).pack(side=tk.LEFT, anchor=tk.W) +refresh_polls_button = ttk.Button(poll_list_management_frame, text="🔄 Refresh Poll List & Results", command=fetch_all_poll_data_from_server) +refresh_polls_button.pack(side=tk.RIGHT) + + +poll_results_listbox_frame = tk.Frame(poll_results_tab) +poll_results_listbox_frame.pack(fill=tk.X, pady=5) +pr_scrollbar = ttk.Scrollbar(poll_results_listbox_frame, orient=tk.VERTICAL) +poll_results_listbox = tk.Listbox(poll_results_listbox_frame, yscrollcommand=pr_scrollbar.set, exportselection=False, font=("Segoe UI", 9), height=10) +pr_scrollbar.config(command=poll_results_listbox.yview); pr_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) +poll_results_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) +poll_results_listbox.bind("<>", display_selected_poll_results) + + +poll_results_display_outer_frame = ttk.LabelFrame(poll_results_tab, text="Selected Poll Details & Results", padding=10) +poll_results_display_outer_frame.pack(fill=tk.BOTH, expand=True, pady=5) + +poll_results_label = scrolledtext.ScrolledText( + poll_results_display_outer_frame, wrap=tk.WORD, font=("Courier New", 9), + state=tk.DISABLED, relief=tk.SOLID, borderwidth=1, height=15 +) +poll_results_label.pack(fill=tk.BOTH, expand=True, padx=2, pady=2) + + +# --- Socket.IO Connection Thread --- +def attempt_sio_connection(): + """Attempt to connect to Socket.IO server in a loop.""" + if not sio.connected: + try: + print("Attempting to connect to Socket.IO server...") + sio.connect(NODE_SERVER_URL, wait_timeout=5) + except socketio.exceptions.ConnectionError as e: + + print(f"Socket.IO connection attempt failed (will retry via client): {e}") + if 'status_label' in globals() and status_label.winfo_exists(): + root.after(0, update_status_label, "Socket.IO connection failed. Retrying...", "red") + except Exception as e: + print(f"Unexpected error during Socket.IO connection attempt: {e}") + if 'status_label' in globals() and status_label.winfo_exists(): + root.after(0, update_status_label, f"Socket.IO error: {e}", "red") + +def sio_connection_thread_func(): + while True: + if not sio.connected: + attempt_sio_connection() + time.sleep(10) + +# --- Initializations & Main Loop --- +def initial_gui_setup(): + update_poll_template_dropdown() + + + root.after(1000, fetch_all_poll_data_from_server) + + root.after(500, check_whatsapp_status) + + +def on_closing(): + if messagebox.askokcancel("Quit", "Do you want to quit the Poll Master application?"): + if sio.connected: + print("Disconnecting Socket.IO client...") + sio.disconnect() + root.destroy() + print("Application closed.") + +if __name__ == "__main__": + root.protocol("WM_DELETE_WINDOW", on_closing) + + sio_thread = threading.Thread(target=sio_connection_thread_func, daemon=True) + sio_thread.start() + + + root.after(100, initial_gui_setup) + + root.mainloop()