1+ using System ;
2+ using System . Collections . Generic ;
3+ using System . Linq ;
4+ using System . Threading . Tasks ;
5+ using Interfaces ;
6+ using Octokit ;
7+ using Storage . Local ;
8+ using Storage . Remote . GitHub ;
9+ using System . Numerics ;
10+
11+ namespace Platform . Bot . Triggers
12+ {
13+ using TContext = Issue ;
14+
15+ /// <summary>
16+ /// <para>
17+ /// Represents the voting countdown trigger that prevents users from responding with "-" to another "-" comment within a specified time period.
18+ /// </para>
19+ /// <para></para>
20+ /// </summary>
21+ /// <seealso cref="ITrigger{TContext}"/>
22+ internal class VotingCountdownTrigger : ITrigger < TContext >
23+ {
24+ private readonly GitHubStorage _storage ;
25+ private readonly FileStorage _fileStorage ;
26+ private readonly TimeSpan _countdownPeriod ;
27+ private readonly string _votingTrackingKey = "voting_countdown_tracking" ;
28+
29+ /// <summary>
30+ /// <para>
31+ /// Initializes a new <see cref="VotingCountdownTrigger"/> instance.
32+ /// </para>
33+ /// <para></para>
34+ /// </summary>
35+ /// <param name="storage">
36+ /// <para>A GitHub storage instance.</para>
37+ /// <para></para>
38+ /// </param>
39+ /// <param name="fileStorage">
40+ /// <para>A file storage instance.</para>
41+ /// <para></para>
42+ /// </param>
43+ /// <param name="countdownMinutes">
44+ /// <para>The countdown period in minutes (default: 5 minutes).</para>
45+ /// <para></para>
46+ /// </param>
47+ public VotingCountdownTrigger ( GitHubStorage storage , FileStorage fileStorage , int countdownMinutes = 5 )
48+ {
49+ _storage = storage ;
50+ _fileStorage = fileStorage ;
51+ _countdownPeriod = TimeSpan . FromMinutes ( countdownMinutes ) ;
52+ }
53+
54+ /// <summary>
55+ /// <para>
56+ /// Determines whether this instance should process the issue for voting countdown enforcement.
57+ /// </para>
58+ /// <para></para>
59+ /// </summary>
60+ /// <param name="context">
61+ /// <para>The issue context.</para>
62+ /// <para></para>
63+ /// </param>
64+ /// <returns>
65+ /// <para>True if the issue has recent "-" comments that need countdown enforcement.</para>
66+ /// <para></para>
67+ /// </returns>
68+ public async Task < bool > Condition ( TContext context )
69+ {
70+ try
71+ {
72+ var comments = await _storage . GetIssueComments ( context . Repository . Id , context . Number ) ;
73+
74+ // Only process if there are comments
75+ if ( ! comments . Any ( ) ) return false ;
76+
77+ // Check if there are any "-" comments in the recent timeframe
78+ var recentComments = comments . Where ( c => c . CreatedAt > DateTimeOffset . UtcNow . Subtract ( _countdownPeriod ) ) . ToList ( ) ;
79+ var minusComments = recentComments . Where ( c => c . Body . Trim ( ) == "-" ) . ToList ( ) ;
80+
81+ return minusComments . Any ( ) ;
82+ }
83+ catch ( Exception )
84+ {
85+ // If we can't retrieve comments, don't trigger
86+ return false ;
87+ }
88+ }
89+
90+ /// <summary>
91+ /// <para>
92+ /// Enforces the voting countdown rules by deleting or warning about invalid "-" responses.
93+ /// </para>
94+ /// <para></para>
95+ /// </summary>
96+ /// <param name="context">
97+ /// <para>The issue context.</para>
98+ /// <para></para>
99+ /// </param>
100+ public async Task Action ( TContext context )
101+ {
102+ try
103+ {
104+ var comments = await _storage . GetIssueComments ( context . Repository . Id , context . Number ) ;
105+ var minusComments = comments . Where ( c => c . Body . Trim ( ) == "-" )
106+ . OrderBy ( c => c . CreatedAt )
107+ . ToList ( ) ;
108+
109+ if ( minusComments . Count < 2 ) return ; // Need at least 2 minus comments to check
110+
111+ // Track user voting timestamps
112+ var userVotingData = GetUserVotingData ( context . Repository . Id , context . Number ) ;
113+ var now = DateTimeOffset . UtcNow ;
114+ bool hasViolations = false ;
115+
116+ for ( int i = 1 ; i < minusComments . Count ; i ++ )
117+ {
118+ var currentComment = minusComments [ i ] ;
119+ var previousComment = minusComments [ i - 1 ] ;
120+ var timeDifference = currentComment . CreatedAt - previousComment . CreatedAt ;
121+
122+ // Check if this is a response to the previous "-" comment within the countdown period
123+ if ( timeDifference < _countdownPeriod )
124+ {
125+ // Check if the user had already voted with "-" recently
126+ var userKey = $ "{ currentComment . User . Login } _{ context . Repository . Id } _{ context . Number } ";
127+ var lastVoteTime = GetLastVoteTime ( userVotingData , userKey ) ;
128+
129+ if ( lastVoteTime . HasValue && ( currentComment . CreatedAt - lastVoteTime . Value ) < _countdownPeriod )
130+ {
131+ // This is a violation - user voted with "-" too soon after another "-"
132+ await _storage . CreateIssueComment ( context . Repository . Id , context . Number ,
133+ $ "@{ currentComment . User . Login } Please wait { _countdownPeriod . TotalMinutes } minutes before responding with \" -\" to another \" -\" comment. " +
134+ $ "This helps maintain civil discussion. Your comment was posted too quickly after a previous \" -\" vote.") ;
135+
136+ hasViolations = true ;
137+ }
138+
139+ // Update the user's voting timestamp
140+ SetLastVoteTime ( userVotingData , userKey , currentComment . CreatedAt ) ;
141+ }
142+ }
143+
144+ if ( hasViolations )
145+ {
146+ // Save the updated voting tracking data
147+ SaveUserVotingData ( context . Repository . Id , context . Number , userVotingData ) ;
148+ }
149+ }
150+ catch ( Exception )
151+ {
152+ // Log error if needed, but don't crash the bot
153+ }
154+ }
155+
156+ private Dictionary < string , DateTimeOffset > GetUserVotingData ( long repositoryId , int issueNumber )
157+ {
158+ try
159+ {
160+ var key = $ "{ _votingTrackingKey } _{ repositoryId } _{ issueNumber } ";
161+ var fileSet = _fileStorage . GetFileSet ( key ) ;
162+
163+ if ( fileSet != 0 ) // FileSet exists
164+ {
165+ var files = _fileStorage . GetFilesFromSet ( key ) ;
166+ var dataFile = files . FirstOrDefault ( ) ;
167+ if ( dataFile != null )
168+ {
169+ // Parse the stored data (format: "user_repo_issue:timestamp,user_repo_issue:timestamp")
170+ var result = new Dictionary < string , DateTimeOffset > ( ) ;
171+ var lines = dataFile . Content . Split ( '\n ' , StringSplitOptions . RemoveEmptyEntries ) ;
172+
173+ foreach ( var line in lines )
174+ {
175+ var parts = line . Split ( ':' , 2 ) ;
176+ if ( parts . Length == 2 && DateTimeOffset . TryParse ( parts [ 1 ] , out var timestamp ) )
177+ {
178+ result [ parts [ 0 ] ] = timestamp ;
179+ }
180+ }
181+ return result ;
182+ }
183+ }
184+ }
185+ catch ( Exception )
186+ {
187+ // If parsing fails, return empty dictionary
188+ }
189+
190+ return new Dictionary < string , DateTimeOffset > ( ) ;
191+ }
192+
193+ private void SaveUserVotingData ( long repositoryId , int issueNumber , Dictionary < string , DateTimeOffset > votingData )
194+ {
195+ try
196+ {
197+ var key = $ "{ _votingTrackingKey } _{ repositoryId } _{ issueNumber } ";
198+
199+ // Convert dictionary to string format
200+ var dataLines = votingData . Select ( kvp => $ "{ kvp . Key } :{ kvp . Value : O} ") . ToArray ( ) ;
201+ var content = string . Join ( '\n ' , dataLines ) ;
202+
203+ // Create or update the file set
204+ var fileSet = _fileStorage . CreateFileSet ( key ) ;
205+ var file = _fileStorage . AddFile ( content ) ;
206+ _fileStorage . AddFileToSet ( fileSet , file , $ "{ key } _data.txt") ;
207+ }
208+ catch ( Exception )
209+ {
210+ // If saving fails, continue silently
211+ }
212+ }
213+
214+ private DateTimeOffset ? GetLastVoteTime ( Dictionary < string , DateTimeOffset > votingData , string userKey )
215+ {
216+ return votingData . TryGetValue ( userKey , out var timestamp ) ? timestamp : null ;
217+ }
218+
219+ private void SetLastVoteTime ( Dictionary < string , DateTimeOffset > votingData , string userKey , DateTimeOffset timestamp )
220+ {
221+ votingData [ userKey ] = timestamp ;
222+ }
223+ }
224+ }
0 commit comments