package signal import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" ) const ( DefaultDeviceName = "Prism" DefaultConfigPath = ".local/share/signal-cli" ) type Client struct { ConfigPath string } func NewClient() *Client { if _, err := exec.LookPath("signal-cli"); err != nil { return nil } return &Client{ ConfigPath: filepath.Join(os.Getenv("HOME"), DefaultConfigPath), } } type Account struct { Number string UUID string } func (c *Client) exec(args ...string) ([]byte, error) { baseArgs := []string{"--config", c.ConfigPath, "--output=json"} cmd := exec.Command("signal-cli", append(baseArgs, args...)...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if stderr.Len() > 0 { return nil, fmt.Errorf("signal-cli: %s", strings.TrimSpace(stderr.String())) } return nil, err } return stdout.Bytes(), nil } func (c *Client) GetLinkedAccount() (*Account, error) { if c == nil { return nil, nil } accountsFile := filepath.Join(c.ConfigPath, "data", "accounts.json") if _, err := os.Stat(accountsFile); os.IsNotExist(err) { return nil, nil } data, err := os.ReadFile(accountsFile) if err != nil { return nil, err } var accountsData struct { Accounts []struct { Number string `json:"number"` UUID string `json:"uuid"` } `json:"accounts"` } if err := json.Unmarshal(data, &accountsData); err != nil { return nil, err } if len(accountsData.Accounts) == 0 { return nil, nil } account := &Account{ Number: accountsData.Accounts[0].Number, UUID: accountsData.Accounts[0].UUID, } cmd := exec.Command("signal-cli", "-a", account.Number, "receive", "--timeout", "0") output, err := cmd.CombinedOutput() if err != nil { errStr := strings.ToLower(string(output)) if strings.Contains(errStr, "not registered") || strings.Contains(errStr, "authorization failed") { return nil, nil } } return account, nil } func (c *Client) CreateGroup(name string) (string, string, error) { if c == nil { return "", "", fmt.Errorf("signal client not initialized") } account, err := c.GetLinkedAccount() if err != nil { return "", "", fmt.Errorf("failed to get linked account: %w", err) } if account == nil { return "", "", fmt.Errorf("no linked Signal account") } output, err := c.exec("-o", "json", "-a", account.Number, "updateGroup", "-n", name) if err != nil { return "", "", fmt.Errorf("failed to create group: %w", err) } var response struct { GroupID string `json:"groupId"` } lines := bytes.Split(output, []byte("\n")) for _, line := range lines { if len(line) == 0 { continue } if err := json.Unmarshal(line, &response); err == nil && response.GroupID != "" { return response.GroupID, account.Number, nil } } return "", "", fmt.Errorf("failed to parse group creation response") } func (c *Client) SendGroupMessage(groupID, message string) error { if c == nil { return fmt.Errorf("signal client not initialized") } account, err := c.GetLinkedAccount() if err != nil { return fmt.Errorf("failed to get linked account: %w", err) } if account == nil { return fmt.Errorf("no linked Signal account") } _, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message) return err } func (c *Client) SendGroupMessageWithAttachment(groupID, message, imageURL string) error { if c == nil { return fmt.Errorf("signal client not initialized") } resp, err := http.Get(imageURL) //nolint:noctx if err != nil { return fmt.Errorf("failed to download image: %w", err) } defer resp.Body.Close() tmp, err := os.CreateTemp("", "prism-signal-*.jpg") if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } defer os.Remove(tmp.Name()) if _, err := io.Copy(tmp, resp.Body); err != nil { tmp.Close() return fmt.Errorf("failed to write image: %w", err) } tmp.Close() account, err := c.GetLinkedAccount() if err != nil { return fmt.Errorf("failed to get linked account: %w", err) } if account == nil { return fmt.Errorf("no linked Signal account") } _, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message, "--attachment", tmp.Name()) return err }