|
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
|
|