Add Go implementation of Signal Bot for improved performance

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-12-01 15:15:42 +00:00
parent 7050129e38
commit 4e260c0551
8 changed files with 855 additions and 0 deletions

24
.github/workflows/go.yml vendored Normal file
View File

@ -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 ./...

11
.gitignore vendored
View File

@ -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

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module github.com/kuhyx/signal-bot
go 1.21
require github.com/gorilla/websocket v1.5.3

2
go.sum Normal file
View File

@ -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=

575
main.go Normal file
View File

@ -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)
}

207
main_test.go Normal file
View File

@ -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")
}
}

View File

@ -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

8
run_go.sh Executable file
View File

@ -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