ScuttleBot

scuttlebot / pkg / protocol / protocol.go
Source Blame History 186 lines
60feebc… lmata 1 // Package protocol defines the scuttlebot wire format.
60feebc… lmata 2 //
60feebc… lmata 3 // Agent messages are JSON envelopes sent as IRC PRIVMSG.
60feebc… lmata 4 // System/status messages use NOTICE and are human-readable only.
cadb504… lmata 5 package protocol
60feebc… lmata 6
60feebc… lmata 7 import (
60feebc… lmata 8 "encoding/json"
60feebc… lmata 9 "fmt"
60feebc… lmata 10 "math/rand"
9ef9425… lmata 11 "strings"
60feebc… lmata 12 "time"
60feebc… lmata 13
60feebc… lmata 14 "github.com/oklog/ulid/v2"
60feebc… lmata 15 )
60feebc… lmata 16
60feebc… lmata 17 // Version is the current envelope version.
60feebc… lmata 18 const Version = 1
60feebc… lmata 19
60feebc… lmata 20 // Message types.
60feebc… lmata 21 const (
60feebc… lmata 22 TypeTaskCreate = "task.create"
60feebc… lmata 23 TypeTaskUpdate = "task.update"
60feebc… lmata 24 TypeTaskComplete = "task.complete"
60feebc… lmata 25 TypeAgentHello = "agent.hello"
60feebc… lmata 26 TypeAgentBye = "agent.bye"
60feebc… lmata 27 )
60feebc… lmata 28
60feebc… lmata 29 // Envelope is the standard wrapper for all agent messages over IRC.
60feebc… lmata 30 type Envelope struct {
60feebc… lmata 31 V int `json:"v"`
60feebc… lmata 32 Type string `json:"type"`
60feebc… lmata 33 ID string `json:"id"`
60feebc… lmata 34 From string `json:"from"`
9ef9425… lmata 35 To []string `json:"to,omitempty"`
60feebc… lmata 36 TS int64 `json:"ts"`
60feebc… lmata 37 Payload json.RawMessage `json:"payload,omitempty"`
9eb7d9e… noreply 38
9eb7d9e… noreply 39 // IRCv3 transport metadata — populated at receive time, not serialized.
9eb7d9e… noreply 40 Channel string `json:"-"` // channel the message arrived on
9eb7d9e… noreply 41 Account string `json:"-"` // account-tag: sender's NickServ account
9eb7d9e… noreply 42 MsgID string `json:"-"` // msgid tag: server-assigned message ID
9eb7d9e… noreply 43 ServerTime time.Time `json:"-"` // server-time tag: server-provided timestamp
9eb7d9e… noreply 44 Tags map[string]string `json:"-"` // all IRCv3 message tags
60feebc… lmata 45 }
60feebc… lmata 46
60feebc… lmata 47 // New creates a new Envelope with a generated ID and current timestamp.
9ef9425… lmata 48 // To is left empty (unaddressed — matches all recipients).
60feebc… lmata 49 func New(msgType, from string, payload any) (*Envelope, error) {
9ef9425… lmata 50 return NewTo(msgType, from, nil, payload)
9ef9425… lmata 51 }
9ef9425… lmata 52
9ef9425… lmata 53 // NewTo creates a new Envelope addressed to specific recipients.
9ef9425… lmata 54 // See MatchesRecipient for supported To patterns.
9ef9425… lmata 55 func NewTo(msgType, from string, to []string, payload any) (*Envelope, error) {
60feebc… lmata 56 var raw json.RawMessage
60feebc… lmata 57 if payload != nil {
60feebc… lmata 58 b, err := json.Marshal(payload)
60feebc… lmata 59 if err != nil {
60feebc… lmata 60 return nil, fmt.Errorf("protocol: marshal payload: %w", err)
60feebc… lmata 61 }
60feebc… lmata 62 raw = b
60feebc… lmata 63 }
60feebc… lmata 64 return &Envelope{
60feebc… lmata 65 V: Version,
60feebc… lmata 66 Type: msgType,
60feebc… lmata 67 ID: newID(),
60feebc… lmata 68 From: from,
9ef9425… lmata 69 To: to,
60feebc… lmata 70 TS: time.Now().UnixMilli(),
60feebc… lmata 71 Payload: raw,
60feebc… lmata 72 }, nil
60feebc… lmata 73 }
60feebc… lmata 74
60feebc… lmata 75 // Marshal encodes the envelope to JSON.
60feebc… lmata 76 func Marshal(e *Envelope) ([]byte, error) {
60feebc… lmata 77 b, err := json.Marshal(e)
60feebc… lmata 78 if err != nil {
60feebc… lmata 79 return nil, fmt.Errorf("protocol: marshal envelope: %w", err)
60feebc… lmata 80 }
60feebc… lmata 81 return b, nil
60feebc… lmata 82 }
60feebc… lmata 83
60feebc… lmata 84 // Unmarshal decodes a JSON envelope and validates it.
60feebc… lmata 85 func Unmarshal(data []byte) (*Envelope, error) {
60feebc… lmata 86 var e Envelope
60feebc… lmata 87 if err := json.Unmarshal(data, &e); err != nil {
60feebc… lmata 88 return nil, fmt.Errorf("protocol: unmarshal envelope: %w", err)
60feebc… lmata 89 }
60feebc… lmata 90 if err := validate(&e); err != nil {
60feebc… lmata 91 return nil, err
60feebc… lmata 92 }
60feebc… lmata 93 return &e, nil
60feebc… lmata 94 }
60feebc… lmata 95
60feebc… lmata 96 // UnmarshalPayload decodes the envelope payload into dst.
60feebc… lmata 97 func UnmarshalPayload(e *Envelope, dst any) error {
60feebc… lmata 98 if len(e.Payload) == 0 {
60feebc… lmata 99 return nil
60feebc… lmata 100 }
60feebc… lmata 101 if err := json.Unmarshal(e.Payload, dst); err != nil {
60feebc… lmata 102 return fmt.Errorf("protocol: unmarshal payload: %w", err)
60feebc… lmata 103 }
60feebc… lmata 104 return nil
60feebc… lmata 105 }
60feebc… lmata 106
60feebc… lmata 107 func validate(e *Envelope) error {
60feebc… lmata 108 if e.V != Version {
60feebc… lmata 109 return fmt.Errorf("protocol: unsupported version %d", e.V)
60feebc… lmata 110 }
60feebc… lmata 111 if e.Type == "" {
60feebc… lmata 112 return fmt.Errorf("protocol: missing type")
60feebc… lmata 113 }
60feebc… lmata 114 if e.ID == "" {
60feebc… lmata 115 return fmt.Errorf("protocol: missing id")
60feebc… lmata 116 }
60feebc… lmata 117 if e.From == "" {
60feebc… lmata 118 return fmt.Errorf("protocol: missing from")
60feebc… lmata 119 }
60feebc… lmata 120 return nil
9ef9425… lmata 121 }
9ef9425… lmata 122
9ef9425… lmata 123 // Group addressing tokens for use in Envelope.To.
9ef9425… lmata 124 const (
9ef9425… lmata 125 ToAll = "@all"
9ef9425… lmata 126 ToOperators = "@operators"
9ef9425… lmata 127 ToOrchestrators = "@orchestrators"
9ef9425… lmata 128 ToWorkers = "@workers"
9ef9425… lmata 129 ToObservers = "@observers"
9ef9425… lmata 130 )
9ef9425… lmata 131
9ef9425… lmata 132 // MatchesRecipient reports whether env is addressed to the agent identified by
9ef9425… lmata 133 // nick and agentType.
9ef9425… lmata 134 //
9ef9425… lmata 135 // Matching rules (OR'd across all To entries):
9ef9425… lmata 136 // - empty/nil To → true (unaddressed = broadcast, backwards compat)
9ef9425… lmata 137 // - "@all" → true
9ef9425… lmata 138 // - "@operators" etc. → agentType == "operator" etc.
9ef9425… lmata 139 // - "@prefix-*" → strings.HasPrefix(nick, "prefix-")
9ef9425… lmata 140 // - bare string → nick == token
9ef9425… lmata 141 func MatchesRecipient(env *Envelope, nick, agentType string) bool {
9ef9425… lmata 142 if len(env.To) == 0 {
9ef9425… lmata 143 return true
9ef9425… lmata 144 }
9ef9425… lmata 145 for _, token := range env.To {
9ef9425… lmata 146 switch token {
9ef9425… lmata 147 case ToAll:
9ef9425… lmata 148 return true
9ef9425… lmata 149 case ToOperators:
9ef9425… lmata 150 if agentType == "operator" {
9ef9425… lmata 151 return true
9ef9425… lmata 152 }
9ef9425… lmata 153 case ToOrchestrators:
9ef9425… lmata 154 if agentType == "orchestrator" {
9ef9425… lmata 155 return true
9ef9425… lmata 156 }
9ef9425… lmata 157 case ToWorkers:
9ef9425… lmata 158 if agentType == "worker" {
9ef9425… lmata 159 return true
9ef9425… lmata 160 }
9ef9425… lmata 161 case ToObservers:
9ef9425… lmata 162 if agentType == "observer" {
9ef9425… lmata 163 return true
9ef9425… lmata 164 }
9ef9425… lmata 165 default:
9ef9425… lmata 166 if strings.HasPrefix(token, "@") {
9ef9425… lmata 167 // @prefix-* glob: strip leading "@" and trailing "-*"
9ef9425… lmata 168 prefix := strings.TrimPrefix(token, "@")
9ef9425… lmata 169 if strings.HasSuffix(prefix, "-*") {
9ef9425… lmata 170 prefix = strings.TrimSuffix(prefix, "*")
9ef9425… lmata 171 if strings.HasPrefix(nick, prefix) {
9ef9425… lmata 172 return true
9ef9425… lmata 173 }
9ef9425… lmata 174 }
9ef9425… lmata 175 } else if token == nick {
9ef9425… lmata 176 return true
9ef9425… lmata 177 }
9ef9425… lmata 178 }
9ef9425… lmata 179 }
9ef9425… lmata 180 return false
60feebc… lmata 181 }
60feebc… lmata 182
60feebc… lmata 183 func newID() string {
60feebc… lmata 184 entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) //nolint:gosec
60feebc… lmata 185 return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
60feebc… lmata 186 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button