feat(signal): integrate native Signal messenger support

This commit introduces the independent picoclaw-signal-bridge daemon using Unix socket IPC for robust security and privacy. Updated Makefiles for a simplified build process and synchronized documentation across all 6 supported languages (EN, VI, ZH, JA, FR, PT-BR) to highlight Signal setup instructions.
This commit is contained in:
AlbertBui010 2026-02-21 15:21:36 +07:00
parent 1ef33c90ed
commit 4e3836c307
23 changed files with 1679 additions and 0 deletions

View file

@ -92,6 +92,10 @@ build-all: generate
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@echo "All builds complete"
## build-signal: Build the picoclaw-signal-bridge daemon
build-signal:
@cd contrib/picoclaw-signal-bridge && ./build.sh
## install: Install picoclaw to system and copy builtin skills
install: build
@echo "Installing $(BINARY_NAME)..."

View file

@ -272,6 +272,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
| **DingTalk** | Moyen (identifiants de l'application) |
| **LINE** | Moyen (identifiants + URL de webhook) |
| **WeCom** | Moyen (CorpID + configuration webhook) |
| **Signal** | Moyen (démon bridge autonome) |
<details>
<summary><b>Telegram</b> (Recommandé)</summary>
@ -552,6 +553,56 @@ picoclaw gateway
</details>
<details>
<summary><b>Signal</b> (Sécurité Maximale)</summary>
PicoClaw prend en charge Signal via un démon bridge autonome natif pour une confidentialité et une sécurité maximales.
**1. Compiler le Signal Bridge**
Consultez les [instructions de picoclaw-signal-bridge](contrib/picoclaw-signal-bridge/README.md) pour compiler le démon, ou exécutez simplement :
```bash
make build-signal
```
**2. Lier votre Appareil**
```bash
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal
```
**3. Configurer PicoClaw**
```json
{
"channels": {
"signal": {
"enabled": true,
"bridge_url": "unix:///tmp/picoclaw-signal.sock",
"allow_from": ["+1234567890"]
}
}
}
```
> **Astuce UX :** Le bridge extrait automatiquement les numéros de téléphone E164, vous permettant de les configurer facilement dans `allow_from` au lieu de gérer des UUID complexes.
**4. Lancer**
Démarrez à la fois le bridge et PicoClaw :
```bash
# Terminal 1 : Exécuter le bridge
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock
# Terminal 2 : Exécuter PicoClaw
picoclaw gateway
```
</details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Rejoignez le Réseau Social d'Agents
Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.

View file

@ -236,6 +236,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
| **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL |
| **WeCom** | 普通CorpID + Webhook設定 |
| **Signal** | 普通(独立した bridge デーモン) |
<details>
<summary><b>Telegram</b>(推奨)</summary>
@ -512,6 +513,56 @@ picoclaw gateway
</details>
<details>
<summary><b>Signal</b>(最高水準のセキュリティ)</summary>
PicoClaw は、ネイティブの独立した bridge デーモンを通じて Signal をサポートし、最大限のプライバシーとセキュリティを確保します。
**1. Signal Bridge のビルド**
ビルドの詳細については [picoclaw-signal-bridge の手順](contrib/picoclaw-signal-bridge/README.md) を参照するか、以下を実行してください:
```bash
make build-signal
```
**2. デバイスのリンク**
```bash
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal
```
**3. PicoClaw の設定**
```json
{
"channels": {
"signal": {
"enabled": true,
"bridge_url": "unix:///tmp/picoclaw-signal.sock",
"allow_from": ["+1234567890"]
}
}
}
```
> **UX のヒント:** ブリッジは自動的に E164 電話番号を抽出するため、複雑な UUID ではなく、電話番号を 直接 `allow_from` に設定できます。
**4. 実行**
bridge と PicoClaw の両方を起動します:
```bash
# Terminal 1: bridge を実行
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock
# Terminal 2: PicoClaw を実行
picoclaw gateway
```
</details>
## ⚙️ 設定
設定ファイル: `~/.picoclaw/config.json`

View file

@ -274,6 +274,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
| **WeCom** | Medium (CorpID + webhook setup) |
| **Signal** | Medium (standalone bridge daemon) |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
@ -559,6 +560,56 @@ picoclaw gateway
</details>
<details>
<summary><b>Signal</b> (Extremely Secure)</summary>
PicoClaw supports Signal via a native standalone bridge daemon for maximum privacy and security.
**1. Build the Signal Bridge**
See the [picoclaw-signal-bridge instructions](contrib/picoclaw-signal-bridge/README.md) or simply run:
```bash
make build-signal
```
**2. Link your Device**
```bash
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal
```
**3. Configure PicoClaw**
```json
{
"channels": {
"signal": {
"enabled": true,
"bridge_url": "unix:///tmp/picoclaw-signal.sock",
"allow_from": ["+1234567890"]
}
}
}
```
> **UX Tip:** The bridge automatically extracts E164 phone numbers, allowing you to easily configure phone numbers in `allow_from` rather than dealing with complex UUIDs.
**4. Run**
Start both the bridge and PicoClaw:
```bash
# Terminal 1: Run the bridge
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock
# Terminal 2: Run PicoClaw
picoclaw gateway
```
</details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.

View file

@ -273,6 +273,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
| **DingTalk** | Médio (credenciais do app) |
| **LINE** | Médio (credenciais + webhook URL) |
| **WeCom** | Médio (CorpID + configuração webhook) |
| **Signal** | Médio (daemon bridge autônomo) |
<details>
<summary><b>Telegram</b> (Recomendado)</summary>
@ -553,6 +554,56 @@ picoclaw gateway
</details>
<details>
<summary><b>Signal</b> (Segurança Máxima)</summary>
O PicoClaw suporta o Signal através de um daemon bridge autônomo nativo para máxima privacidade e segurança.
**1. Construir o Signal Bridge**
Veja as [instruções do picoclaw-signal-bridge](contrib/picoclaw-signal-bridge/README.md) para construir o daemon, ou simplesmente execute:
```bash
make build-signal
```
**2. Vincular seu Dispositivo**
```bash
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal
```
**3. Configurar o PicoClaw**
```json
{
"channels": {
"signal": {
"enabled": true,
"bridge_url": "unix:///tmp/picoclaw-signal.sock",
"allow_from": ["+1234567890"]
}
}
}
```
> **Dica de UX:** A bridge extrai automaticamente números de telefone E164, permitindo que você os configure facilmente no `allow_from` em vez de lidar com UUIDs complexos.
**4. Executar**
Inicie a bridge e o PicoClaw:
```bash
# Terminal 1: Executar a bridge
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock
# Terminal 2: Executar o PicoClaw
picoclaw gateway
```
</details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se a Rede Social de Agentes
Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.

View file

@ -253,6 +253,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
| **DingTalk** | Trung bình (app credentials) |
| **LINE** | Trung bình (credentials + webhook URL) |
| **WeCom** | Trung bình (CorpID + cấu hình webhook) |
| **Signal** | Trung bình (standalone bridge daemon) |
<details>
<summary><b>Telegram</b> (Khuyên dùng)</summary>
@ -533,6 +534,56 @@ picoclaw gateway
</details>
<details>
<summary><b>Signal</b> (Bảo mật tối đa)</summary>
PicoClaw hỗ trợ Signal thông qua một trình nền (daemon) bridge độc lập, giúp tối đa hoá sự riêng tư và bảo mật.
**1. Biên dịch (Build) Signal Bridge**
Xem [hướng dẫn picoclaw-signal-bridge](contrib/picoclaw-signal-bridge/README.md) để build daemon, hoặc đơn giản chạy lệnh:
```bash
make build-signal
```
**2. Liên kết Thiết bị**
```bash
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal
```
**3. Cấu hình PicoClaw**
```json
{
"channels": {
"signal": {
"enabled": true,
"bridge_url": "unix:///tmp/picoclaw-signal.sock",
"allow_from": ["+1234567890"]
}
}
}
```
> **Mẹo UX:** Bridge tự động trích xuất số điện thoại định dạng chuẩn (E164), cho phép bạn dễ dàng cấp quyền bằng số điện thoại trong `allow_from` thay vì phải xử lý các chuỗi UUID phức tạp.
**4. Khởi động**
Khởi động đồng thời cả bridge và mạng PicoClaw:
```bash
# Terminal 1: Chạy bridge
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock
# Terminal 2: Chạy PicoClaw
picoclaw gateway
```
</details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Tham gia Mạng xã hội Agent
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.

View file

@ -282,6 +282,7 @@ picoclaw agent -m "2+2 等于几?"
| **QQ** | 简单 (AppID + AppSecret) |
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
| **Signal** | 中等 (独立的 bridge 进程) |
<details>
<summary><b>Telegram</b> (推荐)</summary>
@ -521,6 +522,56 @@ picoclaw gateway
</details>
<details>
<summary><b>Signal</b> (极致安全)</summary>
PicoClaw 通过一个原生的独立 bridge 守护进程支持 Signal最大程度保证隐私与安全。
**1. 编译 (Build) Signal Bridge**
查看 [picoclaw-signal-bridge 指南](contrib/picoclaw-signal-bridge/README.md) 获取编译详情,或者直接运行:
```bash
make build-signal
```
**2. 链接您的设备**
```bash
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal
```
**3. 配置 PicoClaw**
```json
{
"channels": {
"signal": {
"enabled": true,
"bridge_url": "unix:///tmp/picoclaw-signal.sock",
"allow_from": ["+1234567890"]
}
}
}
```
> **UX 提示:** 该 bridge 会自动提取标准的 E164 电话号码,允许您直接在 `allow_from` 中配置电话号码,而无需处理复杂的 UUID
**4. 运行**
同时启动 bridge 和 PicoClaw
```bash
# 终端 1: 运行 bridge
cd contrib/picoclaw-signal-bridge
./picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock
# 终端 2: 运行 PicoClaw
picoclaw gateway
```
</details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。

View file

@ -0,0 +1,6 @@
picoclaw-signal-bridge
lib/libsignal_ffi.a
lib/signal_ffi.h
*.db
*.db-journal
*.db-wal

View file

@ -0,0 +1,10 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
This bridge program links libsignal_ffi.a (AGPL-3.0) and imports
mautrix-signal (AGPL-3.0), therefore it is licensed under AGPL-3.0.
PicoClaw itself (the main binary) remains MIT-licensed.
Communication happens over Unix socket IPC (process boundary).
See: https://www.gnu.org/licenses/agpl-3.0.html

View file

@ -0,0 +1,50 @@
# picoclaw-signal-bridge
> Native Signal messenger integration bridge for PicoClaw using `libsignal` FFI.
## Quick Start
```bash
# 1. Download dependencies and build
./build.sh
# 2. Link your Signal device (one-time setup)
./picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal
# 3. Start the bridge daemon
./picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock
```
## Features
- **Direct Signal Integration:** Sends and receives messages securely via the official Signal infrastructure.
- **Unix Socket IPC:** Fast, secure communication with the PicoClaw gateway.
- **E164 Phone Number Allowlist:** Automatically extracts sender phone numbers for easy configuration in PicoClaw (e.g., `UUID|+1234567890`).
- **Group Chat Spam Protection:** Automatically blocks and logs messages from group chats to maintain security and focus.
## Configuration
The bridge is configured via command-line flags.
| Flag | Description | Default |
|----------|-------------|---------|
| `--data-dir` | Path to store the SQLite session database | (current dir) |
| `--socket` | Unix socket path to listen on for PicoClaw IPC | `/tmp/picoclaw-signal.sock` |
| `--link` | Run in interactive QR-code device linking mode | false |
## Architecture
```
Signal Server ←→ picoclaw-signal-bridge (AGPL) ←→ PicoClaw (MIT)
Unix socket IPC
```
## Build Prerequisites
- Go 1.25+
- Rust toolchain (if compiling `libsignal_ffi.a` from source)
## License
AGPL-3.0 (due to `libsignal` and `mautrix-signal` dependencies).
PicoClaw's main binary remains MIT-licensed by executing this bridge as an independent process.

View file

@ -0,0 +1,338 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
"github.com/mdp/qrterminal/v3"
"github.com/rs/zerolog"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/events"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
"go.mau.fi/util/dbutil"
"google.golang.org/protobuf/proto"
_ "github.com/mattn/go-sqlite3"
)
// Bridge manages the Signal client and routes messages to/from PicoClaw via IPC.
type Bridge struct {
logger zerolog.Logger
dataDir string
container *store.Container
device *store.Device
client *signalmeow.Client
ipc *IPCServer
mu sync.Mutex
}
// NewBridge initializes the bridge with a data directory for session storage.
func NewBridge(logger zerolog.Logger, dataDir string) (*Bridge, error) {
dbPath := filepath.Join(dataDir, "signal.db")
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
rawDB, err := dbutil.NewWithDB(db, "sqlite3")
if err != nil {
return nil, fmt.Errorf("failed to create dbutil: %w", err)
}
container := store.NewStore(rawDB, nil)
if err := container.Upgrade(context.Background()); err != nil {
return nil, fmt.Errorf("failed to upgrade database: %w", err)
}
bridge := &Bridge{
logger: logger,
dataDir: dataDir,
container: container,
}
devices, err := container.GetAllDevices(context.Background())
if err == nil && len(devices) > 0 {
bridge.device = devices[0]
logger.Info().Str("number", bridge.device.Number).Msg("Loaded existing device session")
}
return bridge, nil
}
// IsLinked returns true if a device session exists.
func (b *Bridge) IsLinked() bool {
return b.device != nil && b.device.IsDeviceLoggedIn()
}
// LinkDevice performs the QR code device linking flow.
func (b *Bridge) LinkDevice(ctx context.Context) error {
b.logger.Info().Msg("Starting device provisioning...")
b.logger.Info().Msg("Open Signal app → Settings → Linked Devices → Link New Device")
fmt.Println()
provChan := signalmeow.PerformProvisioning(ctx, b.container, "PicoClaw Bridge", false)
for resp := range provChan {
switch resp.State {
case signalmeow.StateProvisioningURLReceived:
fmt.Println("📱 Scan this QR code with Signal app:")
fmt.Println()
qrterminal.GenerateHalfBlock(resp.ProvisioningURL, qrterminal.L, os.Stdout)
fmt.Println()
fmt.Printf(" Or open manually: %s\n", resp.ProvisioningURL)
fmt.Println()
case signalmeow.StateProvisioningDataReceived:
b.logger.Info().Msg("Provisioning data received, finalizing...")
case signalmeow.StateProvisioningError:
return fmt.Errorf("provisioning failed: %w", resp.Err)
}
if resp.ProvisioningData != nil {
b.logger.Info().
Str("aci", resp.ProvisioningData.ACI.String()).
Msg("✅ Device linked successfully!")
// Device data is already stored by PerformProvisioning via DeviceStore
return nil
}
}
return fmt.Errorf("provisioning channel closed unexpectedly")
}
// Start begins the Signal WebSocket connection and message routing.
func (b *Bridge) Start(ctx context.Context, ipc *IPCServer) error {
b.mu.Lock()
b.ipc = ipc
b.mu.Unlock()
if b.device == nil {
return fmt.Errorf("no device session, run --link first")
}
b.client = signalmeow.NewClient(b.device, b.logger, b.handleSignalEvent)
// Start listening for IPC messages from PicoClaw
go b.listenIPC(ctx)
// Connect to Signal and start receive loops
statusChan, err := b.client.StartReceiveLoops(ctx)
if err != nil {
return fmt.Errorf("failed to start receive loops: %w", err)
}
// Monitor connection status
go func() {
for status := range statusChan {
b.logger.Info().
Str("status", fmt.Sprintf("%v", status)).
Msg("Signal connection status changed")
}
}()
b.sendStatus(true, "Bridge connected to Signal")
return nil
}
// Stop gracefully shuts down the bridge.
func (b *Bridge) Stop() {
b.sendStatus(false, "Bridge shutting down")
b.mu.Lock()
defer b.mu.Unlock()
if b.client != nil {
_ = b.client.StopReceiveLoops()
}
if b.ipc != nil {
b.ipc.Close()
}
}
// handleSignalEvent processes events from the Signal client.
func (b *Bridge) handleSignalEvent(evt events.SignalEvent) bool {
switch e := evt.(type) {
case *events.ChatEvent:
b.handleChatEvent(e)
case *events.Receipt:
b.logger.Debug().Msg("Received receipt")
case *events.LoggedOut:
b.logger.Warn().Err(e.Error).Msg("Logged out from Signal")
b.sendStatus(false, "Device logged out")
default:
b.logger.Debug().Str("type", fmt.Sprintf("%T", evt)).Msg("Unhandled event")
}
return true
}
// handleChatEvent converts a Signal chat event to IPC format.
func (b *Bridge) handleChatEvent(evt *events.ChatEvent) {
senderUUID := evt.Info.Sender
sender := evt.Info.Sender.String()
chatID := evt.Info.ChatID
if chatID == "" {
chatID = sender
}
// 1. BLOCK GROUP CHATS FOR SPAM PROTECTION
if chatID != sender {
b.logger.Warn().
Str("from", sender).
Str("chat", chatID).
Msg("🛑 Blocked message from Group (Security Policy)")
return
}
// 2. GET PHONE NUMBER FOR BETTER ALLOWLIST UX
recipient, err := b.device.RecipientStore.LoadAndUpdateRecipient(context.Background(), senderUUID, uuid.Nil, nil)
if err == nil && recipient != nil && recipient.E164 != "" {
// Combine UUID and Phone: UUID|+1234xxxxxx
sender = sender + "|" + recipient.E164
}
// Extract text body from the protobuf event
body := extractBody(evt.Event)
b.logger.Info().
Str("from", sender).
Str("chat", chatID).
Str("preview", truncate(body, 50)).
Msg("Incoming Signal message")
ipcMsg := SignalIPCInbound{
Type: "message",
From: sender,
ChatID: chatID,
Content: body,
Metadata: map[string]string{
"timestamp": fmt.Sprintf("%d", evt.Info.ServerTimestamp),
},
}
b.mu.Lock()
ipc := b.ipc
b.mu.Unlock()
if ipc != nil {
if err := ipc.Send(ipcMsg); err != nil {
b.logger.Error().Err(err).Msg("Failed to send to PicoClaw")
}
}
}
// extractBody extracts the text body from a Signal chat event content.
func extractBody(evt signalpb.ChatEventContent) string {
switch e := evt.(type) {
case *signalpb.DataMessage:
return e.GetBody()
case *signalpb.EditMessage:
if dm := e.GetDataMessage(); dm != nil {
return dm.GetBody()
}
}
return ""
}
// listenIPC reads outbound messages from PicoClaw and sends them via Signal.
func (b *Bridge) listenIPC(ctx context.Context) {
b.mu.Lock()
ipc := b.ipc
b.mu.Unlock()
if ipc == nil {
return
}
for {
select {
case <-ctx.Done():
return
default:
data, err := ipc.Receive()
if err != nil {
// Avoid log spam when PicoClaw isn't running by sleeping 1s
time.Sleep(1 * time.Second)
continue
}
var outMsg SignalIPCOutbound
if err := json.Unmarshal(data, &outMsg); err != nil {
b.logger.Error().Err(err).Msg("Failed to parse IPC message")
continue
}
if outMsg.Type == "send" {
b.sendSignalMessage(ctx, outMsg)
}
}
}
}
// sendSignalMessage sends a message to a Signal recipient.
func (b *Bridge) sendSignalMessage(ctx context.Context, msg SignalIPCOutbound) {
recipientUUID, err := uuid.Parse(msg.To)
if err != nil {
b.logger.Error().Err(err).Str("to", msg.To).Msg("Invalid recipient UUID")
return
}
recipientServiceID := libsignalgo.NewACIServiceID(recipientUUID)
body := msg.Content
timestamp := uint64(time.Now().UnixMilli())
content := &signalpb.Content{
DataMessage: &signalpb.DataMessage{
Body: proto.String(body),
Timestamp: proto.Uint64(timestamp),
},
}
result := b.client.SendMessage(ctx, recipientServiceID, content)
if result.WasSuccessful {
b.logger.Info().Str("to", msg.To).Msg("Signal message sent")
} else {
errMsg := "unknown"
if result.FailedSendResult.Error != nil {
errMsg = result.FailedSendResult.Error.Error()
}
b.logger.Error().
Str("to", msg.To).
Str("error", errMsg).
Msg("Failed to send Signal message")
}
}
// sendStatus sends a status update to PicoClaw.
func (b *Bridge) sendStatus(connected bool, message string) {
b.mu.Lock()
ipc := b.ipc
b.mu.Unlock()
if ipc != nil {
_ = ipc.Send(SignalIPCInbound{
Type: "status",
Content: message,
Metadata: map[string]string{
"connected": fmt.Sprintf("%t", connected),
},
})
}
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View file

@ -0,0 +1,41 @@
#!/bin/bash
# Build script for picoclaw-signal-bridge
# Requires: Rust toolchain, Go 1.25+, cmake, protobuf
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LIB_DIR="$SCRIPT_DIR/lib"
mkdir -p "$LIB_DIR"
# Step 1: Build or download libsignal_ffi.a
if [ ! -f "$LIB_DIR/libsignal_ffi.a" ]; then
echo "📦 Building libsignal_ffi.a from source..."
TMPDIR=$(mktemp -d)
git clone --depth 1 https://github.com/signalapp/libsignal.git "$TMPDIR/libsignal"
cd "$TMPDIR/libsignal"
cargo build --release -p libsignal-ffi
cp target/release/libsignal_ffi.a "$LIB_DIR/"
cp swift/Sources/SignalFfi/signal_ffi.h "$LIB_DIR/"
cd "$SCRIPT_DIR"
rm -rf "$TMPDIR"
echo "✅ libsignal_ffi.a built"
else
echo "✅ libsignal_ffi.a already exists"
fi
# Step 2: Build Go binary
echo "🔨 Building picoclaw-signal-bridge..."
cd "$SCRIPT_DIR"
LIBRARY_PATH="$LIB_DIR:$LIBRARY_PATH" \
CGO_ENABLED=1 \
CGO_LDFLAGS="-L$LIB_DIR -lsignal_ffi -lc++ -framework Security -framework Foundation" \
go build -o picoclaw-signal-bridge .
echo "✅ Build complete: $SCRIPT_DIR/picoclaw-signal-bridge"
echo ""
echo "Usage:"
echo " # Link device (one-time)"
echo " ./picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal"
echo ""
echo " # Run bridge"
echo " ./picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock"

View file

@ -0,0 +1,29 @@
module github.com/sipeed/picoclaw/contrib/picoclaw-signal-bridge
go 1.25.7
require (
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.34
github.com/rs/zerolog v1.34.0
go.mau.fi/mautrix-signal v0.2602.0
go.mau.fi/util v0.9.6
google.golang.org/protobuf v1.36.11
)
require (
github.com/coder/websocket v1.8.14 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/mdp/qrterminal/v3 v3.2.1 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
rsc.io/qr v0.2.0 // indirect
)

View file

@ -0,0 +1,64 @@
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
go.mau.fi/mautrix-signal v0.2602.0 h1:HHSMoKEBysXdwJotFrwElP8YkPKz20cskdrznJ1ib34=
go.mau.fi/mautrix-signal v0.2602.0/go.mod h1:wMQAGayouAx9/5u/RqZ6aWwzxea5GmpDJgguc1xWTGI=
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View file

@ -0,0 +1,121 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"sync"
)
// IPCServer manages Unix socket communication with PicoClaw.
type IPCServer struct {
listener net.Listener
conn net.Conn
reader *bufio.Reader
mu sync.Mutex
sockPath string
}
// NewIPCServer creates a Unix socket server at the given path.
func NewIPCServer(sockPath string) (*IPCServer, error) {
// Remove stale socket
os.Remove(sockPath)
listener, err := net.Listen("unix", sockPath)
if err != nil {
return nil, fmt.Errorf("failed to listen on %s: %w", sockPath, err)
}
s := &IPCServer{
listener: listener,
sockPath: sockPath,
}
// Accept connections in background
go s.acceptLoop()
return s, nil
}
// acceptLoop accepts incoming connections from PicoClaw.
func (s *IPCServer) acceptLoop() {
for {
conn, err := s.listener.Accept()
if err != nil {
return // listener closed
}
s.mu.Lock()
// Close old connection if exists
if s.conn != nil {
s.conn.Close()
}
s.conn = conn
s.reader = bufio.NewReader(conn)
s.mu.Unlock()
}
}
// Send writes a JSON message to the connected PicoClaw client.
func (s *IPCServer) Send(msg interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil {
return fmt.Errorf("no PicoClaw client connected")
}
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
data = append(data, '\n')
if _, err := s.conn.Write(data); err != nil {
s.conn.Close()
s.conn = nil
s.reader = nil
return fmt.Errorf("failed to write to PicoClaw: %w", err)
}
return nil
}
// Receive reads a newline-delimited JSON message from PicoClaw.
func (s *IPCServer) Receive() ([]byte, error) {
s.mu.Lock()
reader := s.reader
s.mu.Unlock()
if reader == nil {
return nil, fmt.Errorf("no PicoClaw client connected")
}
line, err := reader.ReadBytes('\n')
if err != nil {
s.mu.Lock()
if s.conn != nil {
s.conn.Close()
s.conn = nil
s.reader = nil
}
s.mu.Unlock()
return nil, fmt.Errorf("read error: %w", err)
}
return line, nil
}
// Close shuts down the IPC server.
func (s *IPCServer) Close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
s.conn.Close()
}
s.listener.Close()
os.Remove(s.sockPath)
}

View file

@ -0,0 +1,102 @@
// picoclaw-signal-bridge — Signal bridge for PicoClaw
//
// Licensed under AGPL-3.0 (links libsignal_ffi.a + imports mautrix-signal)
// PicoClaw itself remains MIT-licensed.
//
// Usage:
// picoclaw-signal-bridge --link --data-dir ~/.picoclaw/signal
// picoclaw-signal-bridge --data-dir ~/.picoclaw/signal --socket /tmp/picoclaw-signal.sock
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/rs/zerolog"
)
var (
socketPath = flag.String("socket", "/tmp/picoclaw-signal.sock", "Unix socket path for PicoClaw IPC")
dataDir = flag.String("data-dir", "", "Data directory for Signal session (required)")
linkMode = flag.Bool("link", false, "Link as secondary device (one-time setup)")
logLevel = flag.String("log-level", "info", "Log level (debug, info, warn, error)")
)
func main() {
flag.Parse()
if *dataDir == "" {
home, _ := os.UserHomeDir()
*dataDir = filepath.Join(home, ".picoclaw", "signal")
}
if err := os.MkdirAll(*dataDir, 0700); err != nil {
log.Fatalf("Failed to create data dir: %v", err)
}
// Setup logger
level, err := zerolog.ParseLevel(*logLevel)
if err != nil {
level = zerolog.InfoLevel
}
logger := zerolog.New(zerolog.NewConsoleWriter()).
Level(level).
With().Timestamp().
Str("component", "signal-bridge").
Logger()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
bridge, err := NewBridge(logger, *dataDir)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to initialize bridge")
}
if *linkMode {
logger.Info().Msg("Starting device linking flow...")
if err := bridge.LinkDevice(ctx); err != nil {
logger.Fatal().Err(err).Msg("Device linking failed")
}
logger.Info().Msg("Device linked successfully!")
return
}
// Check if device is linked
if !bridge.IsLinked() {
fmt.Println("⚠️ No linked device found. Run with --link first:")
fmt.Printf(" %s --link --data-dir %s\n", os.Args[0], *dataDir)
os.Exit(1)
}
// Start IPC server
ipcServer, err := NewIPCServer(*socketPath)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to start IPC server")
}
defer ipcServer.Close()
// Start bridge with IPC
logger.Info().
Str("socket", *socketPath).
Str("data_dir", *dataDir).
Msg("Starting Signal bridge")
if err := bridge.Start(ctx, ipcServer); err != nil {
logger.Fatal().Err(err).Msg("Failed to start bridge")
}
// Wait for shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
logger.Info().Msg("Shutting down...")
bridge.Stop()
}

View file

@ -0,0 +1,21 @@
package main
// IPC protocol types — must match PicoClaw's pkg/channels/signal_proto.go
// SignalIPCInbound represents a message sent from bridge to PicoClaw.
type SignalIPCInbound struct {
Type string `json:"type"`
From string `json:"from,omitempty"`
ChatID string `json:"chat_id,omitempty"`
Content string `json:"content,omitempty"`
Media []string `json:"media,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// SignalIPCOutbound represents a message sent from PicoClaw to bridge.
type SignalIPCOutbound struct {
Type string `json:"type"`
To string `json:"to"`
Content string `json:"content"`
Attachments []string `json:"attachments,omitempty"`
}

View file

@ -202,6 +202,19 @@ func (m *Manager) initChannels() error {
}
}
if m.config.Channels.Signal.Enabled && m.config.Channels.Signal.BridgeURL != "" {
logger.DebugC("channels", "Attempting to initialize Signal channel")
signal, err := NewSignalChannel(m.config.Channels.Signal, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Signal channel", map[string]interface{}{
"error": err.Error(),
})
} else {
m.channels["signal"] = signal
logger.InfoC("channels", "Signal channel enabled successfully")
}
}
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
"enabled_channels": len(m.channels),
})

304
pkg/channels/signal.go Normal file
View file

@ -0,0 +1,304 @@
package channels
import (
"context"
"encoding/json"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
signalReconnectBaseDelay = 1 * time.Second
signalReconnectMaxDelay = 30 * time.Second
signalReadBufferSize = 64 * 1024 // 64KB read buffer
)
// SignalChannel communicates with picoclaw-signal-bridge via Unix socket or TCP.
// PicoClaw side is pure Go (MIT). All AGPL code lives in the separate bridge process.
type SignalChannel struct {
*BaseChannel
config config.SignalConfig
bridgeURL string
conn net.Conn
mu sync.Mutex
connected bool
}
// NewSignalChannel creates a new Signal channel that connects to the bridge process.
func NewSignalChannel(cfg config.SignalConfig, messageBus *bus.MessageBus) (*SignalChannel, error) {
base := NewBaseChannel("signal", cfg, messageBus, cfg.AllowFrom)
return &SignalChannel{
BaseChannel: base,
config: cfg,
bridgeURL: cfg.BridgeURL,
connected: false,
}, nil
}
// Start connects to the Signal bridge and begins listening for messages.
func (c *SignalChannel) Start(ctx context.Context) error {
logger.InfoCF("signal", "Starting Signal channel, connecting to bridge", map[string]interface{}{
"bridge_url": c.bridgeURL,
})
if err := c.connect(); err != nil {
// Don't fail startup — reconnect loop will retry
logger.WarnCF("signal", "Initial bridge connection failed, will retry", map[string]interface{}{
"error": err.Error(),
})
}
c.setRunning(true)
go c.listen(ctx)
return nil
}
// Stop disconnects from the Signal bridge.
func (c *SignalChannel) Stop(ctx context.Context) error {
logger.InfoC("signal", "Stopping Signal channel...")
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
if err := c.conn.Close(); err != nil {
logger.DebugCF("signal", "Error closing bridge connection", map[string]interface{}{
"error": err.Error(),
})
}
c.conn = nil
}
c.connected = false
c.setRunning(false)
return nil
}
// Send sends a message to the Signal bridge for delivery.
func (c *SignalChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("signal bridge not connected")
}
payload := SignalIPCOutbound{
Type: "send",
To: msg.ChatID,
Content: msg.Content,
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal signal message: %w", err)
}
// Append newline delimiter for the bridge to parse messages
data = append(data, '\n')
if _, err := c.conn.Write(data); err != nil {
return fmt.Errorf("failed to send to signal bridge: %w", err)
}
return nil
}
// connect establishes a connection to the bridge process.
func (c *SignalChannel) connect() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
network, address := parseBridgeURL(c.bridgeURL)
conn, err := net.DialTimeout(network, address, 10*time.Second)
if err != nil {
c.connected = false
return fmt.Errorf("failed to connect to signal bridge at %s: %w", c.bridgeURL, err)
}
c.conn = conn
c.connected = true
logger.InfoC("signal", "Connected to Signal bridge")
return nil
}
// listen reads messages from the bridge in a loop with automatic reconnection.
func (c *SignalChannel) listen(ctx context.Context) {
delay := signalReconnectBaseDelay
for {
select {
case <-ctx.Done():
return
default:
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
// Try to reconnect
if err := c.connect(); err != nil {
logger.DebugCF("signal", "Bridge reconnect failed", map[string]interface{}{
"error": err.Error(),
"retry_in_s": delay.Seconds(),
})
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
// Exponential backoff
delay *= 2
if delay > signalReconnectMaxDelay {
delay = signalReconnectMaxDelay
}
continue
}
// Reset delay on successful connect
delay = signalReconnectBaseDelay
}
buf := make([]byte, signalReadBufferSize)
n, err := conn.Read(buf)
if err != nil {
logger.WarnCF("signal", "Bridge read error, will reconnect", map[string]interface{}{
"error": err.Error(),
})
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connected = false
c.mu.Unlock()
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
continue
}
// Process potentially multiple newline-delimited JSON messages
messages := strings.Split(strings.TrimSpace(string(buf[:n])), "\n")
for _, raw := range messages {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
c.handleBridgeMessage([]byte(raw))
}
}
}
}
// handleBridgeMessage processes a single JSON message from the bridge.
func (c *SignalChannel) handleBridgeMessage(data []byte) {
var msg SignalIPCInbound
if err := json.Unmarshal(data, &msg); err != nil {
logger.ErrorCF("signal", "Failed to unmarshal bridge message", map[string]interface{}{
"error": err.Error(),
})
return
}
switch msg.Type {
case "message":
c.handleIncomingMessage(msg)
case "status":
logger.InfoCF("signal", "Bridge status update", map[string]interface{}{
"content": msg.Content,
})
case "error":
logger.ErrorCF("signal", "Bridge error", map[string]interface{}{
"content": msg.Content,
})
default:
logger.DebugCF("signal", "Unknown bridge message type", map[string]interface{}{
"type": msg.Type,
})
}
}
// handleIncomingMessage routes an incoming Signal message to the message bus.
func (c *SignalChannel) handleIncomingMessage(msg SignalIPCInbound) {
senderID := msg.From
chatID := msg.ChatID
if chatID == "" {
chatID = senderID
}
content := msg.Content
if content == "" {
content = "[empty message]"
}
metadata := msg.Metadata
if metadata == nil {
metadata = make(map[string]string)
}
// Set peer info if not provided by bridge
if _, ok := metadata["peer_kind"]; !ok {
if chatID == senderID {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
} else {
metadata["peer_kind"] = "group"
metadata["peer_id"] = chatID
}
}
if !c.IsAllowed(senderID) {
logger.WarnCF("signal", "🛑 Unauthorized user blocked. To allow, copy the phone number or UUID below into allow_from", map[string]interface{}{
"sender_id": senderID,
})
return
}
logger.InfoCF("signal", "Signal message received", map[string]interface{}{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 50),
})
c.HandleMessage(senderID, chatID, content, msg.Media, metadata)
}
// parseBridgeURL extracts network type and address from a bridge URL.
// Supported formats:
// - "unix:///path/to/socket" → ("unix", "/path/to/socket")
// - "tcp://host:port" → ("tcp", "host:port")
// - "host:port" → ("tcp", "host:port") (default)
func parseBridgeURL(url string) (network, address string) {
if strings.HasPrefix(url, "unix://") {
return "unix", strings.TrimPrefix(url, "unix://")
}
if strings.HasPrefix(url, "tcp://") {
return "tcp", strings.TrimPrefix(url, "tcp://")
}
// Default to TCP
return "tcp", url
}

View file

@ -0,0 +1,29 @@
package channels
// Signal IPC protocol messages for communication between PicoClaw and
// picoclaw-signal-bridge. This file is MIT-licensed and contains NO AGPL code.
// SignalIPCInbound represents an incoming message from the Signal bridge.
type SignalIPCInbound struct {
Type string `json:"type"` // "message", "status", "error"
From string `json:"from,omitempty"` // sender phone or UUID
ChatID string `json:"chat_id,omitempty"` // conversation ID
Content string `json:"content,omitempty"` // text content
Media []string `json:"media,omitempty"` // attachment file paths
Metadata map[string]string `json:"metadata,omitempty"`
}
// SignalIPCOutbound represents an outgoing message sent to the Signal bridge.
type SignalIPCOutbound struct {
Type string `json:"type"` // "send"
To string `json:"to"` // recipient phone or UUID
Content string `json:"content"` // text content
Attachments []string `json:"attachments,omitempty"` // attachment file paths
}
// SignalIPCStatus represents a status update from the bridge.
type SignalIPCStatus struct {
Type string `json:"type"` // "status"
Connected bool `json:"connected"` // bridge connection state
Message string `json:"message,omitempty"`
}

229
pkg/channels/signal_test.go Normal file
View file

@ -0,0 +1,229 @@
package channels
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestParseBridgeURL(t *testing.T) {
tests := []struct {
input string
network string
address string
}{
{"unix:///tmp/signal.sock", "unix", "/tmp/signal.sock"},
{"tcp://localhost:9090", "tcp", "localhost:9090"},
{"localhost:9090", "tcp", "localhost:9090"},
{"127.0.0.1:8080", "tcp", "127.0.0.1:8080"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
network, address := parseBridgeURL(tt.input)
assert.Equal(t, tt.network, network)
assert.Equal(t, tt.address, address)
})
}
}
func TestSignalIPCProtocol(t *testing.T) {
t.Run("marshal inbound message", func(t *testing.T) {
msg := SignalIPCInbound{
Type: "message",
From: "+84123456789",
ChatID: "+84123456789",
Content: "Hello from Signal",
Media: []string{"/tmp/photo.jpg"},
Metadata: map[string]string{
"peer_kind": "direct",
},
}
data, err := json.Marshal(msg)
require.NoError(t, err)
var decoded SignalIPCInbound
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, msg.Type, decoded.Type)
assert.Equal(t, msg.From, decoded.From)
assert.Equal(t, msg.Content, decoded.Content)
assert.Equal(t, msg.Media, decoded.Media)
})
t.Run("marshal outbound message", func(t *testing.T) {
msg := SignalIPCOutbound{
Type: "send",
To: "+84987654321",
Content: "Hello back",
}
data, err := json.Marshal(msg)
require.NoError(t, err)
var decoded SignalIPCOutbound
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, "send", decoded.Type)
assert.Equal(t, msg.To, decoded.To)
assert.Equal(t, msg.Content, decoded.Content)
})
}
func TestNewSignalChannel(t *testing.T) {
messageBus := bus.NewMessageBus()
cfg := config.SignalConfig{
Enabled: true,
BridgeURL: "unix:///tmp/test-signal.sock",
AllowFrom: config.FlexibleStringSlice{"+84123456789"},
}
ch, err := NewSignalChannel(cfg, messageBus)
require.NoError(t, err)
assert.Equal(t, "signal", ch.Name())
assert.False(t, ch.IsRunning())
assert.Equal(t, "unix:///tmp/test-signal.sock", ch.bridgeURL)
}
func TestSignalChannelSendReceive(t *testing.T) {
// Create a temp Unix socket for testing
tmpDir := t.TempDir()
sockPath := filepath.Join(tmpDir, "test.sock")
// Start mock bridge server
listener, err := net.Listen("unix", sockPath)
require.NoError(t, err)
defer listener.Close()
var receivedData []byte
serverReady := make(chan struct{})
messageReceived := make(chan struct{})
go func() {
close(serverReady)
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
// Send a test message to the channel
inbound := SignalIPCInbound{
Type: "message",
From: "+84111222333",
ChatID: "+84111222333",
Content: "Test message from Signal",
}
data, _ := json.Marshal(inbound)
data = append(data, '\n')
conn.Write(data)
// Read the response from Send()
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
receivedData = buf[:n]
close(messageReceived)
}()
<-serverReady
// Give server a moment to start accepting
time.Sleep(50 * time.Millisecond)
messageBus := bus.NewMessageBus()
cfg := config.SignalConfig{
Enabled: true,
BridgeURL: "unix://" + sockPath,
AllowFrom: config.FlexibleStringSlice{},
}
ch, err := NewSignalChannel(cfg, messageBus)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = ch.Start(ctx)
require.NoError(t, err)
assert.True(t, ch.IsRunning())
// Wait for bridge message to be processed
time.Sleep(200 * time.Millisecond)
// Send a reply
err = ch.Send(ctx, bus.OutboundMessage{
Channel: "signal",
ChatID: "+84111222333",
Content: "Reply from PicoClaw",
})
require.NoError(t, err)
// Wait for the mock server to receive the message
select {
case <-messageReceived:
// Verify the sent data
var outbound SignalIPCOutbound
err = json.Unmarshal(receivedData[:len(receivedData)-1], &outbound) // trim newline
require.NoError(t, err)
assert.Equal(t, "send", outbound.Type)
assert.Equal(t, "+84111222333", outbound.To)
assert.Equal(t, "Reply from PicoClaw", outbound.Content)
case <-time.After(3 * time.Second):
t.Fatal("Timed out waiting for message")
}
// Verify inbound message was published to bus
inMsg, ok := messageBus.ConsumeInbound(ctx)
assert.True(t, ok)
assert.Equal(t, "signal", inMsg.Channel)
assert.Equal(t, "+84111222333", inMsg.SenderID)
assert.Equal(t, "Test message from Signal", inMsg.Content)
// Stop
err = ch.Stop(ctx)
require.NoError(t, err)
assert.False(t, ch.IsRunning())
}
func TestSignalChannelReconnect(t *testing.T) {
messageBus := bus.NewMessageBus()
cfg := config.SignalConfig{
Enabled: true,
BridgeURL: "unix:///tmp/nonexistent-signal-test.sock",
AllowFrom: config.FlexibleStringSlice{},
}
ch, err := NewSignalChannel(cfg, messageBus)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Start should succeed even if bridge is not available (reconnect loop)
err = ch.Start(ctx)
require.NoError(t, err)
assert.True(t, ch.IsRunning())
// Send should fail when not connected
err = ch.Send(ctx, bus.OutboundMessage{
Channel: "signal",
ChatID: "+84111222333",
Content: "This should fail",
})
assert.Error(t, err)
_ = os.Remove("/tmp/nonexistent-signal-test.sock")
err = ch.Stop(ctx)
require.NoError(t, err)
}

View file

@ -192,6 +192,7 @@ type ChannelsConfig struct {
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"`
Signal SignalConfig `json:"signal"`
}
type WhatsAppConfig struct {
@ -200,6 +201,12 @@ type WhatsAppConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type SignalConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SIGNAL_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_SIGNAL_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SIGNAL_ALLOW_FROM"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`

View file

@ -113,6 +113,11 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
},
Signal: SignalConfig{
Enabled: false,
BridgeURL: "unix:///tmp/picoclaw-signal.sock",
AllowFrom: FlexibleStringSlice{},
},
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},