mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-07-04 15:03:09 +02:00
Add Go implementation of Signal Bot for improved performance
Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>
This commit is contained in:
parent
7050129e38
commit
4e260c0551
24
.github/workflows/go.yml
vendored
Normal file
24
.github/workflows/go.yml
vendored
Normal 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
11
.gitignore
vendored
@ -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
5
go.mod
Normal 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
2
go.sum
Normal 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
575
main.go
Normal 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
207
main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
23
readme.md
23
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user