All files / src/processors WithdrawalProcessor.js

100% Statements 66/66
90% Branches 27/30
100% Functions 8/8
100% Lines 63/63

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172                                33x 33x           9x     9x 9x 2x 2x     7x 7x     7x 7x 7x   7x 1x 1x       6x   5x   4x 1x       3x 2x 2x                 3x 3x     1x 1x               16x 8x         8x 8x 1x 1x   1x           27x 7x 134x     7x 4x     3x         6x 6x 2x       4x           6x 6x 1x 1x         5x 4x 4x 4x 1x 1x   3x         4x 4x 4x   4x 4x                           4x 4x       1x         10x
/**
 * WithdrawalProcessor - Handles proposal withdrawals and resolution removal
 * 
 * Manages the complex process of finding and removing passed resolutions through
 * democratic withdrawal proposals. Enables the community to reverse previous decisions
 * when circumstances change or errors are discovered.
 * 
 * Design rationale:
 * - Democratic reversibility: Communities can correct mistakes through proper process
 * - Resolution matching: Intelligent search to find target resolutions across channels
 * - Audit trail: Maintains record of what was withdrawn and when
 * - Safe removal: Validates withdrawal targets to prevent accidental deletions
 * - Multi-format support: Handles various ways users might reference resolutions
 */
class WithdrawalProcessor {
    constructor(bot, proposalConfig) {
        this.bot = bot;
        this.proposalConfig = proposalConfig;
    }
 
    // Parse withdrawal proposal to find the target resolution to remove
    // Searches resolution channels to locate the specific resolution being withdrawn
    async parseWithdrawalTarget(content, proposalType, config) {
        try {
            // Extract resolution reference from withdrawal proposal text
            // Expected format: **Withdraw**: [Resolution description/link]
            const withdrawMatch = content.match(/\*\*Withdraw\*\*:\s*(.+)/i);
            if (!withdrawMatch) {
                console.log('No withdrawal content found');
                return null;
            }
 
            const withdrawalContent = withdrawMatch[1].trim();
            console.log(`Looking for resolution to withdraw: "${withdrawalContent}"`);
 
            // Access the resolutions channel to search for target resolution
            const guild = this.bot.client.guilds.cache.get(this.bot.getGuildId());
            const resolutionsChannelId = config.resolutionsChannelId;
            const resolutionsChannel = guild.channels.cache.get(resolutionsChannelId);
 
            if (!resolutionsChannel) {
                console.error(`Resolutions channel ${resolutionsChannelId} not found`);
                return null;
            }
 
            // Fetch recent messages to find the target resolution
            const messages = await resolutionsChannel.messages.fetch({ limit: 100 });
            
            for (const [messageId, message] of messages) {
                // Skip if not a resolution message
                if (!message.content.includes('PASSED') || !message.content.includes('RESOLUTION')) {
                    continue;
                }
 
                // Check if this resolution matches the withdrawal request
                if (this.isMatchingResolution(message.content, withdrawalContent)) {
                    console.log(`Found matching resolution: ${messageId}`);
                    return {
                        messageId: messageId,
                        channelId: resolutionsChannelId,
                        content: message.content,
                        originalContent: this.extractOriginalResolution(message.content)
                    };
                }
            }
 
            console.log('No matching resolution found');
            return null;
 
        } catch (error) {
            console.error('Error parsing withdrawal target:', error);
            return null;
        }
    }
 
    // Determine if a resolution matches the withdrawal target using multiple strategies
    // Uses fuzzy matching to handle variations in wording and partial references
    isMatchingResolution(resolutionContent, withdrawalTarget) {
        // Strategy 1: Direct substring matching for exact references
        if (resolutionContent.toLowerCase().includes(withdrawalTarget.toLowerCase())) {
            return true;
        }
 
        // Strategy 2: Extract and compare policy text specifically
        // Focuses on the actual policy content rather than metadata
        const policyMatch = resolutionContent.match(/\*\*(?:Policy|Governance|Resolution)\*\*:\s*(.+?)(?:\n|$)/i);
        if (policyMatch) {
            const policyText = policyMatch[1].trim();
            Eif (policyText.toLowerCase().includes(withdrawalTarget.toLowerCase()) ||
                withdrawalTarget.toLowerCase().includes(policyText.toLowerCase())) {
                return true;
            }
        }
 
        // Strategy 3: Keyword overlap analysis for partial matches
        // Handles cases where withdrawal references don't exactly match resolution text
        const withdrawalWords = withdrawalTarget.toLowerCase().split(/\s+/).filter(w => w.length > 3);
        const resolutionWords = resolutionContent.toLowerCase().split(/\s+/);
        const matchCount = withdrawalWords.filter(word => resolutionWords.some(rw => rw.includes(word))).length;
        
        // Require 60% keyword overlap to minimize false matches
        if (withdrawalWords.length > 0 && matchCount / withdrawalWords.length >= 0.6) {
            return true;
        }
 
        return false;
    }
 
    extractOriginalResolution(resolutionContent) {
        // Extract the original proposal text from a resolution message
        const resolutionMatch = resolutionContent.match(/\*\*Resolution:\*\*\s*(.+?)(?:\n\*|$)/s);
        if (resolutionMatch) {
            return resolutionMatch[1].trim();
        }
        
        // Fallback: return the whole resolution content
        return resolutionContent;
    }
 
    // Execute the withdrawal by removing the target resolution and posting notification
    // Creates permanent record of the withdrawal for transparency and accountability
    async processWithdrawal(proposal, guild) {
        try {
            if (!proposal.targetResolution) {
                console.error('No target resolution found for withdrawal');
                return;
            }
 
            // Remove the original resolution message from the resolutions channel
            // This effectively revokes the policy from active status
            const resolutionsChannel = guild.channels.cache.get(proposal.targetResolution.channelId);
            Eif (resolutionsChannel) {
                try {
                    const targetMessage = await resolutionsChannel.messages.fetch(proposal.targetResolution.messageId);
                    await targetMessage.delete();
                    console.log(`Deleted resolution message ${proposal.targetResolution.messageId}`);
                } catch (error) {
                    console.error('Could not delete target resolution:', error);
                }
            }
 
            // Post withdrawal notification in resolutions channel
            const proposalTypeConfig = this.proposalConfig[proposal.proposalType];
            const resolutionsChannelId = proposalTypeConfig.resolutionsChannelId;
            const resolutionsChannelForNotification = guild.channels.cache.get(resolutionsChannelId);
            
            Eif (resolutionsChannelForNotification) {
                const withdrawalContent = `🗑️ **WITHDRAWN ${proposal.proposalType.toUpperCase()} RESOLUTION**
 
**Withdrawn by:** <@${proposal.authorId}>
**Withdrawn on:** <t:${Math.floor(Date.parse(proposal.completedAt) / 1000)}:F>
**Final Vote:** ✅ ${proposal.finalYes} - ❌ ${proposal.finalNo}
 
**Original Resolution (now withdrawn):**
${proposal.targetResolution.originalContent}
 
**Withdrawal Proposal:**
${proposal.content}
 
*This resolution has been officially withdrawn and is no longer active policy.*`;
 
                await resolutionsChannelForNotification.send(withdrawalContent);
                console.log(`Withdrawal notification posted to ${resolutionsChannelId}`);
            }
            
        } catch (error) {
            console.error('Error processing withdrawal:', error);
        }
    }
}
 
module.exports = WithdrawalProcessor;