diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..cbb2dc9 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,24 @@ +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.gitignore b/.gitignore index 9ca4be5..fcfa285 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,14 @@ cython_debug/ *.jpg *.gif nohup.out + +# Go binaries and build artifacts +signal-bot +*.exe +*.exe~ +*.dll +*.dylib +*.test +*.out +go.work +go.work.sum diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d5acacd --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/kuhyx/signal-bot + +go 1.21 + +require github.com/gorilla/websocket v1.5.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..230ce7e --- /dev/null +++ b/main.go @@ -0,0 +1,575 @@ +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 +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) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..ebf6b4a --- /dev/null +++ b/main_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "testing" +) + +func TestNewStringCounter(t *testing.T) { + counter := NewStringCounter() + if counter == nil { + t.Fatal("NewStringCounter returned nil") + } + if counter.StringMap == nil { + t.Fatal("StringMap is nil") + } + if len(counter.StringMap) != 0 { + t.Fatal("StringMap should be empty") + } +} + +func TestUpdateStringMap(t *testing.T) { + counter := NewStringCounter() + + // First update + result := counter.UpdateStringMap("uuid1", "Alice") + if result["uuid1"].Count != 1 { + t.Errorf("Expected count 1, got %d", result["uuid1"].Count) + } + if result["uuid1"].CommonName != "Alice" { + t.Errorf("Expected name 'Alice', got '%s'", result["uuid1"].CommonName) + } + + // Second update for same user + result = counter.UpdateStringMap("uuid1", "Alice") + if result["uuid1"].Count != 2 { + t.Errorf("Expected count 2, got %d", result["uuid1"].Count) + } + + // Update for different user + result = counter.UpdateStringMap("uuid2", "Bob") + if result["uuid2"].Count != 1 { + t.Errorf("Expected count 1 for Bob, got %d", result["uuid2"].Count) + } + if len(result) != 2 { + t.Errorf("Expected 2 users, got %d", len(result)) + } +} + +func TestGetCommonName(t *testing.T) { + counter := NewStringCounter() + counter.UpdateStringMap("uuid1", "Alice") + + name := counter.GetCommonName("uuid1") + if name != "Alice" { + t.Errorf("Expected 'Alice', got '%s'", name) + } + + name = counter.GetCommonName("nonexistent") + if name != "" { + t.Errorf("Expected empty string, got '%s'", name) + } +} + +func TestReset(t *testing.T) { + counter := NewStringCounter() + counter.UpdateStringMap("uuid1", "Alice") + counter.UpdateStringMap("uuid2", "Bob") + + counter.Reset() + if len(counter.StringMap) != 0 { + t.Errorf("Expected empty map after reset, got %d entries", len(counter.StringMap)) + } +} + +func TestGetStringMapJSON(t *testing.T) { + counter := NewStringCounter() + + // Empty map + json := counter.GetStringMapJSON() + if json != "{}" { + t.Errorf("Expected '{}', got '%s'", json) + } + + // With data + counter.UpdateStringMap("uuid1", "Alice") + json = counter.GetStringMapJSON() + if json == "{}" { + t.Error("Expected non-empty JSON") + } +} + +func TestContainsString(t *testing.T) { + slice := []string{"apple", "banana", "cherry"} + + if !containsString(slice, "banana") { + t.Error("Expected to find 'banana'") + } + + if containsString(slice, "orange") { + t.Error("Did not expect to find 'orange'") + } + + if containsString(nil, "apple") { + t.Error("Expected false for nil slice") + } + + if containsString([]string{}, "apple") { + t.Error("Expected false for empty slice") + } +} + +func TestCatCommands(t *testing.T) { + expectedCommands := []string{"!kot", "!cat", "!meow"} + for _, cmd := range expectedCommands { + if !containsString(catCommands, cmd) { + t.Errorf("Expected '%s' to be in catCommands", cmd) + } + } +} + +func TestDogCommands(t *testing.T) { + expectedCommands := []string{"!pies", "!dog", "!woof"} + for _, cmd := range expectedCommands { + if !containsString(dogCommands, cmd) { + t.Errorf("Expected '%s' to be in dogCommands", cmd) + } + } +} + +func TestIsMessageReaction(t *testing.T) { + // Message without reaction + msg := &SignalMessage{} + if isMessageReaction(msg) { + t.Error("Expected false for message without reaction") + } + + // Message with data message reaction + msg = &SignalMessage{} + msg.Envelope.DataMessage.Reaction.Emoji = "👍" + if !isMessageReaction(msg) { + t.Error("Expected true for message with data message reaction") + } + + // Message with sync message reaction + msg = &SignalMessage{} + msg.Envelope.SyncMessage.Reaction.Emoji = "❤️" + if !isMessageReaction(msg) { + t.Error("Expected true for message with sync message reaction") + } +} + +func TestShouldCount(t *testing.T) { + // Normal message should count + msg := &SignalMessage{} + msg.Envelope.DataMessage.Message = "Hello" + if !shouldCount(msg) { + t.Error("Expected normal message to be counted") + } + + // Message with sticker should not count + msg = &SignalMessage{} + msg.Envelope.DataMessage.Sticker.PackID = "some-pack-id" + if shouldCount(msg) { + t.Error("Expected message with sticker to not be counted") + } + + // Sync message with sticker should not count + msg = &SignalMessage{} + msg.Envelope.SyncMessage.SentMessage.Sticker.PackID = "some-pack-id" + if shouldCount(msg) { + t.Error("Expected sync message with sticker to not be counted") + } +} + +func TestExtractMessageContent(t *testing.T) { + // Test data message extraction + msg := &SignalMessage{} + msg.Envelope.DataMessage.Timestamp = 12345 + msg.Envelope.DataMessage.Message = "Hello" + + content := extractMessageContent(msg) + if content == nil { + t.Fatal("Expected content to be non-nil") + } + if content.Message != "Hello" { + t.Errorf("Expected message 'Hello', got '%s'", content.Message) + } + + // Test sync message extraction + msg = &SignalMessage{} + msg.Envelope.SyncMessage.SentMessage.Timestamp = 12345 + msg.Envelope.SyncMessage.SentMessage.Message = "World" + + content = extractMessageContent(msg) + if content == nil { + t.Fatal("Expected content to be non-nil") + } + if content.Message != "World" { + t.Errorf("Expected message 'World', got '%s'", content.Message) + } + + // Test empty message + msg = &SignalMessage{} + content = extractMessageContent(msg) + if content != nil { + t.Error("Expected nil for empty message") + } +} diff --git a/readme.md b/readme.md index 43607a0..61e44a8 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,12 @@ This guide will help you set up and run a Signal bot using the Signal CLI REST API. For more details, refer to the [signal cli rest api examples](https://bbernhard.github.io/signal-cli-rest-api/). +## Language Options + +This bot is available in two implementations: +- **Go** (recommended for speed) - Single binary, fast execution, low memory usage +- **Python** - Original implementation, easier to modify + ## Message Formats ### Message Sent from the Same Account as the Bot @@ -123,6 +129,23 @@ And source the venv shell: ```sh source venv/bin/activate ``` + +### Option 1: Run Go Version (Recommended for Speed) + +```sh +# Install Go (if not already installed) +# https://go.dev/doc/install + +# Build and run +./run_go.sh + +# Or manually: +go build -o signal-bot +./signal-bot +``` + +### Option 2: Run Python Version + ### Install Required Python Packages ```sh diff --git a/run_go.sh b/run_go.sh new file mode 100755 index 0000000..0a52b6a --- /dev/null +++ b/run_go.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +# Build and run the Go version of Signal Bot +# First, build the application +go build -o signal-bot main.go + +# Run the application +./signal-bot