feat: port Signal channel to post-refactor subpackage architecture
Adapt the Signal channel (PR #630) to the upstream channel system refactor (#662, #877). Fresh implementation on current main rather than rebasing 5 commits across 155 upstream changes. Changes from the original #630: - Moved from flat pkg/channels/signal.go to pkg/channels/signal/ subpackage - Factory registration via init() + blank import in gateway - New HandleMessage signature with bus.Peer, bus.SenderInfo, identity.BuildCanonicalID - IsAllowedSender() replaces IsAllowed() for structured identity matching - Manager-handled message splitting via WithMaxMessageLength(6000) - Typed errors (ErrNotRunning, ErrTemporary) per Phase 4 lifecycle contract - Proper goroutine tracking with sync.WaitGroup in Start/Stop - TypingCapable interface wraps existing typing indicator feature - ReactionCapable interface (first channel to implement — 👀 on inbound, undo on reply) - WithReasoningChannelID() option for routing LLM reasoning to separate channel All original features preserved: SSE inbound, JSON-RPC sending, markdown-to-Signal text styles, voice transcription, attachment handling, group/DM filtering, typing indicators. 28 tests passing (24 ported + 4 new for parseMessageID).
This commit is contained in:
parent
abafa3c2aa
commit
d1c557a728
7 changed files with 1369 additions and 1 deletions
53
README.md
53
README.md
|
|
@ -308,7 +308,7 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Signal, Matrix, QQ, DingTalk, LINE, or WeCom
|
||||
|
||||
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||
|
||||
|
|
@ -317,6 +317,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
|
|||
| **Telegram** | Easy (just a token) |
|
||||
| **Discord** | Easy (bot token + intents) |
|
||||
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||
| **Signal** | Easy (signal-cli daemon + phone) |
|
||||
| **Matrix** | Medium (homeserver + bot access token) |
|
||||
| **QQ** | Easy (AppID + AppSecret) |
|
||||
| **DingTalk** | Medium (app credentials) |
|
||||
|
|
@ -463,6 +464,56 @@ If `session_store_path` is empty, the session is stored in `<workspace>/wh
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Signal</b> (via signal-cli)</summary>
|
||||
|
||||
PicoClaw connects to Signal through [signal-cli](https://github.com/AsamK/signal-cli) running in JSON-RPC daemon mode. signal-cli handles Signal protocol registration and encryption; PicoClaw connects to its HTTP API.
|
||||
|
||||
**1. Set up signal-cli**
|
||||
|
||||
Run signal-cli as a daemon (Docker recommended):
|
||||
|
||||
```bash
|
||||
docker run -d --name signal-cli \
|
||||
-p 8080:8080 \
|
||||
-v signal-data:/home/.local/share/signal-cli \
|
||||
bbernhard/signal-cli-rest-api
|
||||
```
|
||||
|
||||
Register or link a phone number following the [signal-cli docs](https://github.com/AsamK/signal-cli/wiki).
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"signal": {
|
||||
"enabled": true,
|
||||
"account": "+1234567890",
|
||||
"signal_cli_url": "http://localhost:8080",
|
||||
"allow_from": ["+1987654321"],
|
||||
"dms_enabled": true,
|
||||
"groups_enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `account`: The phone number registered with signal-cli
|
||||
- `signal_cli_url`: URL of the signal-cli REST API (default: `http://localhost:8080`)
|
||||
- `allow_from`: Phone numbers allowed to interact (empty = allow all)
|
||||
- `dms_enabled` / `groups_enabled`: Toggle DM and group message handling
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> Signal supports markdown-to-styled-text conversion (bold, italic, strikethrough, monospace) and typing indicators.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/signal"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||
|
|
|
|||
|
|
@ -280,6 +280,10 @@ func (m *Manager) initChannels() error {
|
|||
m.initChannel("irc", "IRC")
|
||||
}
|
||||
|
||||
if m.config.Channels.Signal.Enabled && m.config.Channels.Signal.Account != "" {
|
||||
m.initChannel("signal", "Signal")
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||
"enabled_channels": len(m.channels),
|
||||
})
|
||||
|
|
|
|||
13
pkg/channels/signal/init.go
Normal file
13
pkg/channels/signal/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory("signal", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewSignalChannel(cfg, b)
|
||||
})
|
||||
}
|
||||
863
pkg/channels/signal/signal.go
Normal file
863
pkg/channels/signal/signal.go
Normal file
|
|
@ -0,0 +1,863 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
const (
|
||||
signalMaxMessageLength = 6000
|
||||
signalSSEReconnectDelay = 5 * time.Second
|
||||
signalRPCTimeout = 30 * time.Second
|
||||
signalTypingInterval = 8 * time.Second
|
||||
signalTypingTimeout = 5 * time.Minute
|
||||
signalDefaultCLIURL = "http://localhost:8080"
|
||||
)
|
||||
|
||||
// SignalChannel implements the Channel interface for Signal via signal-cli daemon.
|
||||
// It connects to signal-cli's HTTP API: SSE for receiving events, JSON-RPC for sending.
|
||||
//
|
||||
// Implements: channels.Channel, channels.TypingCapable, channels.ReactionCapable
|
||||
type SignalChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.SignalConfig
|
||||
httpClient *http.Client
|
||||
transcriber *voice.GroqTranscriber
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// Signal SSE event types
|
||||
|
||||
type signalEvent struct {
|
||||
Envelope signalEnvelope `json:"envelope"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
type signalEnvelope 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 *signalDataMessage `json:"dataMessage"`
|
||||
}
|
||||
|
||||
type signalDataMessage struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
ExpiresInSeconds int `json:"expiresInSeconds"`
|
||||
ViewOnce bool `json:"viewOnce"`
|
||||
GroupInfo *signalGroupInfo `json:"groupInfo"`
|
||||
Attachments []signalAttachment `json:"attachments"`
|
||||
}
|
||||
|
||||
type signalGroupInfo struct {
|
||||
GroupID string `json:"groupId"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type signalAttachment struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Filename string `json:"filename"`
|
||||
ID string `json:"id"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// JSON-RPC types
|
||||
|
||||
type jsonRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
ID int `json:"id"`
|
||||
Params any `json:"params"`
|
||||
}
|
||||
|
||||
type jsonRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *jsonRPCError `json:"error"`
|
||||
}
|
||||
|
||||
type jsonRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func NewSignalChannel(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
signalCfg := cfg.Channels.Signal
|
||||
|
||||
if signalCfg.SignalCLIURL == "" {
|
||||
signalCfg.SignalCLIURL = signalDefaultCLIURL
|
||||
}
|
||||
|
||||
opts := []channels.BaseChannelOption{
|
||||
channels.WithMaxMessageLength(signalMaxMessageLength),
|
||||
}
|
||||
if signalCfg.ReasoningChannelID != "" {
|
||||
opts = append(opts, channels.WithReasoningChannelID(signalCfg.ReasoningChannelID))
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel("signal", signalCfg, b, signalCfg.AllowFrom, opts...)
|
||||
|
||||
return &SignalChannel{
|
||||
BaseChannel: base,
|
||||
config: signalCfg,
|
||||
httpClient: &http.Client{Timeout: signalRPCTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SignalChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||
c.transcriber = transcriber
|
||||
}
|
||||
|
||||
func (c *SignalChannel) Start(ctx context.Context) error {
|
||||
logger.InfoCF("signal", "Starting Signal channel", map[string]any{
|
||||
"signal_cli_url": c.config.SignalCLIURL,
|
||||
"account": c.config.Account,
|
||||
})
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
c.sseLoop()
|
||||
}()
|
||||
|
||||
c.SetRunning(true)
|
||||
logger.InfoC("signal", "Signal channel started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SignalChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("signal", "Stopping Signal channel")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
// Wait for goroutines with context deadline
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
logger.WarnC("signal", fmt.Sprintf("Stop context canceled before goroutines finished: %v", ctx.Err()))
|
||||
}
|
||||
|
||||
c.SetRunning(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SignalChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := c.sendMessage(ctx, msg.ChatID, msg.Content); err != nil {
|
||||
return fmt.Errorf("signal send: %w", channels.ErrTemporary)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartTyping implements channels.TypingCapable.
|
||||
// It sends a typing indicator immediately and then repeats every 8 seconds
|
||||
// (signal-cli's typing indicator expires after ~10s) in a background goroutine.
|
||||
// The returned stop function is idempotent and cancels the goroutine.
|
||||
func (c *SignalChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||
c.sendTyping(chatID)
|
||||
|
||||
typingCtx, cancel := context.WithCancel(ctx)
|
||||
var once sync.Once
|
||||
stop := func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(signalTypingInterval)
|
||||
defer ticker.Stop()
|
||||
timeout := time.After(signalTypingTimeout)
|
||||
for {
|
||||
select {
|
||||
case <-typingCtx.Done():
|
||||
return
|
||||
case <-timeout:
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.sendTyping(chatID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return stop, nil
|
||||
}
|
||||
|
||||
// ReactToMessage implements channels.ReactionCapable.
|
||||
// It sends a 👀 emoji reaction to the inbound message and returns an undo
|
||||
// function that removes the reaction. The Manager auto-calls this on inbound
|
||||
// and undoes it before sending the bot's response.
|
||||
func (c *SignalChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||
// messageID is encoded as "timestamp:senderPhone" by handleEvent
|
||||
ts, senderPhone, ok := parseMessageID(messageID)
|
||||
if !ok {
|
||||
return func() {}, nil // non-critical, skip silently
|
||||
}
|
||||
|
||||
c.sendReaction(ctx, chatID, senderPhone, ts, "👀", false)
|
||||
|
||||
var once sync.Once
|
||||
undo := func() {
|
||||
once.Do(func() {
|
||||
undoCtx, undoCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer undoCancel()
|
||||
c.sendReaction(undoCtx, chatID, senderPhone, ts, "👀", true)
|
||||
})
|
||||
}
|
||||
|
||||
return undo, nil
|
||||
}
|
||||
|
||||
// SSE event loop with automatic reconnection
|
||||
|
||||
func (c *SignalChannel) sseLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
if err := c.connectSSE(); err != nil {
|
||||
logger.ErrorCF("signal", "SSE connection error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-time.After(signalSSEReconnectDelay):
|
||||
logger.InfoC("signal", "Reconnecting SSE...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SignalChannel) connectSSE() error {
|
||||
url := fmt.Sprintf("%s/api/v1/events", c.config.SignalCLIURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(c.ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SSE request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
sseClient := &http.Client{Timeout: 0}
|
||||
resp, err := sseClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSE connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("SSE returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
logger.InfoC("signal", "SSE connected successfully")
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data:")
|
||||
data = strings.TrimSpace(data)
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event signalEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
logger.DebugCF("signal", "Failed to parse SSE event", map[string]any{
|
||||
"error": err.Error(),
|
||||
"data": utils.Truncate(data, 100),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
c.handleEvent(event)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("SSE stream error: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("SSE stream ended")
|
||||
}
|
||||
|
||||
// Event handling
|
||||
|
||||
func (c *SignalChannel) handleEvent(event signalEvent) {
|
||||
envelope := event.Envelope
|
||||
|
||||
if envelope.DataMessage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dm := envelope.DataMessage
|
||||
|
||||
senderPhone := envelope.SourceNumber
|
||||
if senderPhone == "" {
|
||||
senderPhone = envelope.Source
|
||||
}
|
||||
if senderPhone == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Build structured sender info for the new identity system
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "signal",
|
||||
PlatformID: senderPhone,
|
||||
CanonicalID: identity.BuildCanonicalID("signal", senderPhone),
|
||||
DisplayName: envelope.SourceName,
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("signal", "Message rejected by allowlist", map[string]any{
|
||||
"sender": senderPhone,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isGroup := dm.GroupInfo != nil
|
||||
chatID := senderPhone
|
||||
peerKind := "direct"
|
||||
peerID := senderPhone
|
||||
|
||||
if isGroup {
|
||||
if c.config.GroupsEnabled != nil && !*c.config.GroupsEnabled {
|
||||
logger.DebugCF("signal", "Group message ignored (groups_enabled=false)", map[string]any{
|
||||
"group_id": dm.GroupInfo.GroupID,
|
||||
})
|
||||
return
|
||||
}
|
||||
chatID = dm.GroupInfo.GroupID
|
||||
peerKind = "group"
|
||||
peerID = dm.GroupInfo.GroupID
|
||||
} else {
|
||||
if c.config.DMsEnabled != nil && !*c.config.DMsEnabled {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
content := dm.Message
|
||||
mediaPaths := []string{}
|
||||
localFiles := []string{}
|
||||
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("signal", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for _, att := range dm.Attachments {
|
||||
localPath := c.downloadAttachment(att)
|
||||
if localPath == "" {
|
||||
continue
|
||||
}
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
|
||||
if strings.HasPrefix(att.ContentType, "image/") {
|
||||
content = appendContent(content, "[image: photo]")
|
||||
} else if utils.IsAudioFile(att.Filename, att.ContentType) {
|
||||
transcribedText := c.transcribeAudio(localPath)
|
||||
content = appendContent(content, transcribedText)
|
||||
} else {
|
||||
name := att.Filename
|
||||
if name == "" {
|
||||
name = att.ContentType
|
||||
}
|
||||
content = appendContent(content, fmt.Sprintf("[file: %s]", name))
|
||||
}
|
||||
}
|
||||
|
||||
if content == "" && len(mediaPaths) == 0 {
|
||||
return
|
||||
}
|
||||
if content == "" {
|
||||
content = "[media only]"
|
||||
}
|
||||
|
||||
// Build compound senderID for backward compat
|
||||
senderID := senderPhone
|
||||
if envelope.SourceName != "" {
|
||||
senderID = fmt.Sprintf("%s|%s", senderPhone, envelope.SourceName)
|
||||
}
|
||||
|
||||
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||
|
||||
// Encode messageID as "timestamp:senderPhone" so ReactToMessage can extract both
|
||||
messageID := fmt.Sprintf("%d:%s", dm.Timestamp, senderPhone)
|
||||
|
||||
metadata := map[string]string{
|
||||
"timestamp": fmt.Sprintf("%d", dm.Timestamp),
|
||||
"source_uuid": envelope.SourceUUID,
|
||||
"source_name": envelope.SourceName,
|
||||
"phone": senderPhone,
|
||||
"is_group": fmt.Sprintf("%t", isGroup),
|
||||
"peer_kind": peerKind,
|
||||
"peer_id": peerID,
|
||||
"message_id": messageID,
|
||||
}
|
||||
if isGroup {
|
||||
metadata["group_id"] = dm.GroupInfo.GroupID
|
||||
}
|
||||
|
||||
logger.DebugCF("signal", "Received message", map[string]any{
|
||||
"sender": senderPhone,
|
||||
"chat_id": chatID,
|
||||
"is_group": isGroup,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||
}
|
||||
|
||||
// Media handling
|
||||
|
||||
func (c *SignalChannel) downloadAttachment(att signalAttachment) string {
|
||||
if att.ID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/attachments/%s", c.config.SignalCLIURL, att.ID)
|
||||
filename := att.Filename
|
||||
if filename == "" {
|
||||
filename = "attachment" + extensionFromMIME(att.ContentType)
|
||||
}
|
||||
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "signal",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *SignalChannel) transcribeAudio(localPath string) string {
|
||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
tCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
|
||||
result, err := c.transcriber.Transcribe(tCtx, localPath)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("signal", "Voice transcription failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return "[voice (transcription failed)]"
|
||||
}
|
||||
logger.InfoCF("signal", "Voice transcribed successfully", map[string]any{
|
||||
"text": result.Text,
|
||||
})
|
||||
return fmt.Sprintf("[voice transcription: %s]", result.Text)
|
||||
}
|
||||
return "[voice]"
|
||||
}
|
||||
|
||||
func extensionFromMIME(mime string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/jpeg"):
|
||||
return ".jpg"
|
||||
case strings.HasPrefix(mime, "image/png"):
|
||||
return ".png"
|
||||
case strings.HasPrefix(mime, "image/gif"):
|
||||
return ".gif"
|
||||
case strings.HasPrefix(mime, "image/webp"):
|
||||
return ".webp"
|
||||
case strings.HasPrefix(mime, "audio/mpeg"), strings.HasPrefix(mime, "audio/mp3"):
|
||||
return ".mp3"
|
||||
case strings.HasPrefix(mime, "audio/ogg"):
|
||||
return ".ogg"
|
||||
case strings.HasPrefix(mime, "audio/mp4"), strings.HasPrefix(mime, "audio/aac"):
|
||||
return ".m4a"
|
||||
case strings.HasPrefix(mime, "video/mp4"):
|
||||
return ".mp4"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func appendContent(content, suffix string) string {
|
||||
if content == "" {
|
||||
return suffix
|
||||
}
|
||||
return content + "\n" + suffix
|
||||
}
|
||||
|
||||
// Sending messages via JSON-RPC
|
||||
|
||||
func (c *SignalChannel) sendMessage(ctx context.Context, chatID, content string) error {
|
||||
plainText, textStyles := markdownToSignal(content)
|
||||
|
||||
params := map[string]any{
|
||||
"account": c.config.Account,
|
||||
"message": plainText,
|
||||
}
|
||||
|
||||
if len(textStyles) > 0 {
|
||||
params["textStyle"] = textStyles
|
||||
}
|
||||
|
||||
if isGroupChat(chatID) {
|
||||
params["groupId"] = chatID
|
||||
} else {
|
||||
params["recipient"] = []string{chatID}
|
||||
}
|
||||
|
||||
_, err := c.rpcCall(ctx, "send", params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *SignalChannel) sendReaction(ctx context.Context, chatID, targetAuthor string, targetTimestamp int64, emoji string, remove bool) {
|
||||
params := map[string]any{
|
||||
"account": c.config.Account,
|
||||
"emoji": emoji,
|
||||
"targetAuthor": targetAuthor,
|
||||
"targetTimestamp": targetTimestamp,
|
||||
"remove": remove,
|
||||
}
|
||||
if isGroupChat(chatID) {
|
||||
params["groupId"] = chatID
|
||||
} else {
|
||||
params["recipient"] = chatID
|
||||
}
|
||||
|
||||
if _, err := c.rpcCall(ctx, "sendReaction", params); err != nil {
|
||||
logger.DebugCF("signal", "Failed to send reaction", map[string]any{
|
||||
"error": err.Error(),
|
||||
"remove": remove,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SignalChannel) rpcCall(ctx context.Context, method string, params any) (*jsonRPCResponse, error) {
|
||||
req := jsonRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
Method: method,
|
||||
ID: 1,
|
||||
Params: params,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal RPC request: %w", err)
|
||||
}
|
||||
|
||||
rpcURL := c.config.SignalCLIURL + "/api/v1/rpc"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create RPC request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RPC request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read RPC response: %w", err)
|
||||
}
|
||||
|
||||
var rpcResp jsonRPCResponse
|
||||
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse RPC response: %w", err)
|
||||
}
|
||||
|
||||
if rpcResp.Error != nil {
|
||||
return nil, fmt.Errorf("RPC error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
|
||||
}
|
||||
|
||||
return &rpcResp, nil
|
||||
}
|
||||
|
||||
// Typing indicator
|
||||
|
||||
func (c *SignalChannel) sendTyping(chatID string) {
|
||||
params := map[string]any{
|
||||
"account": c.config.Account,
|
||||
}
|
||||
|
||||
if isGroupChat(chatID) {
|
||||
params["groupId"] = chatID
|
||||
} else {
|
||||
params["recipient"] = chatID
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := c.rpcCall(ctx, "sendTyping", params); err != nil {
|
||||
logger.DebugCF("signal", "Failed to send typing indicator", map[string]any{
|
||||
"error": err.Error(),
|
||||
"chat_id": chatID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isGroupChat determines if a chatID is a Signal group (base64-encoded) or a phone number.
|
||||
func isGroupChat(chatID string) bool {
|
||||
return chatID != "" && !strings.HasPrefix(chatID, "+")
|
||||
}
|
||||
|
||||
// parseMessageID extracts timestamp and sender phone from the encoded messageID
|
||||
// format "timestamp:senderPhone".
|
||||
func parseMessageID(messageID string) (timestamp int64, senderPhone string, ok bool) {
|
||||
idx := strings.Index(messageID, ":")
|
||||
if idx <= 0 || idx == len(messageID)-1 {
|
||||
return 0, "", false
|
||||
}
|
||||
ts, err := strconv.ParseInt(messageID[:idx], 10, 64)
|
||||
if err != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
return ts, messageID[idx+1:], true
|
||||
}
|
||||
|
||||
// markdownToSignal converts markdown-formatted text to plain text with signal-cli
|
||||
// textStyle ranges. Returns the converted text and a slice of style strings in
|
||||
// "START:LENGTH:STYLE" format for signal-cli's textStyle parameter.
|
||||
// Handles: **bold**, *italic*, ~~strikethrough~~, `code`, ```code blocks```,
|
||||
// [links](url), heading stripping, list markers, blockquotes.
|
||||
func markdownToSignal(text string) (string, []string) {
|
||||
if text == "" {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// Step 0: extract code blocks and inline code into placeholders.
|
||||
// This prevents code content (e.g. *ptr inside code) from being
|
||||
// processed as markdown in later steps.
|
||||
var codeBlocks []string
|
||||
var inlineCodes []string
|
||||
|
||||
reCodeBlock := regexp.MustCompile("(?s)```[\\w]*\\n?(.*?)```")
|
||||
text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||
inner := reCodeBlock.FindStringSubmatch(m)[1]
|
||||
idx := len(codeBlocks)
|
||||
codeBlocks = append(codeBlocks, inner)
|
||||
return fmt.Sprintf("\x00CB%d\x00", idx)
|
||||
})
|
||||
|
||||
reInlineCode := regexp.MustCompile("`([^`]+)`")
|
||||
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
|
||||
inner := reInlineCode.FindStringSubmatch(m)[1]
|
||||
idx := len(inlineCodes)
|
||||
inlineCodes = append(inlineCodes, inner)
|
||||
return fmt.Sprintf("\x00IC%d\x00", idx)
|
||||
})
|
||||
|
||||
// Step 1: line-level markdown (headings, lists, blockquotes)
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
trimmed := strings.TrimLeft(line, "#")
|
||||
lines[i] = strings.TrimLeft(trimmed, " ")
|
||||
} else if strings.HasPrefix(line, "- ") {
|
||||
lines[i] = "• " + line[2:]
|
||||
} else if strings.HasPrefix(line, "* ") {
|
||||
lines[i] = "• " + line[2:]
|
||||
} else if strings.HasPrefix(line, "> ") {
|
||||
lines[i] = line[2:]
|
||||
}
|
||||
}
|
||||
text = strings.Join(lines, "\n")
|
||||
|
||||
// Step 1b: convert markdown links [text](url) → text (url)
|
||||
reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
text = reLink.ReplaceAllString(text, "$1 ($2)")
|
||||
|
||||
// Step 2: inline styles → textStyle position ranges
|
||||
type styleEntry struct {
|
||||
start int
|
||||
length int
|
||||
style string
|
||||
}
|
||||
|
||||
var styles []styleEntry
|
||||
var result []rune
|
||||
runes := []rune(text)
|
||||
i := 0
|
||||
|
||||
utf16Pos := func() int {
|
||||
return len(utf16.Encode(result))
|
||||
}
|
||||
|
||||
// Check if current position is the start of a placeholder (\x00CB0\x00 or \x00IC0\x00)
|
||||
matchPlaceholder := func(pos int) (kind string, idx int, end int, ok bool) {
|
||||
if pos+4 >= len(runes) || runes[pos] != 0 {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
// Find the closing \x00
|
||||
j := pos + 1
|
||||
for j < len(runes) && runes[j] != 0 {
|
||||
j++
|
||||
}
|
||||
if j >= len(runes) {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
tag := string(runes[pos+1 : j])
|
||||
if strings.HasPrefix(tag, "CB") {
|
||||
n := 0
|
||||
if _, err := fmt.Sscanf(tag, "CB%d", &n); err == nil {
|
||||
return "CB", n, j + 1, true
|
||||
}
|
||||
} else if strings.HasPrefix(tag, "IC") {
|
||||
n := 0
|
||||
if _, err := fmt.Sscanf(tag, "IC%d", &n); err == nil {
|
||||
return "IC", n, j + 1, true
|
||||
}
|
||||
}
|
||||
return "", 0, 0, false
|
||||
}
|
||||
|
||||
for i < len(runes) {
|
||||
// Code placeholders → restore content with MONOSPACE style
|
||||
if kind, idx, end, ok := matchPlaceholder(i); ok {
|
||||
var code string
|
||||
if kind == "CB" && idx < len(codeBlocks) {
|
||||
code = strings.TrimRight(codeBlocks[idx], "\n")
|
||||
} else if kind == "IC" && idx < len(inlineCodes) {
|
||||
code = inlineCodes[idx]
|
||||
}
|
||||
if code != "" {
|
||||
codeRunes := []rune(code)
|
||||
start := utf16Pos()
|
||||
styles = append(styles, styleEntry{start, len(utf16.Encode(codeRunes)), "MONOSPACE"})
|
||||
result = append(result, codeRunes...)
|
||||
}
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// Strikethrough: ~~text~~
|
||||
if i+1 < len(runes) && runes[i] == '~' && runes[i+1] == '~' {
|
||||
if end := signalFindDouble(runes, i+2, '~'); end > i+2 {
|
||||
inner := runes[i+2 : end]
|
||||
start := utf16Pos()
|
||||
styles = append(styles, styleEntry{start, len(utf16.Encode(inner)), "STRIKETHROUGH"})
|
||||
result = append(result, inner...)
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Bold: **text**
|
||||
if i+1 < len(runes) && runes[i] == '*' && runes[i+1] == '*' {
|
||||
if end := signalFindDouble(runes, i+2, '*'); end > i+2 {
|
||||
inner := runes[i+2 : end]
|
||||
start := utf16Pos()
|
||||
styles = append(styles, styleEntry{start, len(utf16.Encode(inner)), "BOLD"})
|
||||
result = append(result, inner...)
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Italic: *text* (single *, not followed by another *)
|
||||
if runes[i] == '*' && (i+1 < len(runes) && runes[i+1] != '*') {
|
||||
if end := signalFindSingle(runes, i+1, '*'); end > i+1 {
|
||||
inner := runes[i+1 : end]
|
||||
start := utf16Pos()
|
||||
styles = append(styles, styleEntry{start, len(utf16.Encode(inner)), "ITALIC"})
|
||||
result = append(result, inner...)
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, runes[i])
|
||||
i++
|
||||
}
|
||||
|
||||
if len(styles) == 0 {
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
strs := make([]string, len(styles))
|
||||
for idx, s := range styles {
|
||||
strs[idx] = fmt.Sprintf("%d:%d:%s", s.start, s.length, s.style)
|
||||
}
|
||||
return string(result), strs
|
||||
}
|
||||
|
||||
// signalFindDouble finds the next occurrence of two consecutive ch runes starting from pos.
|
||||
func signalFindDouble(runes []rune, start int, ch rune) int {
|
||||
for i := start; i+1 < len(runes); i++ {
|
||||
if runes[i] == ch && runes[i+1] == ch {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// signalFindSingle finds the next occurrence of ch that is NOT followed by another ch.
|
||||
func signalFindSingle(runes []rune, start int, ch rune) int {
|
||||
for i := start; i < len(runes); i++ {
|
||||
if runes[i] == ch && (i+1 >= len(runes) || runes[i+1] != ch) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
425
pkg/channels/signal/signal_test.go
Normal file
425
pkg/channels/signal/signal_test.go
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownToSignal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantText string
|
||||
wantStyles []string
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "Hello world",
|
||||
wantText: "Hello world",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantText: "",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "bold",
|
||||
input: "**hello** world",
|
||||
wantText: "hello world",
|
||||
wantStyles: []string{"0:5:BOLD"},
|
||||
},
|
||||
{
|
||||
name: "multiple bold",
|
||||
input: "**hello** and **world**",
|
||||
wantText: "hello and world",
|
||||
wantStyles: []string{"0:5:BOLD", "10:5:BOLD"},
|
||||
},
|
||||
{
|
||||
name: "italic",
|
||||
input: "It is *really* good",
|
||||
wantText: "It is really good",
|
||||
wantStyles: []string{"6:6:ITALIC"},
|
||||
},
|
||||
{
|
||||
name: "bold and italic",
|
||||
input: "**Alice** is *great*",
|
||||
wantText: "Alice is great",
|
||||
wantStyles: []string{"0:5:BOLD", "9:5:ITALIC"},
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
input: "~~not available~~ found it",
|
||||
wantText: "not available found it",
|
||||
wantStyles: []string{"0:13:STRIKETHROUGH"},
|
||||
},
|
||||
{
|
||||
name: "unmatched bold markers left as-is",
|
||||
input: "**unclosed bold",
|
||||
wantText: "**unclosed bold",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "unmatched italic marker left as-is",
|
||||
input: "*unclosed italic",
|
||||
wantText: "*unclosed italic",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "heading stripped",
|
||||
input: "## Tasks\nHere are some tasks",
|
||||
wantText: "Tasks\nHere are some tasks",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "list markers converted (dash)",
|
||||
input: "- Alice\n- Bob\n- Carol",
|
||||
wantText: "• Alice\n• Bob\n• Carol",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "list markers converted (asterisk)",
|
||||
input: "* Alice\n* Bob\n* Carol",
|
||||
wantText: "• Alice\n• Bob\n• Carol",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "asterisk list with bold content",
|
||||
input: "* **First item**: description one\n* **Second item**: description two",
|
||||
wantText: "• First item: description one\n• Second item: description two",
|
||||
wantStyles: []string{"2:10:BOLD", "32:11:BOLD"},
|
||||
},
|
||||
{
|
||||
name: "blockquote stripped",
|
||||
input: "> Some quote here",
|
||||
wantText: "Some quote here",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "bold with non-ASCII characters",
|
||||
input: "**Blåbær** er på bordet",
|
||||
wantText: "Blåbær er på bordet",
|
||||
wantStyles: []string{"0:6:BOLD"},
|
||||
},
|
||||
{
|
||||
name: "mixed formatting and line-level",
|
||||
input: "## Results\n- **Alice** is in group A\n- **Bob** is in group B",
|
||||
wantText: "Results\n• Alice is in group A\n• Bob is in group B",
|
||||
wantStyles: []string{"10:5:BOLD", "32:3:BOLD"},
|
||||
},
|
||||
{
|
||||
name: "inline code",
|
||||
input: "Run `kubectl get pods` to check",
|
||||
wantText: "Run kubectl get pods to check",
|
||||
wantStyles: []string{"4:16:MONOSPACE"},
|
||||
},
|
||||
{
|
||||
name: "code block",
|
||||
input: "Example:\n```bash\necho hello\n```\nDone",
|
||||
wantText: "Example:\necho hello\nDone",
|
||||
wantStyles: []string{"9:10:MONOSPACE"},
|
||||
},
|
||||
{
|
||||
name: "code block with language tag",
|
||||
input: "```python\nprint(\"hi\")\n```",
|
||||
wantText: "print(\"hi\")",
|
||||
wantStyles: []string{"0:11:MONOSPACE"},
|
||||
},
|
||||
{
|
||||
name: "code preserves markdown inside",
|
||||
input: "Use `**not bold**` in code",
|
||||
wantText: "Use **not bold** in code",
|
||||
wantStyles: []string{"4:12:MONOSPACE"},
|
||||
},
|
||||
{
|
||||
name: "inline code and bold mixed",
|
||||
input: "**Important**: use `cmd` here",
|
||||
wantText: "Important: use cmd here",
|
||||
wantStyles: []string{"0:9:BOLD", "15:3:MONOSPACE"},
|
||||
},
|
||||
{
|
||||
name: "markdown link converted",
|
||||
input: "See [Google](https://google.com) for more",
|
||||
wantText: "See Google (https://google.com) for more",
|
||||
wantStyles: nil,
|
||||
},
|
||||
{
|
||||
name: "link with bold text",
|
||||
input: "**Check** [docs](https://example.com)",
|
||||
wantText: "Check docs (https://example.com)",
|
||||
wantStyles: []string{"0:5:BOLD"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotText, gotStyles := markdownToSignal(tt.input)
|
||||
if gotText != tt.wantText {
|
||||
t.Errorf("text = %q, want %q", gotText, tt.wantText)
|
||||
}
|
||||
if !reflect.DeepEqual(gotStyles, tt.wantStyles) {
|
||||
t.Errorf("styles = %v, want %v", gotStyles, tt.wantStyles)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionFromMIME(t *testing.T) {
|
||||
tests := []struct {
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{"image/jpeg", ".jpg"},
|
||||
{"image/jpeg; charset=utf-8", ".jpg"},
|
||||
{"image/png", ".png"},
|
||||
{"image/gif", ".gif"},
|
||||
{"image/webp", ".webp"},
|
||||
{"audio/mpeg", ".mp3"},
|
||||
{"audio/mp3", ".mp3"},
|
||||
{"audio/ogg", ".ogg"},
|
||||
{"audio/mp4", ".m4a"},
|
||||
{"audio/aac", ".m4a"},
|
||||
{"video/mp4", ".mp4"},
|
||||
{"application/pdf", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mime, func(t *testing.T) {
|
||||
if got := extensionFromMIME(tt.mime); got != tt.want {
|
||||
t.Errorf("extensionFromMIME(%q) = %q, want %q", tt.mime, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalEventDeserialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
wantSource string
|
||||
wantName string
|
||||
wantMsg string
|
||||
wantGroup bool
|
||||
}{
|
||||
{
|
||||
name: "direct message",
|
||||
json: `{
|
||||
"envelope": {
|
||||
"source": "+4512345678",
|
||||
"sourceNumber": "+4512345678",
|
||||
"sourceUuid": "abc-def-123",
|
||||
"sourceName": "John Doe",
|
||||
"sourceDevice": 1,
|
||||
"timestamp": 1700000000000,
|
||||
"dataMessage": {
|
||||
"timestamp": 1700000000000,
|
||||
"message": "Hello bot",
|
||||
"expiresInSeconds": 0,
|
||||
"viewOnce": false
|
||||
}
|
||||
},
|
||||
"account": "+4587654321"
|
||||
}`,
|
||||
wantSource: "+4512345678",
|
||||
wantName: "John Doe",
|
||||
wantMsg: "Hello bot",
|
||||
wantGroup: false,
|
||||
},
|
||||
{
|
||||
name: "group message",
|
||||
json: `{
|
||||
"envelope": {
|
||||
"source": "+4512345678",
|
||||
"sourceNumber": "+4512345678",
|
||||
"sourceUuid": "abc-def-123",
|
||||
"sourceName": "Jane",
|
||||
"sourceDevice": 2,
|
||||
"timestamp": 1700000000000,
|
||||
"dataMessage": {
|
||||
"timestamp": 1700000000000,
|
||||
"message": "Hi group",
|
||||
"groupInfo": {
|
||||
"groupId": "R3JvdXBJZEhlcmU=",
|
||||
"type": "DELIVER"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": "+4587654321"
|
||||
}`,
|
||||
wantSource: "+4512345678",
|
||||
wantName: "Jane",
|
||||
wantMsg: "Hi group",
|
||||
wantGroup: true,
|
||||
},
|
||||
{
|
||||
name: "message with attachment",
|
||||
json: `{
|
||||
"envelope": {
|
||||
"source": "+4512345678",
|
||||
"sourceNumber": "+4512345678",
|
||||
"sourceUuid": "abc-def-123",
|
||||
"sourceName": "John",
|
||||
"sourceDevice": 1,
|
||||
"timestamp": 1700000000000,
|
||||
"dataMessage": {
|
||||
"timestamp": 1700000000000,
|
||||
"message": "",
|
||||
"attachments": [{
|
||||
"contentType": "image/jpeg",
|
||||
"filename": "photo.jpg",
|
||||
"id": "att-123",
|
||||
"size": 54321
|
||||
}]
|
||||
}
|
||||
},
|
||||
"account": "+4587654321"
|
||||
}`,
|
||||
wantSource: "+4512345678",
|
||||
wantName: "John",
|
||||
wantMsg: "",
|
||||
wantGroup: false,
|
||||
},
|
||||
{
|
||||
name: "no data message (e.g. receipt)",
|
||||
json: `{
|
||||
"envelope": {
|
||||
"source": "+4512345678",
|
||||
"sourceNumber": "+4512345678",
|
||||
"sourceUuid": "abc-def-123",
|
||||
"sourceName": "John",
|
||||
"sourceDevice": 1,
|
||||
"timestamp": 1700000000000
|
||||
},
|
||||
"account": "+4587654321"
|
||||
}`,
|
||||
wantSource: "+4512345678",
|
||||
wantName: "John",
|
||||
wantMsg: "",
|
||||
wantGroup: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var event signalEvent
|
||||
if err := json.Unmarshal([]byte(tt.json), &event); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if event.Envelope.SourceNumber != tt.wantSource {
|
||||
t.Errorf("sourceNumber = %q, want %q", event.Envelope.SourceNumber, tt.wantSource)
|
||||
}
|
||||
if event.Envelope.SourceName != tt.wantName {
|
||||
t.Errorf("sourceName = %q, want %q", event.Envelope.SourceName, tt.wantName)
|
||||
}
|
||||
|
||||
if event.Envelope.DataMessage != nil {
|
||||
if event.Envelope.DataMessage.Message != tt.wantMsg {
|
||||
t.Errorf("message = %q, want %q", event.Envelope.DataMessage.Message, tt.wantMsg)
|
||||
}
|
||||
gotGroup := event.Envelope.DataMessage.GroupInfo != nil
|
||||
if gotGroup != tt.wantGroup {
|
||||
t.Errorf("isGroup = %v, want %v", gotGroup, tt.wantGroup)
|
||||
}
|
||||
} else if tt.wantMsg != "" {
|
||||
t.Errorf("dataMessage is nil, want message %q", tt.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGroupChat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chatID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "phone number is not group",
|
||||
chatID: "+4571376774",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "base64 group ID is group",
|
||||
chatID: "abc123def456==",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty string is not group",
|
||||
chatID: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isGroupChat(tt.chatID); got != tt.want {
|
||||
t.Errorf("isGroupChat(%q) = %v, want %v", tt.chatID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMessageID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messageID string
|
||||
wantTS int64
|
||||
wantPhone string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "valid direct message ID",
|
||||
messageID: "1700000000000:+4512345678",
|
||||
wantTS: 1700000000000,
|
||||
wantPhone: "+4512345678",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
messageID: "",
|
||||
wantTS: 0,
|
||||
wantPhone: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "no colon",
|
||||
messageID: "1700000000000",
|
||||
wantTS: 0,
|
||||
wantPhone: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "invalid timestamp",
|
||||
messageID: "notanumber:+4512345678",
|
||||
wantTS: 0,
|
||||
wantPhone: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "colon at end",
|
||||
messageID: "1700000000000:",
|
||||
wantTS: 0,
|
||||
wantPhone: "",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ts, phone, ok := parseMessageID(tt.messageID)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if ts != tt.wantTS {
|
||||
t.Errorf("timestamp = %d, want %d", ts, tt.wantTS)
|
||||
}
|
||||
if phone != tt.wantPhone {
|
||||
t.Errorf("phone = %q, want %q", phone, tt.wantPhone)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -233,6 +233,7 @@ type ChannelsConfig struct {
|
|||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||
Pico PicoConfig `json:"pico"`
|
||||
IRC IRCConfig `json:"irc"`
|
||||
Signal SignalConfig `json:"signal"`
|
||||
}
|
||||
|
||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||
|
|
@ -449,6 +450,16 @@ type IRCConfig struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
type SignalConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SIGNAL_ENABLED"`
|
||||
Account string `json:"account" env:"PICOCLAW_CHANNELS_SIGNAL_ACCOUNT"`
|
||||
SignalCLIURL string `json:"signal_cli_url" env:"PICOCLAW_CHANNELS_SIGNAL_CLI_URL"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SIGNAL_ALLOW_FROM"`
|
||||
DMsEnabled *bool `json:"dms_enabled,omitempty"`
|
||||
GroupsEnabled *bool `json:"groups_enabled,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SIGNAL_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue