Add routing plugin + default policy
This commit is contained in:
commit
f60140892c
89
README.md
Normal file
89
README.md
Normal 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
1337
cmd/clawgo/main.go
Normal file
File diff suppressed because it is too large
Load Diff
13
go.mod
Normal file
13
go.mod
Normal 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
26
go.sum
Normal 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=
|
||||
80
internal/routing/policy/default/policy.go
Normal file
80
internal/routing/policy/default/policy.go
Normal 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()), " ")
|
||||
}
|
||||
110
internal/routing/policy/default/policy_test.go
Normal file
110
internal/routing/policy/default/policy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
61
internal/routing/routing.go
Normal file
61
internal/routing/routing.go
Normal 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 = ®istry{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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user