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:
Achton Smidt Winther 2026-02-28 08:52:20 +01:00
parent abafa3c2aa
commit d1c557a728
No known key found for this signature in database
GPG key ID: B2C1917109213BD1
7 changed files with 1369 additions and 1 deletions

View file

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

View file

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

View file

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

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

View 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
}

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

View file

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