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)
|
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
@echo "All builds complete"
|
@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: Install picoclaw to system and copy builtin skills
|
||||||
install: build
|
install: build
|
||||||
@echo "Installing $(BINARY_NAME)..."
|
@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) |
|
| **DingTalk** | Moyen (identifiants de l'application) |
|
||||||
| **LINE** | Moyen (identifiants + URL de webhook) |
|
| **LINE** | Moyen (identifiants + URL de webhook) |
|
||||||
| **WeCom** | Moyen (CorpID + configuration webhook) |
|
| **WeCom** | Moyen (CorpID + configuration webhook) |
|
||||||
|
| **Signal** | Moyen (démon bridge autonome) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommandé)</summary>
|
<summary><b>Telegram</b> (Recommandé)</summary>
|
||||||
|
|
@ -552,6 +553,56 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</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
|
## <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.
|
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** | 普通(アプリ認証情報) |
|
| **DingTalk** | 普通(アプリ認証情報) |
|
||||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||||
| **WeCom** | 普通(CorpID + Webhook設定) |
|
| **WeCom** | 普通(CorpID + Webhook設定) |
|
||||||
|
| **Signal** | 普通(独立した bridge デーモン) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b>(推奨)</summary>
|
<summary><b>Telegram</b>(推奨)</summary>
|
||||||
|
|
@ -512,6 +513,56 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</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`
|
設定ファイル: `~/.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) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **LINE** | Medium (credentials + webhook URL) |
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
| **WeCom** | Medium (CorpID + webhook setup) |
|
| **WeCom** | Medium (CorpID + webhook setup) |
|
||||||
|
| **Signal** | Medium (standalone bridge daemon) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
|
|
@ -559,6 +560,56 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</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
|
## <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.
|
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) |
|
| **DingTalk** | Médio (credenciais do app) |
|
||||||
| **LINE** | Médio (credenciais + webhook URL) |
|
| **LINE** | Médio (credenciais + webhook URL) |
|
||||||
| **WeCom** | Médio (CorpID + configuração webhook) |
|
| **WeCom** | Médio (CorpID + configuração webhook) |
|
||||||
|
| **Signal** | Médio (daemon bridge autônomo) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recomendado)</summary>
|
<summary><b>Telegram</b> (Recomendado)</summary>
|
||||||
|
|
@ -553,6 +554,56 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</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
|
## <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.
|
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) |
|
| **DingTalk** | Trung bình (app credentials) |
|
||||||
| **LINE** | Trung bình (credentials + webhook URL) |
|
| **LINE** | Trung bình (credentials + webhook URL) |
|
||||||
| **WeCom** | Trung bình (CorpID + cấu hình webhook) |
|
| **WeCom** | Trung bình (CorpID + cấu hình webhook) |
|
||||||
|
| **Signal** | Trung bình (standalone bridge daemon) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
||||||
|
|
@ -533,6 +534,56 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</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
|
## <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.
|
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) |
|
| **QQ** | 简单 (AppID + AppSecret) |
|
||||||
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
|
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
|
||||||
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
|
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
|
||||||
|
| **Signal** | 中等 (独立的 bridge 进程) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (推荐)</summary>
|
<summary><b>Telegram</b> (推荐)</summary>
|
||||||
|
|
@ -521,6 +522,56 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</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 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 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{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"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"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
WeCom WeComConfig `json:"wecom"`
|
WeCom WeComConfig `json:"wecom"`
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
|
Signal SignalConfig `json:"signal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
|
|
@ -200,6 +201,12 @@ type WhatsAppConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
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 {
|
type TelegramConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,11 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
ReplyTimeout: 5,
|
ReplyTimeout: 5,
|
||||||
},
|
},
|
||||||
|
Signal: SignalConfig{
|
||||||
|
Enabled: false,
|
||||||
|
BridgeURL: "unix:///tmp/picoclaw-signal.sock",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue