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:
parent
1ef33c90ed
commit
4e3836c307
23 changed files with 1679 additions and 0 deletions
4
Makefile
4
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)..."
|
||||
|
|
|
|||
51
README.fr.md
51
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) |
|
||||
|
||||
<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.
|
||||
|
|
|
|||
51
README.ja.md
51
README.ja.md
|
|
@ -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`
|
||||
|
|
|
|||
51
README.md
51
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) |
|
||||
|
||||
<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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
51
README.vi.md
51
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) |
|
||||
|
||||
<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.
|
||||
|
|
|
|||
51
README.zh.md
51
README.zh.md
|
|
@ -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 社交网络。
|
||||
|
|
|
|||
6
contrib/picoclaw-signal-bridge/.gitignore
vendored
Normal file
6
contrib/picoclaw-signal-bridge/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
picoclaw-signal-bridge
|
||||
lib/libsignal_ffi.a
|
||||
lib/signal_ffi.h
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
10
contrib/picoclaw-signal-bridge/LICENSE
Normal file
10
contrib/picoclaw-signal-bridge/LICENSE
Normal 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
|
||||
50
contrib/picoclaw-signal-bridge/README.md
Normal file
50
contrib/picoclaw-signal-bridge/README.md
Normal 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.
|
||||
338
contrib/picoclaw-signal-bridge/bridge.go
Normal file
338
contrib/picoclaw-signal-bridge/bridge.go
Normal 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] + "..."
|
||||
}
|
||||
41
contrib/picoclaw-signal-bridge/build.sh
Executable file
41
contrib/picoclaw-signal-bridge/build.sh
Executable 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"
|
||||
29
contrib/picoclaw-signal-bridge/go.mod
Normal file
29
contrib/picoclaw-signal-bridge/go.mod
Normal 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
|
||||
)
|
||||
64
contrib/picoclaw-signal-bridge/go.sum
Normal file
64
contrib/picoclaw-signal-bridge/go.sum
Normal 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=
|
||||
121
contrib/picoclaw-signal-bridge/ipc.go
Normal file
121
contrib/picoclaw-signal-bridge/ipc.go
Normal 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)
|
||||
}
|
||||
102
contrib/picoclaw-signal-bridge/main.go
Normal file
102
contrib/picoclaw-signal-bridge/main.go
Normal 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()
|
||||
}
|
||||
21
contrib/picoclaw-signal-bridge/proto.go
Normal file
21
contrib/picoclaw-signal-bridge/proto.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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
304
pkg/channels/signal.go
Normal 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
|
||||
}
|
||||
29
pkg/channels/signal_proto.go
Normal file
29
pkg/channels/signal_proto.go
Normal 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
229
pkg/channels/signal_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue