Skip to content

Commit 91a06dc

Browse files
committed
feat: add thread support to Discord messaging and implement direct text message API endpoint
1 parent a53e747 commit 91a06dc

3 files changed

Lines changed: 125 additions & 15 deletions

File tree

internal/modules/gobot/api.go

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ func setupAPI() *gin.Engine {
165165
internal := r.Group("/internal")
166166
{
167167
internal.POST("/send-file", sendFileToDiscord)
168+
internal.POST("/send-message", sendMessageToDiscord)
168169
}
169170

170171
// List all scans
@@ -1533,6 +1534,7 @@ func sendFileToDiscord(c *gin.Context) {
15331534
FilePath string `json:"file_path" binding:"required"`
15341535
Description string `json:"description"`
15351536
ChannelID string `json:"channel_id"`
1537+
ThreadID string `json:"thread_id"`
15361538
}
15371539

15381540
if err := c.ShouldBindJSON(&req); err != nil {
@@ -1575,11 +1577,8 @@ func sendFileToDiscord(c *gin.Context) {
15751577
log.Printf("[API] [sendFileToDiscord] Using channel ID: %s", channelID)
15761578

15771579
// Check if we should send to a thread instead of the channel.
1578-
// IMPORTANT: Only use the explicit scan ID to look up the thread.
1579-
// Do NOT fall back to searching all scans by channel ID — when multiple scans run
1580-
// in the same channel this would pick the wrong (first matching) thread.
1581-
threadID := ""
1582-
if req.ScanID != "" {
1580+
threadID := req.ThreadID
1581+
if threadID == "" && req.ScanID != "" {
15831582
scansMutex.RLock()
15841583
if scan, ok := activeScans[req.ScanID]; ok && scan.ThreadID != "" {
15851584
threadID = scan.ThreadID
@@ -1652,7 +1651,7 @@ func sendFileToDiscord(c *gin.Context) {
16521651
message := fmt.Sprintf("%s\n\n📦 **File too large for Discord** (%.2f MB)\n🔗 **Download:** %s", description, float64(fileInfo.Size())/1024/1024, publicURL)
16531652
// Small delay to respect Discord rate limits
16541653
time.Sleep(500 * time.Millisecond)
1655-
_, err = session.ChannelMessageSend(channelID, message)
1654+
_, err = session.ChannelMessageSend(targetID, message)
16561655
if err != nil {
16571656
log.Printf("[API] [sendFileToDiscord] [ERROR] Failed to send R2 link to Discord: %v", err)
16581657
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send R2 link: %v", err)})
@@ -1737,7 +1736,72 @@ func sendFileToDiscord(c *gin.Context) {
17371736
}
17381737

17391738
log.Printf("[API] [sendFileToDiscord] [SUCCESS] File sent successfully to Discord channel %s", channelID)
1740-
c.JSON(http.StatusOK, gin.H{"message": "file sent successfully"})
1739+
c.JSON(http.StatusOK, gin.H{"message": "file scheduled for processing"})
1740+
}
1741+
1742+
// sendMessageToDiscord handles posting text messages to Discord from modules
1743+
func sendMessageToDiscord(c *gin.Context) {
1744+
log.Printf("[API] [sendMessageToDiscord] Received message send request")
1745+
1746+
var req struct {
1747+
ScanID string `json:"scan_id"`
1748+
Message string `json:"message" binding:"required"`
1749+
ChannelID string `json:"channel_id"`
1750+
ThreadID string `json:"thread_id"`
1751+
}
1752+
1753+
if err := c.ShouldBindJSON(&req); err != nil {
1754+
log.Printf("[API] [sendMessageToDiscord] [ERROR] Failed to bind JSON: %v", err)
1755+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
1756+
return
1757+
}
1758+
1759+
channelID := req.ChannelID
1760+
if channelID == "" && req.ScanID != "" {
1761+
channelID = getChannelID(req.ScanID)
1762+
}
1763+
if channelID == "" {
1764+
channelID = os.Getenv("AUTOAR_CURRENT_CHANNEL_ID")
1765+
}
1766+
if channelID == "" {
1767+
channelID = os.Getenv("DISCORD_DEFAULT_CHANNEL_ID")
1768+
}
1769+
if channelID == "" {
1770+
c.JSON(http.StatusBadRequest, gin.H{"error": "no channel ID found"})
1771+
return
1772+
}
1773+
1774+
threadID := req.ThreadID
1775+
if threadID == "" && req.ScanID != "" {
1776+
scansMutex.RLock()
1777+
if scan, ok := activeScans[req.ScanID]; ok && scan.ThreadID != "" {
1778+
threadID = scan.ThreadID
1779+
}
1780+
scansMutex.RUnlock()
1781+
}
1782+
1783+
targetID := channelID
1784+
if threadID != "" {
1785+
targetID = threadID
1786+
}
1787+
1788+
discordSessionMutex.RLock()
1789+
session := globalDiscordSession
1790+
discordSessionMutex.RUnlock()
1791+
1792+
if session == nil {
1793+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Discord bot not available"})
1794+
return
1795+
}
1796+
1797+
_, err := session.ChannelMessageSend(targetID, req.Message)
1798+
if err != nil {
1799+
log.Printf("[API] [sendMessageToDiscord] [ERROR] Failed to send message: %v", err)
1800+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send message: %v", err)})
1801+
return
1802+
}
1803+
1804+
c.JSON(http.StatusOK, gin.H{"message": "message sent successfully"})
17411805
}
17421806

17431807
// getEnv is defined in main.go

internal/modules/utils/discord.go

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func SendPhaseFiles(phaseName, domain string, filePaths []string) error {
2727
// Get channel ID and scan ID from environment (set by bot)
2828
channelID := os.Getenv("AUTOAR_CURRENT_CHANNEL_ID")
2929
scanID := os.Getenv("AUTOAR_CURRENT_SCAN_ID")
30+
threadID := os.Getenv("AUTOAR_CURRENT_THREAD_ID")
3031

3132
// Debug logs only (not sent to webhook)
3233
log.Printf("[DEBUG] [DISCORD] Attempting to send phase files for phase: %s, domain: %s", phaseName, domain)
@@ -77,7 +78,11 @@ func SendPhaseFiles(phaseName, domain string, filePaths []string) error {
7778
if len(existingFiles) == 0 {
7879
log.Printf("[DEBUG] [DISCORD] No valid files to send for phase %s", phaseName)
7980
msg := phaseNoResultsMessage(phaseName, domain)
80-
SendWebhookLogAsync(msg)
81+
82+
if err := sendStringToDiscordHTTP(apiHost, apiPort, channelID, scanID, threadID, msg); err != nil {
83+
log.Printf("[DEBUG] [DISCORD] HTTP API message send failed: %v, falling back to webhook", err)
84+
SendWebhookLogAsync(msg)
85+
}
8186
return nil
8287
}
8388

@@ -103,8 +108,8 @@ func SendPhaseFiles(phaseName, domain string, filePaths []string) error {
103108
}
104109

105110
// Send file (will use bot if available, otherwise webhook, even if no channel ID)
106-
// sendSingleFileToDiscord handles webhook fallback when channelID is empty
107-
if err := sendSingleFileToDiscordWithDescription(apiHost, apiPort, channelID, scanID, phaseName, filePath, description); err != nil {
111+
// sendSingleFileToDiscordWithDescription handles webhook fallback when channelID is empty
112+
if err := sendSingleFileToDiscordWithDescription(apiHost, apiPort, channelID, scanID, threadID, filePath, description); err != nil {
108113
log.Printf("[DEBUG] [DISCORD] Failed to send file %s: %v", fileName, err)
109114
failCount++
110115
} else {
@@ -122,11 +127,49 @@ func SendPhaseFiles(phaseName, domain string, filePaths []string) error {
122127
return nil
123128
}
124129

125-
func sendSingleFileToDiscord(apiHost, apiPort, channelID, scanID, phaseName, filePath string) error {
126-
return sendSingleFileToDiscordWithDescription(apiHost, apiPort, channelID, scanID, phaseName, filePath, filepath.Base(filePath))
130+
func sendStringToDiscordHTTP(apiHost, apiPort, channelID, scanID, threadID, message string) error {
131+
if scanID == "" && channelID == "" {
132+
return fmt.Errorf("no scan_id or channel_id provided")
133+
}
134+
135+
reqBody := map[string]string{
136+
"message": message,
137+
"channel_id": channelID,
138+
}
139+
if scanID != "" {
140+
reqBody["scan_id"] = scanID
141+
}
142+
if threadID != "" {
143+
reqBody["thread_id"] = threadID
144+
}
145+
146+
jsonData, err := json.Marshal(reqBody)
147+
if err != nil {
148+
return err
149+
}
150+
151+
url := fmt.Sprintf("http://%s:%s/internal/send-message", apiHost, apiPort)
152+
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
153+
if err != nil {
154+
return err
155+
}
156+
req.Header.Set("Content-Type", "application/json")
157+
158+
client := &http.Client{Timeout: 10 * time.Second}
159+
resp, err := client.Do(req)
160+
if err != nil {
161+
return err
162+
}
163+
defer resp.Body.Close()
164+
165+
if resp.StatusCode == http.StatusOK {
166+
return nil
167+
}
168+
bodyBytes, _ := io.ReadAll(resp.Body)
169+
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
127170
}
128171

129-
func sendSingleFileToDiscordWithDescription(apiHost, apiPort, channelID, scanID, phaseName, filePath, description string) error {
172+
func sendSingleFileToDiscordWithDescription(apiHost, apiPort, channelID, scanID, threadID, filePath, description string) error {
130173

131174
// If channel ID is provided, try bot first (for Discord bot context)
132175
// If no channel ID (CLI usage), skip bot and go straight to HTTP API/webhook
@@ -162,6 +205,9 @@ func sendSingleFileToDiscordWithDescription(apiHost, apiPort, channelID, scanID,
162205
reqBody["scan_id"] = scanID
163206
log.Printf("[DEBUG] [DISCORD] Including scan_id in HTTP API request: %s", scanID)
164207
}
208+
if threadID != "" {
209+
reqBody["thread_id"] = threadID
210+
}
165211

166212
jsonData, err := json.Marshal(reqBody)
167213
if err == nil {

internal/modules/utils/file.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ func uploadResultAsync(path string) {
4545
r2Key := path
4646
if idx := strings.Index(path, "new-results/"); idx >= 0 {
4747
r2Key = path[idx:]
48-
} else if strings.HasPrefix(r2Key, "/") {
49-
r2Key = r2Key[1:]
48+
} else {
49+
r2Key = strings.TrimPrefix(r2Key, "/")
5050
}
5151
r2storage.UploadResultFileAndLog(path, r2Key)
5252
log.Printf("[R2] ✅ Auto-uploaded result file: %s", path)

0 commit comments

Comments
 (0)