1+ package org .keycloak .gh .bot .email ;
2+
3+ import jakarta .enterprise .context .ApplicationScoped ;
4+ import jakarta .inject .Inject ;
5+ import org .eclipse .microprofile .config .inject .ConfigProperty ;
6+ import org .jboss .logging .Logger ;
7+ import org .kohsuke .github .GHIssue ;
8+ import org .kohsuke .github .GHIssueComment ;
9+ import org .kohsuke .github .GHReaction ;
10+ import org .kohsuke .github .ReactionContent ;
11+
12+ import java .io .IOException ;
13+ import java .time .Instant ;
14+ import java .time .temporal .ChronoUnit ;
15+ import java .util .Collections ;
16+ import java .util .Date ;
17+ import java .util .LinkedHashMap ;
18+ import java .util .List ;
19+ import java .util .Map ;
20+ import java .util .Optional ;
21+ import java .util .Set ;
22+ import java .util .regex .Matcher ;
23+ import java .util .regex .Pattern ;
24+
25+ /**
26+ * Handles the processing of GitHub comments to identify and execute bot commands
27+ */
28+ @ ApplicationScoped
29+ public class CommandProcessor {
30+
31+ private static final Logger LOG = Logger .getLogger (CommandProcessor .class );
32+ private static final Pattern VISIBLE_MARKER_PATTERN = Pattern .compile ("\\ *\\ *Gmail-Thread-ID:\\ *\\ *\\ s*([a-f0-9]+)" );
33+ private static final Pattern RAW_HEX_PATTERN = Pattern .compile ("\\ b([a-f0-9]{16})\\ b" );
34+
35+ private static final int MAX_PROCESSED_HISTORY = 10000 ;
36+
37+ @ ConfigProperty (name = "google.group.target" )
38+ String targetGroup ;
39+
40+ @ ConfigProperty (name = "email.target.secalert" )
41+ String secAlertEmail ;
42+
43+ @ Inject GitHubAdapter github ;
44+ @ Inject CommandParser parser ;
45+ @ Inject MailSender mailSender ;
46+
47+ private final Set <Long > processedComments = Collections .synchronizedSet (Collections .newSetFromMap (
48+ new LinkedHashMap <Long , Boolean >(MAX_PROCESSED_HISTORY + 1 , .75F , true ) {
49+ @ Override
50+ protected boolean removeEldestEntry (Map .Entry <Long , Boolean > eldest ) {
51+ return size () > MAX_PROCESSED_HISTORY ;
52+ }
53+ }));
54+
55+ private Instant lastPollTime = Instant .now ().minus (10 , ChronoUnit .MINUTES );
56+
57+ public void processCommands () {
58+ try {
59+ String myLogin = parser .getBotName ();
60+ if (myLogin == null || myLogin .isEmpty ()) return ;
61+
62+ Instant executionStart = Instant .now ();
63+
64+ Date querySince = Date .from (lastPollTime .minus (1 , ChronoUnit .MINUTES ));
65+ List <GHIssue > updatedIssues = github .getIssuesUpdatedSince (querySince );
66+
67+ for (GHIssue issue : updatedIssues ) {
68+ try {
69+ scanIssue (issue , myLogin );
70+ } catch (Exception e ) {
71+ LOG .errorf (e , "Failed to scan issue #%d" , issue .getNumber ());
72+ }
73+ }
74+
75+ lastPollTime = executionStart ;
76+ } catch (Exception e ) {
77+ LOG .error ("Fatal error fetching updated issues" , e );
78+ }
79+ }
80+
81+ private void scanIssue (GHIssue issue , String myLogin ) throws IOException {
82+ Optional <String > threadIdOpt = findThreadIdInComments (issue );
83+
84+ List <GHIssueComment > recentComments = issue .queryComments ()
85+ .since (Date .from (lastPollTime .minus (1 , ChronoUnit .MINUTES )))
86+ .list ()
87+ .toList ();
88+
89+ for (GHIssueComment comment : recentComments ) {
90+ if (hasAlreadyProcessed (comment , myLogin )) continue ;
91+
92+ parser .parse (comment .getBody ()).ifPresent (cmd -> executeCommand (issue , comment , cmd , threadIdOpt ));
93+ }
94+ }
95+
96+ private void executeCommand (GHIssue issue , GHIssueComment comment , CommandParser .Command cmd , Optional <String > threadId ) {
97+ boolean success = false ;
98+ ReactionContent reaction = ReactionContent .EYES ;
99+
100+ switch (cmd .type ()) {
101+ case NEW_SECALERT :
102+ success = mailSender .sendNewEmail (secAlertEmail , targetGroup , cmd .subject ().orElse ("No Subject" ), cmd .body ());
103+ if (!success ) {
104+ replyWithError (issue , comment , "❌ Error: Failed to send email via Gmail API." );
105+ reaction = ReactionContent .CONFUSED ;
106+ }
107+ break ;
108+ case REPLY_KEYCLOAK_SECURITY :
109+ if (threadId .isPresent ()) {
110+ success = mailSender .sendReply (threadId .get (), issue .getTitle (), cmd .body (), targetGroup );
111+ if (!success ) {
112+ replyWithError (issue , comment , "❌ Error: Failed to send email via Gmail API." );
113+ reaction = ReactionContent .CONFUSED ;
114+ }
115+ } else {
116+ replyWithError (issue , comment , "❌ Error: Gmail Thread ID not found." );
117+ success = true ;
118+ reaction = ReactionContent .CONFUSED ;
119+ }
120+ break ;
121+ case UNKNOWN :
122+ sendHelpMessage (issue , comment );
123+ success = true ;
124+ reaction = ReactionContent .CONFUSED ;
125+ break ;
126+ }
127+
128+ if (success ) {
129+ processedComments .add (comment .getId ());
130+ addReaction (comment , reaction );
131+ LOG .debugf ("✅ Command executed: %s" , cmd .type ());
132+ }
133+ }
134+
135+ private void addReaction (GHIssueComment comment , ReactionContent reaction ) {
136+ try {
137+ comment .createReaction (reaction );
138+ } catch (IOException e ) {
139+ LOG .errorf ("Failed to react to comment %d" , comment .getId ());
140+ }
141+ }
142+
143+ private void replyWithError (GHIssue issue , GHIssueComment comment , String message ) {
144+ try {
145+ github .commentOnIssue (issue , "@" + comment .getUser ().getLogin () + " " + message );
146+ } catch (IOException e ) {
147+ LOG .error ("Failed to send error reply" , e );
148+ }
149+ }
150+
151+ private void sendHelpMessage (GHIssue issue , GHIssueComment comment ) {
152+ try {
153+ String body = "@" + comment .getUser ().getLogin () + " " + parser .getHelpMessage ();
154+ github .commentOnIssue (issue , body );
155+ } catch (IOException e ) {
156+ LOG .error ("Failed to send help message" , e );
157+ }
158+ }
159+
160+ private boolean hasAlreadyProcessed (GHIssueComment comment , String myLogin ) throws IOException {
161+ if (processedComments .contains (comment .getId ())) return true ;
162+
163+ for (GHReaction reaction : comment .listReactions ()) {
164+ String user = reaction .getUser ().getLogin ();
165+ if ((reaction .getContent () == ReactionContent .EYES || reaction .getContent () == ReactionContent .CONFUSED ) &&
166+ (user .equalsIgnoreCase (myLogin ) || user .equalsIgnoreCase (myLogin + "[bot]" ))) {
167+ processedComments .add (comment .getId ());
168+ return true ;
169+ }
170+ }
171+ return false ;
172+ }
173+
174+ private Optional <String > findThreadIdInComments (GHIssue issue ) throws IOException {
175+ for (GHIssueComment comment : issue .getComments ()) {
176+ Matcher m = VISIBLE_MARKER_PATTERN .matcher (comment .getBody ());
177+ if (m .find ()) return Optional .of (m .group (1 ).trim ());
178+ Matcher raw = RAW_HEX_PATTERN .matcher (comment .getBody ());
179+ if (raw .find ()) return Optional .of (raw .group (1 ).trim ());
180+ }
181+ return Optional .empty ();
182+ }
183+ }
0 commit comments