Add routing plugin + default policy

This commit is contained in:
Mariano Belinky 2026-01-04 18:09:24 +01:00
commit f60140892c
7 changed files with 1716 additions and 0 deletions

89
README.md Normal file
View File

@ -0,0 +1,89 @@
# Clawgo (Go Node)
Minimal headless node client for Raspberry Pi / Linux. Connects to the gateway bridge, handles pairing, streams `voice.transcript` events (stdin/FIFO), subscribes to chat, and can speak responses via local TTS.
## Build
```bash
cd clawgo
go build ./cmd/clawgo
```
Cross-compile for Pi:
```bash
GOOS=linux GOARCH=arm64 go build -o /tmp/clawgo-linux-arm64 ./cmd/clawgo
```
## Key flags
| Flag | Description |
| --- | --- |
| `-session-key` | Session for outgoing `voice.transcript` events (default `main`). |
| `-chat-session-key` | Session to subscribe for chat replies (default mirrors `-session-key`). |
| `-chat-subscribe` | Enable chat stream+TTS (default `true`). |
| `-tts-engine` | `system`, `piper`, `elevenlabs`, or `none` (system = `espeak-ng`). |
| `-tts-system-voice` | espeak voice id (default `en-us`). |
| `-tts-system-rate` | Speech rate (wpm). |
| `-mdns-service` | Bonjour service type (default `_clawdis-node._tcp`). |
| `-stdin` | Read transcripts from stdin (pipe/FIFO). |
| `-stdin-file` | Read transcripts from a FIFO/file instead of stdin. |
| `-agent-request` | Send transcripts as `agent.request` (uses agent + deliver). |
| `-deliver` | Deliver agent responses to a provider (requires channel + to). |
| `-deliver-channel` | Delivery provider (telegram/whatsapp/signal/imessage). |
| `-deliver-to` | Delivery destination id. |
| `-quick-actions` | Enable built-in quick actions (default true). |
| `-ping-message` | Message used for telegram ping quick action. |
## Pair
```bash
./clawgo pair \
-bridge 100.88.46.29:18790 \
-display-name "Razor Pi"
```
Approve via `clawdis nodes approve <requestId>`.
## Run (FIFO + TTS example)
```bash
mkfifo /tmp/voice.fifo
# in one terminal
tail -f /tmp/voice.fifo | ./clawgo run \
-bridge 100.88.46.29:18790 \
-stdin \
-chat-subscribe \
-tts-engine system
# elsewhere
printf hey computer turn on the lightsn > /tmp/voice.fifo
```
Each line on the FIFO becomes a `voice.transcript`; chat responses from the `main` session are spoken via `espeak-ng`.
## systemd example
See `docs/linux-node.md` for the end-to-end Pi setup. TL;DR:
1. Install the binary as `/home/pi/clawgo`.
2. Create a wrapper script that keeps a FIFO (`/home/pi/.cache/clawdis/voice.fifo`) open and pipes it into `clawgo run -stdin`.
3. Create `/etc/systemd/system/clawgo.service` pointing to that wrapper.
## mDNS advertising
The node advertises `_clawdis-node._tcp` by default.
```bash
dns-sd -B _clawdis-node._tcp local.
```
Override to `_clawdis-bridge._tcp` if you intentionally want it to show up as a gateway beacon:
```bash
./clawgo run -mdns-service _clawdis-bridge._tcp
```
## Notes
- Node state (`nodeId` + token) lives in `~/.clawdis/clawgo.json`.
- Caps default to `voiceWake`; override via `-caps` if you expose more commands.
- Set `bridge.bind: "tailnet"` on the gateway to restrict the bridge to Tailscale.

1337
cmd/clawgo/main.go Normal file

File diff suppressed because it is too large Load Diff

13
go.mod Normal file
View File

@ -0,0 +1,13 @@
module github.com/clawdbot/clawgo
go 1.22
require github.com/grandcat/zeroconf v1.0.0
require (
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/miekg/dns v1.1.27 // indirect
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa // indirect
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe // indirect
)

26
go.sum Normal file
View File

@ -0,0 +1,26 @@
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe h1:6fAMxZRR6sl1Uq8U61gxU+kPTs2tR8uOySCbBP7BN/M=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -0,0 +1,80 @@
package defaultpolicy
import (
"context"
"strings"
"unicode"
"github.com/clawdbot/clawgo/internal/routing"
)
type Policy struct {
cfg routing.Config
transport routing.Transport
logf func(string, ...any)
}
func init() {
routing.Register("default", New)
}
func New(cfg routing.Config, transport routing.Transport, logf func(string, ...any)) (routing.Router, error) {
if logf == nil {
logf = func(string, ...any) {}
}
return &Policy{cfg: cfg, transport: transport, logf: logf}, nil
}
func (p *Policy) HandleTranscript(_ context.Context, text string) (bool, error) {
if strings.TrimSpace(text) == "" {
return false, nil
}
if p.cfg.QuickActions {
if handled, err := p.handleQuickActions(text); handled {
return true, err
}
}
if p.cfg.AgentRequest {
return true, p.transport.SendAgentRequest(p.cfg.SessionKey, text, p.cfg.Deliver, p.cfg.DeliverChannel, p.cfg.DeliverTo)
}
return true, p.transport.SendVoiceTranscript(p.cfg.SessionKey, text)
}
func (p *Policy) handleQuickActions(text string) (bool, error) {
if isTelegramPing(text) {
if p.cfg.DeliverChannel != "telegram" || p.cfg.DeliverTo == "" {
p.logf("quick action skipped: telegram delivery not configured")
return true, nil
}
message := strings.TrimSpace(p.cfg.QuickPingMessage)
if message == "" {
message = "Ping."
}
err := p.transport.SendProviderMessage("telegram", p.cfg.DeliverTo, message)
if err != nil {
p.logf("quick action send failed: %v", err)
} else {
p.logf("quick action: telegram ping sent")
}
return true, err
}
return false, nil
}
func isTelegramPing(text string) bool {
normalized := normalizeCommand(text)
return strings.Contains(normalized, "telegram") && strings.Contains(normalized, "ping")
}
func normalizeCommand(text string) string {
lowered := strings.ToLower(text)
var b strings.Builder
for _, r := range lowered {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
b.WriteRune(r)
continue
}
b.WriteRune(32)
}
return strings.Join(strings.Fields(b.String()), " ")
}

View File

@ -0,0 +1,110 @@
package defaultpolicy
import (
"context"
"testing"
"github.com/clawdbot/clawgo/internal/routing"
)
type call struct {
sessionKey string
text string
deliver bool
channel string
to string
provider string
message string
}
type fakeTransport struct {
voice []call
agent []call
provider []call
}
func (f *fakeTransport) SendVoiceTranscript(sessionKey, text string) error {
f.voice = append(f.voice, call{sessionKey: sessionKey, text: text})
return nil
}
func (f *fakeTransport) SendAgentRequest(sessionKey, text string, deliver bool, channel, to string) error {
f.agent = append(f.agent, call{sessionKey: sessionKey, text: text, deliver: deliver, channel: channel, to: to})
return nil
}
func (f *fakeTransport) SendProviderMessage(provider, to, message string) error {
f.provider = append(f.provider, call{provider: provider, to: to, message: message})
return nil
}
func TestDefaultPolicyQuickAction(t *testing.T) {
transport := &fakeTransport{}
cfg := routing.Config{
QuickActions: true,
DeliverChannel: "telegram",
DeliverTo: "123",
QuickPingMessage: "Ping.",
}
policy, err := New(cfg, transport, func(string, ...any) {})
if err != nil {
t.Fatalf("new policy: %v", err)
}
handled, err := policy.HandleTranscript(context.Background(), "hey razor ping me on telegram")
if err != nil {
t.Fatalf("handle: %v", err)
}
if !handled {
t.Fatalf("expected handled")
}
if len(transport.provider) != 1 {
t.Fatalf("expected provider message")
}
if len(transport.voice) != 0 || len(transport.agent) != 0 {
t.Fatalf("unexpected fallback sends")
}
}
func TestDefaultPolicyAgentRequest(t *testing.T) {
transport := &fakeTransport{}
cfg := routing.Config{
SessionKey: "main",
AgentRequest: true,
Deliver: true,
DeliverChannel: "telegram",
DeliverTo: "123",
}
policy, err := New(cfg, transport, func(string, ...any) {})
if err != nil {
t.Fatalf("new policy: %v", err)
}
_, err = policy.HandleTranscript(context.Background(), "hello")
if err != nil {
t.Fatalf("handle: %v", err)
}
if len(transport.agent) != 1 {
t.Fatalf("expected agent request")
}
if len(transport.voice) != 0 || len(transport.provider) != 0 {
t.Fatalf("unexpected fallback sends")
}
}
func TestDefaultPolicyVoiceTranscript(t *testing.T) {
transport := &fakeTransport{}
cfg := routing.Config{SessionKey: "main"}
policy, err := New(cfg, transport, func(string, ...any) {})
if err != nil {
t.Fatalf("new policy: %v", err)
}
_, err = policy.HandleTranscript(context.Background(), "hello")
if err != nil {
t.Fatalf("handle: %v", err)
}
if len(transport.voice) != 1 {
t.Fatalf("expected voice transcript")
}
if len(transport.agent) != 0 || len(transport.provider) != 0 {
t.Fatalf("unexpected fallback sends")
}
}

View File

@ -0,0 +1,61 @@
package routing
import (
"context"
"fmt"
"strings"
"sync"
)
type Transport interface {
SendVoiceTranscript(sessionKey, text string) error
SendAgentRequest(sessionKey, text string, deliver bool, channel, to string) error
SendProviderMessage(provider, to, message string) error
}
type Router interface {
HandleTranscript(ctx context.Context, text string) (bool, error)
}
type Config struct {
SessionKey string
AgentRequest bool
Deliver bool
DeliverChannel string
DeliverTo string
QuickActions bool
QuickPingMessage string
}
type Factory func(cfg Config, transport Transport, logf func(string, ...any)) (Router, error)
type registry struct {
mu sync.RWMutex
factories map[string]Factory
}
var globalRegistry = &registry{factories: map[string]Factory{}}
func Register(name string, factory Factory) {
name = strings.TrimSpace(name)
if name == "" || factory == nil {
return
}
globalRegistry.mu.Lock()
defer globalRegistry.mu.Unlock()
globalRegistry.factories[name] = factory
}
func New(name string, cfg Config, transport Transport, logf func(string, ...any)) (Router, error) {
name = strings.TrimSpace(name)
if name == "" {
name = "default"
}
globalRegistry.mu.RLock()
factory, ok := globalRegistry.factories[name]
globalRegistry.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("routing plugin not found: %s", name)
}
return factory(cfg, transport, logf)
}