Skip to content

feat(signal): add Signal channel via signal-cli#630

Open
achton wants to merge 4 commits intosipeed:mainfrom
achton:feat/signal-channel
Open

feat(signal): add Signal channel via signal-cli#630
achton wants to merge 4 commits intosipeed:mainfrom
achton:feat/signal-channel

Conversation

@achton
Copy link
Contributor

@achton achton commented Feb 22, 2026

📝 Description

Adds Signal as a messaging channel, implementing the signal-cli approach proposed in #41 (Option A). Rebased onto the channel refactor (#621 / #877).

PicoClaw connects to a signal-cli daemon running in HTTP mode. Inbound messages arrive via SSE (/api/v1/events), outbound messages are sent via JSON-RPC (/api/v1/rpc). No CGO or additional compiled dependencies are needed in PicoClaw itself.

Question for maintainers

signal-cli is a required external dependency (Java-based, ~50-100MB RAM). It runs as a separate daemon process — PicoClaw communicates with it over HTTP, so there's no compile-time coupling. Is this acceptable, or would you prefer a different approach? The alternative (native libsignal via CGO) adds significant build complexity and AGPL licensing concerns (see PR #603 for that approach).

Known upstream issue

signal-cli v0.13.24 has a bug (AsamK/signal-cli#1940) where the mentions array in JSON output is empty due to a binary ACI parsing issue. The fix is in AsamK/signal-cli#1944 but not yet released. Our mention detection code is correct and will work once the fix ships — until then, group_trigger.mention_only mode won't respond in groups (DMs work fine).

Key design points

  • Post-refactor architecture — subpackage at pkg/channels/signal/, factory registration via init(), BaseChannel embedding with functional options
  • Capability interfaces — implements TypingCapable (8s refresh loop), ReactionCapable (emoji reaction on inbound), MessageLengthProvider (6000 rune limit)
  • Structured identity — uses bus.SenderInfo with canonical signal:+phone format via the new pkg/identity package
  • Group trigger support — uses GroupTriggerConfig (mention_only, prefixes) with @mention detection via structured mention data from signal-cli
  • Markdown-to-Signal formatting — converts **bold**, *italic*, `code`, ```blocks```, ~~strike~~ to signal-cli textStyle ranges with correct UTF-16 code unit positions
  • Error classification — uses ErrTemporary / ErrNotRunning sentinels for Manager retry logic
  • Attachment support — downloads images, audio, and files from signal-cli; temp files cleaned up after processing
  • No license complications — signal-cli runs as a separate process, keeping PicoClaw fully MIT
  • Test coverage — 46 test cases across markdown conversion, MIME type mapping, SSE event deserialization, isGroupChat() heuristic, and parseMessageID()

🗣️ Type of Change

  • 🐞 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 📖 Documentation update
  • ⚡ Code refactoring (no functional changes, no api changes)

🤖 AI Code Generation

  • 🤖 Fully AI-generated (100% AI, 0% Human)
  • 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
  • 👨‍💻 Mostly Human-written (Human lead, AI assisted or none)

🔗 Related Issue

Closes #41

📚 Technical Context (Skip for Docs)

  • Reference URL: https://github.com/AsamK/signal-cli/wiki/HTTP-API
  • Reasoning: signal-cli provides a stable HTTP/JSON-RPC interface to Signal, avoiding the need for native libsignal compilation (Rust toolchain, CGO, platform-specific linking). This is especially important for embedded/ARM deployments where cross-compilation of native crypto libraries is impractical.

🧪 Test Environment

  • Hardware: Linux PC (x86_64), Raspberry Pi 5 (ARM64)
  • OS: Debian 12 (Bookworm)
  • Model/Provider: OpenRouter (various models)
  • Channels: Signal (DMs and groups, tested with real users)

📸 Evidence (Optional)

Click to view Logs/Screenshots
[INFO] signal: Starting Signal channel {signal_cli_url=http://127.0.0.1:8090, account=+***}
[INFO] signal: Signal channel started
[INFO] signal: SSE connected successfully
[INFO] agent: Processing message from signal:signal:+***: Hello {channel=signal, chat_id=+***, sender_id=signal:+***}
[INFO] agent: Routed message {agent_id=default, session_key=agent:default:signal:direct:+***, matched_by=binding.peer}
[INFO] agent: LLM response without tool calls (direct answer) {agent_id=default, iteration=1, content_chars=42}
[INFO] agent: Published outbound response {channel=signal, chat_id=+***, content_len=42}

☑️ Checklist

  • My code/docs follow the style of this project.
  • I have performed a self-review of my own changes.
  • I have updated the documentation accordingly.

@nikolasdehor
Copy link

Note: #603 also proposes native Signal support. Both address the same feature request. Suggesting the maintainer coordinate or pick one to avoid duplicated effort.

@achton achton force-pushed the feat/signal-channel branch 2 times, most recently from 47b2569 to 4744c69 Compare February 24, 2026 21:31
@yinwm
Copy link
Collaborator

yinwm commented Feb 28, 2026

@achton hi, could you please resolve conflicts, we just merged channel refactoring issue

@achton achton force-pushed the feat/signal-channel branch from 4744c69 to e682afa Compare February 28, 2026 10:36
achton added a commit to achton/picoclaw that referenced this pull request Feb 28, 2026
Adapt the Signal channel (PR sipeed#630) to the upstream channel system
refactor (sipeed#662, sipeed#877). Fresh implementation on current main rather
than rebasing 5 commits across 155 upstream changes.

Changes from the original sipeed#630:
- Moved from flat pkg/channels/signal.go to pkg/channels/signal/ subpackage
- Factory registration via init() + blank import in gateway
- New HandleMessage signature with bus.Peer, bus.SenderInfo, identity.BuildCanonicalID
- IsAllowedSender() replaces IsAllowed() for structured identity matching
- Manager-handled message splitting via WithMaxMessageLength(6000)
- Typed errors (ErrNotRunning, ErrTemporary) per Phase 4 lifecycle contract
- Proper goroutine tracking with sync.WaitGroup in Start/Stop
- TypingCapable interface wraps existing typing indicator feature
- ReactionCapable interface (first channel to implement — 👀 on inbound, undo on reply)
- WithReasoningChannelID() option for routing LLM reasoning to separate channel

All original features preserved: SSE inbound, JSON-RPC sending,
markdown-to-Signal text styles, voice transcription, attachment
handling, group/DM filtering, typing indicators.

28 tests passing (24 ported + 4 new for parseMessageID).
@achton achton force-pushed the feat/signal-channel branch from e682afa to b492ecd Compare February 28, 2026 10:39
@achton
Copy link
Contributor Author

achton commented Feb 28, 2026

@yinwm I've rebased and reimplemented this feature on top of the channels refactor, ready for review now (again).

achton added a commit to achton/picoclaw that referenced this pull request Feb 28, 2026
Adapt the Signal channel (PR sipeed#630) to the upstream channel system
refactor (sipeed#662, sipeed#877). Fresh implementation on current main rather
than rebasing 5 commits across 155 upstream changes.

Changes from the original sipeed#630:
- Moved from flat pkg/channels/signal.go to pkg/channels/signal/ subpackage
- Factory registration via init() + blank import in gateway
- New HandleMessage signature with bus.Peer, bus.SenderInfo, identity.BuildCanonicalID
- IsAllowedSender() replaces IsAllowed() for structured identity matching
- Manager-handled message splitting via WithMaxMessageLength(6000)
- Typed errors (ErrNotRunning, ErrTemporary) per Phase 4 lifecycle contract
- Proper goroutine tracking with sync.WaitGroup in Start/Stop
- TypingCapable interface wraps existing typing indicator feature
- ReactionCapable interface (first channel to implement — 👀 on inbound, undo on reply)
- WithReasoningChannelID() option for routing LLM reasoning to separate channel

All original features preserved: SSE inbound, JSON-RPC sending,
markdown-to-Signal text styles, voice transcription, attachment
handling, group/DM filtering, typing indicators.

28 tests passing (24 ported + 4 new for parseMessageID).
@achton achton force-pushed the feat/signal-channel branch from 988af27 to 47113b4 Compare February 28, 2026 11:07
@achton achton force-pushed the feat/signal-channel branch from 47113b4 to c308fe6 Compare March 4, 2026 08:09
achton added a commit to achton/picoclaw that referenced this pull request Mar 4, 2026
Adapt the Signal channel (PR sipeed#630) to the upstream channel system
refactor (sipeed#662, sipeed#877). Fresh implementation on current main rather
than rebasing 5 commits across 155 upstream changes.

Changes from the original sipeed#630:
- Moved from flat pkg/channels/signal.go to pkg/channels/signal/ subpackage
- Factory registration via init() + blank import in gateway
- New HandleMessage signature with bus.Peer, bus.SenderInfo, identity.BuildCanonicalID
- IsAllowedSender() replaces IsAllowed() for structured identity matching
- Manager-handled message splitting via WithMaxMessageLength(6000)
- Typed errors (ErrNotRunning, ErrTemporary) per Phase 4 lifecycle contract
- Proper goroutine tracking with sync.WaitGroup in Start/Stop
- TypingCapable interface wraps existing typing indicator feature
- ReactionCapable interface (first channel to implement — 👀 on inbound, undo on reply)
- WithReasoningChannelID() option for routing LLM reasoning to separate channel

All original features preserved: SSE inbound, JSON-RPC sending,
markdown-to-Signal text styles, voice transcription, attachment
handling, group/DM filtering, typing indicators.

28 tests passing (24 ported + 4 new for parseMessageID).
@achton
Copy link
Contributor Author

achton commented Mar 4, 2026

I have rebased on main in order to resolve git conflicts in pkg/config/config.go.

@CLAassistant
Copy link

CLAassistant commented Mar 5, 2026

CLA assistant check
All committers have signed the CLA.

achton and others added 4 commits March 9, 2026 11:48
Adapt the Signal channel (PR sipeed#630) to the upstream channel system
refactor (sipeed#662, sipeed#877). Fresh implementation on current main rather
than rebasing 5 commits across 155 upstream changes.

Changes from the original sipeed#630:
- Moved from flat pkg/channels/signal.go to pkg/channels/signal/ subpackage
- Factory registration via init() + blank import in gateway
- New HandleMessage signature with bus.Peer, bus.SenderInfo, identity.BuildCanonicalID
- IsAllowedSender() replaces IsAllowed() for structured identity matching
- Manager-handled message splitting via WithMaxMessageLength(6000)
- Typed errors (ErrNotRunning, ErrTemporary) per Phase 4 lifecycle contract
- Proper goroutine tracking with sync.WaitGroup in Start/Stop
- TypingCapable interface wraps existing typing indicator feature
- ReactionCapable interface (first channel to implement — 👀 on inbound, undo on reply)
- WithReasoningChannelID() option for routing LLM reasoning to separate channel

All original features preserved: SSE inbound, JSON-RPC sending,
markdown-to-Signal text styles, voice transcription, attachment
handling, group/DM filtering, typing indicators.

28 tests passing (24 ported + 4 new for parseMessageID).
- Replace DMsEnabled/GroupsEnabled with GroupTrigger (mention_only, prefixes)
- Add @mention detection for group chats (isBotMentioned, stripMention)
- Fix sendReaction/sendTyping recipient type (string → []string)
- Fix Send() error wrapping to preserve root cause
- Add io.LimitReader guard on SSE error body read
- Remove dead voice transcription code (deferred)
- Remove compound senderID (redundant with SenderInfo)
- Add isGroupChat safety comment
- Update README config docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The SSE client (no timeout, long-lived stream) was recreated on every
reconnect, wasting connection pool resources. Hoist it to the struct
alongside the RPC client (30s timeout) so connections can be reused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@achton achton force-pushed the feat/signal-channel branch from c308fe6 to af25536 Compare March 9, 2026 10:49
@achton
Copy link
Contributor Author

achton commented Mar 9, 2026

I have rebased on main again in order to resolve git conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feat: Add Signal channel integration

4 participants