package main import ( "bytes" "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "sync" "time" "github.com/gorilla/websocket" ) // Configuration from environment variables var ( phoneNumber string receiveURL string removeAttachmentURL string sendURL = "http://localhost:9922/v2/send" groupID string groupIDSend string catAPI string lastCommandTime time.Time warningSent bool commandMutex sync.Mutex ) // StringCounter tracks message counts by user UUID type StringCounter struct { StringMap map[string]UserCount mu sync.RWMutex } // UserCount stores common name and message count type UserCount struct { CommonName string `json:"common_name"` Count int `json:"count"` } // NewStringCounter creates a new StringCounter func NewStringCounter() *StringCounter { return &StringCounter{ StringMap: make(map[string]UserCount), } } // UpdateStringMap updates the count for a given user func (sc *StringCounter) UpdateStringMap(key, commonName string) map[string]UserCount { sc.mu.Lock() defer sc.mu.Unlock() if uc, exists := sc.StringMap[key]; exists { uc.Count++ sc.StringMap[key] = uc } else { sc.StringMap[key] = UserCount{CommonName: commonName, Count: 1} } return sc.StringMap } // GetCommonName returns the common name for a UUID func (sc *StringCounter) GetCommonName(key string) string { sc.mu.RLock() defer sc.mu.RUnlock() if uc, exists := sc.StringMap[key]; exists { return uc.CommonName } return "" } // Reset clears the string map func (sc *StringCounter) Reset() { sc.mu.Lock() defer sc.mu.Unlock() sc.StringMap = make(map[string]UserCount) } // GetStringMapJSON returns the string map as JSON func (sc *StringCounter) GetStringMapJSON() string { sc.mu.RLock() defer sc.mu.RUnlock() data, err := json.Marshal(sc.StringMap) if err != nil { return "{}" } return string(data) } // Message structures for parsing Signal messages type SignalMessage struct { Envelope Envelope `json:"envelope"` Account string `json:"account"` } type Envelope struct { Source string `json:"source"` SourceNumber string `json:"sourceNumber"` SourceUuid string `json:"sourceUuid"` SourceName string `json:"sourceName"` SourceDevice int `json:"sourceDevice"` Timestamp int64 `json:"timestamp"` DataMessage DataMessage `json:"dataMessage,omitempty"` SyncMessage SyncMessage `json:"syncMessage,omitempty"` } type DataMessage struct { Timestamp int64 `json:"timestamp"` Message string `json:"message"` ExpiresInSeconds int `json:"expiresInSeconds"` ViewOnce bool `json:"viewOnce"` GroupInfo GroupInfo `json:"groupInfo,omitempty"` Sticker Sticker `json:"sticker,omitempty"` Reaction Reaction `json:"reaction,omitempty"` } type SyncMessage struct { SentMessage SentMessage `json:"sentMessage,omitempty"` Reaction Reaction `json:"reaction,omitempty"` } type SentMessage struct { Destination string `json:"destination"` DestinationNumber string `json:"destinationNumber"` DestinationUuid string `json:"destinationUuid"` Timestamp int64 `json:"timestamp"` Message string `json:"message"` ExpiresInSeconds int `json:"expiresInSeconds"` ViewOnce bool `json:"viewOnce"` Sticker Sticker `json:"sticker,omitempty"` GroupInfo GroupInfo `json:"groupInfo,omitempty"` } type GroupInfo struct { GroupID string `json:"groupId"` Type string `json:"type"` } type Sticker struct { PackID string `json:"packId,omitempty"` StickerID int `json:"stickerId,omitempty"` } type Reaction struct { Emoji string `json:"emoji,omitempty"` TargetAuthor string `json:"targetAuthor,omitempty"` TargetTimestamp int64 `json:"targetTimestamp,omitempty"` } // SendMessageRequest is the request body for sending messages type SendMessageRequest struct { Message string `json:"message,omitempty"` Base64Attachments []string `json:"base64_attachments,omitempty"` Number string `json:"number"` Recipients []string `json:"recipients"` } // Command triggers // These include various Unicode variants for compatibility with different input methods // and to match the original Python implementation's command variations var catCommands = []string{"!kot", "!koty", "!kots", "!cat", "!cats", "!meow", "!miau", "!ᴋᴏᴛ", "!𝓴𝓸𝓽", "!𝗸𝗼𝘁"} var dogCommands = []string{"!pies", "!psy", "!dog", "!dogs", "!woof", "!szczek", "!𝗽𝗶𝗲𝘀", "!͓̽p͓̽i͓̽e͓̽s͓̽"} func init() { phoneNumber = getEnv("PHONE_NUMBER", "1234567890") receiveURL = fmt.Sprintf("http://localhost:9922/v1/receive/%s", phoneNumber) removeAttachmentURL = "http://localhost:9922/v1/attachments/" groupID = getEnv("GROUP_ID", "") groupIDSend = getEnv("GROUP_ID_SEND", "") catAPI = getEnv("CAT_API", "") } func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // downloadImage downloads an image from URL and returns base64 encoded data func downloadImage(imageURL string) (string, error) { resp, err := http.Get(imageURL) if err != nil { return "", fmt.Errorf("failed to download image: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("image download failed with status: %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read image data: %w", err) } return base64.StdEncoding.EncodeToString(data), nil } // fetchCatImage fetches a random cat image from TheCatAPI func fetchCatImage() (string, error) { resp, err := http.Get("https://api.thecatapi.com/v1/images/search") if err != nil { return "", fmt.Errorf("failed to fetch cat API: %w", err) } defer resp.Body.Close() var result []map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to decode cat API response: %w", err) } if len(result) == 0 { return "", fmt.Errorf("no cat images found") } imageURL, ok := result[0]["url"].(string) if !ok { return "", fmt.Errorf("invalid cat image URL") } return downloadImage(imageURL) } // fetchDogImage fetches a random dog image from Dog CEO API func fetchDogImage() (string, error) { resp, err := http.Get("https://dog.ceo/api/breeds/image/random") if err != nil { return "", fmt.Errorf("failed to fetch dog API: %w", err) } defer resp.Body.Close() var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to decode dog API response: %w", err) } imageURL, ok := result["message"].(string) if !ok { return "", fmt.Errorf("invalid dog image URL") } return downloadImage(imageURL) } // sendImage sends a base64 encoded image to a recipient func sendImage(base64Data, recipient string) error { reqBody := SendMessageRequest{ Base64Attachments: []string{base64Data}, Number: phoneNumber, Recipients: []string{recipient}, } jsonData, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } resp, err := http.Post(sendURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to send image: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { log.Println("Image sent successfully.") return nil } body, _ := io.ReadAll(resp.Body) return fmt.Errorf("send image failed with status %d: %s", resp.StatusCode, string(body)) } // sendMessage sends a text message to a recipient func sendMessage(messageContent, recipient string) error { reqBody := SendMessageRequest{ Message: messageContent, Number: phoneNumber, Recipients: []string{recipient}, } jsonData, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } resp, err := http.Post(sendURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to send message: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { log.Println("Message sent successfully.") return nil } body, _ := io.ReadAll(resp.Body) return fmt.Errorf("send message failed with status %d: %s", resp.StatusCode, string(body)) } // extractMessageContent extracts the message content from a SignalMessage func extractMessageContent(msg *SignalMessage) *DataMessage { if msg.Envelope.DataMessage.Timestamp != 0 || msg.Envelope.DataMessage.Message != "" { return &msg.Envelope.DataMessage } if msg.Envelope.SyncMessage.SentMessage.Timestamp != 0 { // Convert SentMessage to DataMessage-like struct return &DataMessage{ Timestamp: msg.Envelope.SyncMessage.SentMessage.Timestamp, Message: msg.Envelope.SyncMessage.SentMessage.Message, ExpiresInSeconds: msg.Envelope.SyncMessage.SentMessage.ExpiresInSeconds, ViewOnce: msg.Envelope.SyncMessage.SentMessage.ViewOnce, Sticker: msg.Envelope.SyncMessage.SentMessage.Sticker, GroupInfo: msg.Envelope.SyncMessage.SentMessage.GroupInfo, } } return nil } // isMessageReaction checks if a message is a reaction func isMessageReaction(msg *SignalMessage) bool { if msg.Envelope.DataMessage.Reaction.Emoji != "" { return true } if msg.Envelope.SyncMessage.Reaction.Emoji != "" { return true } return false } // shouldCount determines if a message should be counted func shouldCount(msg *SignalMessage) bool { log.Println("shouldCount triggered") // Check for sticker in data message if msg.Envelope.DataMessage.Sticker.PackID != "" { log.Printf("not counting because message has a sticker: %+v\n", msg) return false } // Check for sticker in sync message if msg.Envelope.SyncMessage.SentMessage.Sticker.PackID != "" { log.Printf("not counting because message has a sticker: %+v\n", msg) return false } log.Printf("counting message: %+v\n", msg) return true } // containsString checks if a string is in a slice func containsString(slice []string, str string) bool { for _, s := range slice { if s == str { return true } } return false } // triggerCommand handles command processing func triggerCommand(messageContent *DataMessage, recipient string) { commandMutex.Lock() defer commandMutex.Unlock() message := messageContent.Message if message == "" || !strings.HasPrefix(message, "!") { return } currentTime := time.Now() if !lastCommandTime.IsZero() && currentTime.Sub(lastCommandTime) < 10*time.Second { if !warningSent { if err := sendMessage("BEEP BOOP POCZEKAJ 10 SEKUND.", recipient); err != nil { log.Printf("Failed to send warning: %v\n", err) } warningSent = true } return } var base64Data string var err error if containsString(catCommands, message) { base64Data, err = fetchCatImage() if err != nil { log.Printf("Failed to fetch cat image: %v\n", err) if sendErr := sendMessage(fmt.Sprintf("trigger_command, error: %v", err), recipient); sendErr != nil { log.Printf("Failed to send error message: %v\n", sendErr) } return } } else if containsString(dogCommands, message) { base64Data, err = fetchDogImage() if err != nil { log.Printf("Failed to fetch dog image: %v\n", err) if sendErr := sendMessage(fmt.Sprintf("trigger_command, error: %v", err), recipient); sendErr != nil { log.Printf("Failed to send error message: %v\n", sendErr) } return } } else { return // Unknown command } if err := sendImage(base64Data, recipient); err != nil { log.Printf("Failed to send image: %v\n", err) if sendErr := sendMessage(fmt.Sprintf("trigger_command, error: %v", err), recipient); sendErr != nil { log.Printf("Failed to send error message: %v\n", sendErr) } return } lastCommandTime = currentTime warningSent = false } // countMessages handles message counting func countMessages(msg *SignalMessage, counter *StringCounter) { if shouldCount(msg) { uuid := msg.Envelope.SourceUuid sourceName := msg.Envelope.SourceName counter.UpdateStringMap(uuid, sourceName) if err := sendMessage(counter.GetStringMapJSON(), phoneNumber); err != nil { log.Printf("Failed to send count message: %v\n", err) } } } // sendToGroup handles messages from the specified group func sendToGroup(msg *SignalMessage, messageContent *DataMessage, counter *StringCounter) { if messageContent.GroupInfo.GroupID == groupID { countMessages(msg, counter) triggerCommand(messageContent, groupIDSend) } } // removeAttachment removes an attachment by ID func removeAttachment(attachmentID string) error { req, err := http.NewRequest(http.MethodDelete, removeAttachmentURL+attachmentID, nil) if err != nil { return fmt.Errorf("failed to create delete request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("failed to delete attachment: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent { log.Println("Attachment removed successfully.") return nil } return fmt.Errorf("remove attachment failed with status: %d", resp.StatusCode) } // getAttachments fetches and removes all attachments func getAttachments() error { resp, err := http.Get(removeAttachmentURL) if err != nil { return fmt.Errorf("failed to get attachments: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("get attachments failed with status: %d", resp.StatusCode) } var attachments []string if err := json.NewDecoder(resp.Body).Decode(&attachments); err != nil { return fmt.Errorf("failed to decode attachments: %w", err) } log.Printf("attachments: %v\n", attachments) for _, attachment := range attachments { log.Printf("attachment: %s\n", attachment) if err := removeAttachment(attachment); err != nil { log.Printf("Failed to remove attachment %s: %v\n", attachment, err) } } return nil } // scheduledTask runs at 21:37 daily func scheduledTask(counter *StringCounter) { for { now := time.Now() targetTime := time.Date(now.Year(), now.Month(), now.Day(), 21, 37, 0, 0, now.Location()) if now.After(targetTime) { targetTime = targetTime.Add(24 * time.Hour) } waitDuration := targetTime.Sub(now) log.Printf("Scheduled task will run in %v at %v\n", waitDuration, targetTime) time.Sleep(waitDuration) if err := sendMessage(counter.GetStringMapJSON(), groupIDSend); err != nil { log.Printf("Failed to send scheduled message: %v\n", err) } counter.Reset() } } // listenToServer connects to the WebSocket and listens for messages func listenToServer(counter *StringCounter) { uri := fmt.Sprintf("ws://localhost:9922/v1/receive/%s?send_read_receipts=false", phoneNumber) for { log.Println("Attempting to connect to Signal server...") conn, _, err := websocket.DefaultDialer.Dial(uri, nil) if err != nil { log.Printf("Failed to connect to WebSocket: %v. Retrying in 5 seconds...\n", err) time.Sleep(5 * time.Second) continue } log.Println("Connected to Signal server") for { _, message, err := conn.ReadMessage() if err != nil { log.Printf("WebSocket read error: %v\n", err) break } var msg SignalMessage if err := json.Unmarshal(message, &msg); err != nil { log.Printf("Failed to parse message: %v\n", err) continue } if isMessageReaction(&msg) { continue } log.Printf("message: %s\n", string(message)) messageContent := extractMessageContent(&msg) if messageContent != nil { sendToGroup(&msg, messageContent, counter) } } conn.Close() log.Println("Connection closed. Reconnecting in 5 seconds...") time.Sleep(5 * time.Second) } } func main() { log.Println("Starting Signal Bot (Go version)...") log.Printf("Phone Number: %s\n", phoneNumber) log.Printf("Group ID: %s\n", groupID) log.Printf("Group ID Send: %s\n", groupIDSend) counter := NewStringCounter() // Start the scheduled task in a goroutine go scheduledTask(counter) // Run the WebSocket listener (blocking) listenToServer(counter) }