ScuttleBot

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

Keyboard Shortcuts

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