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