mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-07-04 13:23:07 +02:00
578 lines
16 KiB
Go
578 lines
16 KiB
Go
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)
|
||
}
|