diff --git a/Makefile b/Makefile
index ff280e3e45..7dd3d9e173 100644
--- a/Makefile
+++ b/Makefile
@@ -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)..."
diff --git a/README.fr.md b/README.fr.md
index d49edc5ee5..2bf65e3e5a 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -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) |
Telegram (Recommandé)
@@ -552,6 +553,56 @@ picoclaw gateway
+
+Signal (Sécurité Maximale)
+
+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
+```
+
+
+
##
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.
diff --git a/README.ja.md b/README.ja.md
index 793a51101b..31f64ad06d 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -236,6 +236,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
| **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL) |
| **WeCom** | 普通(CorpID + Webhook設定) |
+| **Signal** | 普通(独立した bridge デーモン) |
Telegram(推奨)
@@ -512,6 +513,56 @@ picoclaw gateway
+
+Signal(最高水準のセキュリティ)
+
+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
+```
+
+
+
## ⚙️ 設定
設定ファイル: `~/.picoclaw/config.json`
diff --git a/README.md b/README.md
index a82a9ad32e..5c7a1c4695 100644
--- a/README.md
+++ b/README.md
@@ -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) |
Telegram (Recommended)
@@ -559,6 +560,56 @@ picoclaw gateway
+
+Signal (Extremely Secure)
+
+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
+```
+
+
+
##
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.
diff --git a/README.pt-br.md b/README.pt-br.md
index a1788d1198..9e6699af3c 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -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) |
Telegram (Recomendado)
@@ -553,6 +554,56 @@ picoclaw gateway
+
+Signal (Segurança Máxima)
+
+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
+```
+
+
+
##
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.
diff --git a/README.vi.md b/README.vi.md
index 5548f88a4e..f0b13dd709 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -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) |
Telegram (Khuyên dùng)
@@ -533,6 +534,56 @@ picoclaw gateway
+
+Signal (Bảo mật tối đa)
+
+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
+```
+
+
+
##
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.
diff --git a/README.zh.md b/README.zh.md
index d470db0331..0e107ea447 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -282,6 +282,7 @@ picoclaw agent -m "2+2 等于几?"
| **QQ** | 简单 (AppID + AppSecret) |
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
+| **Signal** | 中等 (独立的 bridge 进程) |
Telegram (推荐)
@@ -521,6 +522,56 @@ picoclaw gateway
+
+Signal (极致安全)
+
+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
+```
+
+
+
##
加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
diff --git a/contrib/picoclaw-signal-bridge/.gitignore b/contrib/picoclaw-signal-bridge/.gitignore
new file mode 100644
index 0000000000..8403bfdb39
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/.gitignore
@@ -0,0 +1,6 @@
+picoclaw-signal-bridge
+lib/libsignal_ffi.a
+lib/signal_ffi.h
+*.db
+*.db-journal
+*.db-wal
diff --git a/contrib/picoclaw-signal-bridge/LICENSE b/contrib/picoclaw-signal-bridge/LICENSE
new file mode 100644
index 0000000000..a79401722b
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/LICENSE
@@ -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
diff --git a/contrib/picoclaw-signal-bridge/README.md b/contrib/picoclaw-signal-bridge/README.md
new file mode 100644
index 0000000000..06104db51b
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/README.md
@@ -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.
diff --git a/contrib/picoclaw-signal-bridge/bridge.go b/contrib/picoclaw-signal-bridge/bridge.go
new file mode 100644
index 0000000000..67b1fddc51
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/bridge.go
@@ -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] + "..."
+}
diff --git a/contrib/picoclaw-signal-bridge/build.sh b/contrib/picoclaw-signal-bridge/build.sh
new file mode 100755
index 0000000000..e74055cd8a
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/build.sh
@@ -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"
diff --git a/contrib/picoclaw-signal-bridge/go.mod b/contrib/picoclaw-signal-bridge/go.mod
new file mode 100644
index 0000000000..d58baad6a0
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/go.mod
@@ -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
+)
diff --git a/contrib/picoclaw-signal-bridge/go.sum b/contrib/picoclaw-signal-bridge/go.sum
new file mode 100644
index 0000000000..6b2fb5587a
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/go.sum
@@ -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=
diff --git a/contrib/picoclaw-signal-bridge/ipc.go b/contrib/picoclaw-signal-bridge/ipc.go
new file mode 100644
index 0000000000..5db85ada34
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/ipc.go
@@ -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)
+}
diff --git a/contrib/picoclaw-signal-bridge/main.go b/contrib/picoclaw-signal-bridge/main.go
new file mode 100644
index 0000000000..a091e0d898
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/main.go
@@ -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()
+}
diff --git a/contrib/picoclaw-signal-bridge/proto.go b/contrib/picoclaw-signal-bridge/proto.go
new file mode 100644
index 0000000000..4e065f08bc
--- /dev/null
+++ b/contrib/picoclaw-signal-bridge/proto.go
@@ -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"`
+}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index b80d1c8fbe..61d2f4367d 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -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),
})
diff --git a/pkg/channels/signal.go b/pkg/channels/signal.go
new file mode 100644
index 0000000000..efd1de6511
--- /dev/null
+++ b/pkg/channels/signal.go
@@ -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
+}
diff --git a/pkg/channels/signal_proto.go b/pkg/channels/signal_proto.go
new file mode 100644
index 0000000000..9d1941dace
--- /dev/null
+++ b/pkg/channels/signal_proto.go
@@ -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"`
+}
diff --git a/pkg/channels/signal_test.go b/pkg/channels/signal_test.go
new file mode 100644
index 0000000000..2a0c142812
--- /dev/null
+++ b/pkg/channels/signal_test.go
@@ -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)
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 005631e4a4..ecafa45c3e 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -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"`
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 7654326e76..5898a334a6 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -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},