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
|
## 💬 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.
|
> **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) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||||
|
| **Signal** | Easy (signal-cli daemon + phone) |
|
||||||
| **Matrix** | Medium (homeserver + bot access token) |
|
| **Matrix** | Medium (homeserver + bot access token) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
|
|
@ -463,6 +464,56 @@ If `session_store_path` is empty, the session is stored in `<workspace>/wh
|
||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary><b>QQ</b></summary>
|
<summary><b>QQ</b></summary>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
_ "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/slack"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,10 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("irc", "IRC")
|
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{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"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"`
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
IRC IRCConfig `json:"irc"`
|
IRC IRCConfig `json:"irc"`
|
||||||
|
Signal SignalConfig `json:"signal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// 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"`
|
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 {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue