|
ce03bda…
|
lmata
|
1 |
package client |
|
ce03bda…
|
lmata
|
2 |
|
|
ce03bda…
|
lmata
|
3 |
import ( |
|
ce03bda…
|
lmata
|
4 |
"bytes" |
|
ce03bda…
|
lmata
|
5 |
"context" |
|
ce03bda…
|
lmata
|
6 |
"encoding/json" |
|
ce03bda…
|
lmata
|
7 |
"fmt" |
|
ce03bda…
|
lmata
|
8 |
"net/http" |
|
ce03bda…
|
lmata
|
9 |
) |
|
ce03bda…
|
lmata
|
10 |
|
|
ce03bda…
|
lmata
|
11 |
// ChannelInfo is the result of creating or looking up a channel. |
|
ce03bda…
|
lmata
|
12 |
type ChannelInfo struct { |
|
ce03bda…
|
lmata
|
13 |
// Channel is the full IRC channel name (e.g. "#task.gh-42"). |
|
ce03bda…
|
lmata
|
14 |
Channel string `json:"channel"` |
|
ce03bda…
|
lmata
|
15 |
|
|
ce03bda…
|
lmata
|
16 |
// Type is the channel type name from the topology policy (e.g. "task", "sprint"). |
|
ce03bda…
|
lmata
|
17 |
// Empty if the channel does not match any configured type. |
|
ce03bda…
|
lmata
|
18 |
Type string `json:"type,omitempty"` |
|
ce03bda…
|
lmata
|
19 |
|
|
ce03bda…
|
lmata
|
20 |
// Supervision is the coordination/supervision channel where summaries |
|
ce03bda…
|
lmata
|
21 |
// from this channel should also be posted (e.g. "#general"). Empty if none. |
|
ce03bda…
|
lmata
|
22 |
Supervision string `json:"supervision,omitempty"` |
|
ce03bda…
|
lmata
|
23 |
|
|
ce03bda…
|
lmata
|
24 |
// Autojoin is the list of bot nicks that were invited when the channel was created. |
|
ce03bda…
|
lmata
|
25 |
Autojoin []string `json:"autojoin,omitempty"` |
|
ce03bda…
|
lmata
|
26 |
} |
|
ce03bda…
|
lmata
|
27 |
|
|
ce03bda…
|
lmata
|
28 |
// ChannelTypeInfo describes a class of channels defined in the topology policy. |
|
ce03bda…
|
lmata
|
29 |
type ChannelTypeInfo struct { |
|
ce03bda…
|
lmata
|
30 |
// Name is the type identifier (e.g. "task", "sprint"). |
|
ce03bda…
|
lmata
|
31 |
Name string `json:"name"` |
|
ce03bda…
|
lmata
|
32 |
|
|
ce03bda…
|
lmata
|
33 |
// Prefix is the channel name prefix (e.g. "task."). |
|
ce03bda…
|
lmata
|
34 |
Prefix string `json:"prefix"` |
|
ce03bda…
|
lmata
|
35 |
|
|
ce03bda…
|
lmata
|
36 |
// Autojoin is the list of bot nicks invited when a channel of this type is created. |
|
ce03bda…
|
lmata
|
37 |
Autojoin []string `json:"autojoin,omitempty"` |
|
ce03bda…
|
lmata
|
38 |
|
|
ce03bda…
|
lmata
|
39 |
// Supervision is the coordination channel for this type, or empty. |
|
ce03bda…
|
lmata
|
40 |
Supervision string `json:"supervision,omitempty"` |
|
ce03bda…
|
lmata
|
41 |
|
|
ce03bda…
|
lmata
|
42 |
// Ephemeral indicates channels of this type are automatically reaped. |
|
ce03bda…
|
lmata
|
43 |
Ephemeral bool `json:"ephemeral,omitempty"` |
|
ce03bda…
|
lmata
|
44 |
|
|
ce03bda…
|
lmata
|
45 |
// TTLSeconds is the maximum lifetime in seconds for ephemeral channels, or zero. |
|
ce03bda…
|
lmata
|
46 |
TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
|
ce03bda…
|
lmata
|
47 |
} |
|
ce03bda…
|
lmata
|
48 |
|
|
ce03bda…
|
lmata
|
49 |
// TopologyClient calls the scuttlebot HTTP API to provision and discover channels. |
|
ce03bda…
|
lmata
|
50 |
// It complements the IRC-based Client for the dual-channel pattern: agents create |
|
ce03bda…
|
lmata
|
51 |
// a task channel here and get back the supervision channel where they should also post. |
|
ce03bda…
|
lmata
|
52 |
type TopologyClient struct { |
|
ce03bda…
|
lmata
|
53 |
apiURL string |
|
ce03bda…
|
lmata
|
54 |
token string |
|
ce03bda…
|
lmata
|
55 |
http *http.Client |
|
ce03bda…
|
lmata
|
56 |
} |
|
ce03bda…
|
lmata
|
57 |
|
|
ce03bda…
|
lmata
|
58 |
// NewTopologyClient creates a TopologyClient. |
|
ce03bda…
|
lmata
|
59 |
// apiURL is the base URL of the scuttlebot API (e.g. "http://localhost:8080"). |
|
ce03bda…
|
lmata
|
60 |
// token is the Bearer token issued by scuttlebot. |
|
ce03bda…
|
lmata
|
61 |
func NewTopologyClient(apiURL, token string) *TopologyClient { |
|
ce03bda…
|
lmata
|
62 |
return &TopologyClient{ |
|
ce03bda…
|
lmata
|
63 |
apiURL: apiURL, |
|
ce03bda…
|
lmata
|
64 |
token: token, |
|
ce03bda…
|
lmata
|
65 |
http: &http.Client{}, |
|
ce03bda…
|
lmata
|
66 |
} |
|
ce03bda…
|
lmata
|
67 |
} |
|
ce03bda…
|
lmata
|
68 |
|
|
ce03bda…
|
lmata
|
69 |
type createChannelReq struct { |
|
ce03bda…
|
lmata
|
70 |
Name string `json:"name"` |
|
ce03bda…
|
lmata
|
71 |
Topic string `json:"topic,omitempty"` |
|
ce03bda…
|
lmata
|
72 |
Ops []string `json:"ops,omitempty"` |
|
ce03bda…
|
lmata
|
73 |
Voice []string `json:"voice,omitempty"` |
|
ce03bda…
|
lmata
|
74 |
Autojoin []string `json:"autojoin,omitempty"` |
|
ce03bda…
|
lmata
|
75 |
} |
|
ce03bda…
|
lmata
|
76 |
|
|
ce03bda…
|
lmata
|
77 |
// CreateChannel provisions an IRC channel via the scuttlebot topology API. |
|
ce03bda…
|
lmata
|
78 |
// The server applies autojoin policy and invites the configured bots. |
|
ce03bda…
|
lmata
|
79 |
// Returns a ChannelInfo with the channel name, type, and supervision channel. |
|
ce03bda…
|
lmata
|
80 |
// |
|
ce03bda…
|
lmata
|
81 |
// Example: create a task channel for a GitHub issue. |
|
ce03bda…
|
lmata
|
82 |
// |
|
ce03bda…
|
lmata
|
83 |
// info, err := topo.CreateChannel(ctx, "#task.gh-42", "GitHub issue #42") |
|
ce03bda…
|
lmata
|
84 |
// if err != nil { ... } |
|
ce03bda…
|
lmata
|
85 |
// // post activity to info.Channel, summaries to info.Supervision |
|
ce03bda…
|
lmata
|
86 |
func (t *TopologyClient) CreateChannel(ctx context.Context, name, topic string) (ChannelInfo, error) { |
|
ce03bda…
|
lmata
|
87 |
body, err := json.Marshal(createChannelReq{Name: name, Topic: topic}) |
|
ce03bda…
|
lmata
|
88 |
if err != nil { |
|
ce03bda…
|
lmata
|
89 |
return ChannelInfo{}, fmt.Errorf("topology: marshal request: %w", err) |
|
ce03bda…
|
lmata
|
90 |
} |
|
ce03bda…
|
lmata
|
91 |
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.apiURL+"/v1/channels", bytes.NewReader(body)) |
|
ce03bda…
|
lmata
|
92 |
if err != nil { |
|
ce03bda…
|
lmata
|
93 |
return ChannelInfo{}, fmt.Errorf("topology: build request: %w", err) |
|
ce03bda…
|
lmata
|
94 |
} |
|
ce03bda…
|
lmata
|
95 |
req.Header.Set("Content-Type", "application/json") |
|
ce03bda…
|
lmata
|
96 |
req.Header.Set("Authorization", "Bearer "+t.token) |
|
ce03bda…
|
lmata
|
97 |
|
|
ce03bda…
|
lmata
|
98 |
resp, err := t.http.Do(req) |
|
ce03bda…
|
lmata
|
99 |
if err != nil { |
|
ce03bda…
|
lmata
|
100 |
return ChannelInfo{}, fmt.Errorf("topology: create channel: %w", err) |
|
ce03bda…
|
lmata
|
101 |
} |
|
ce03bda…
|
lmata
|
102 |
defer resp.Body.Close() |
|
ce03bda…
|
lmata
|
103 |
if resp.StatusCode != http.StatusCreated { |
|
336984b…
|
lmata
|
104 |
var apiErr struct { |
|
336984b…
|
lmata
|
105 |
Error string `json:"error"` |
|
336984b…
|
lmata
|
106 |
} |
|
ce03bda…
|
lmata
|
107 |
_ = json.NewDecoder(resp.Body).Decode(&apiErr) |
|
ce03bda…
|
lmata
|
108 |
return ChannelInfo{}, fmt.Errorf("topology: create channel: %s", apiErr.Error) |
|
ce03bda…
|
lmata
|
109 |
} |
|
ce03bda…
|
lmata
|
110 |
var info ChannelInfo |
|
ce03bda…
|
lmata
|
111 |
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { |
|
ce03bda…
|
lmata
|
112 |
return ChannelInfo{}, fmt.Errorf("topology: decode response: %w", err) |
|
ce03bda…
|
lmata
|
113 |
} |
|
ce03bda…
|
lmata
|
114 |
return info, nil |
|
ce03bda…
|
lmata
|
115 |
} |
|
ce03bda…
|
lmata
|
116 |
|
|
ce03bda…
|
lmata
|
117 |
// DropChannel drops an ephemeral channel via the scuttlebot topology API. |
|
ce03bda…
|
lmata
|
118 |
// The ChanServ registration is removed and the channel will be vacated. |
|
ce03bda…
|
lmata
|
119 |
func (t *TopologyClient) DropChannel(ctx context.Context, channel string) error { |
|
ce03bda…
|
lmata
|
120 |
if len(channel) < 2 || channel[0] != '#' { |
|
ce03bda…
|
lmata
|
121 |
return fmt.Errorf("topology: invalid channel name %q", channel) |
|
ce03bda…
|
lmata
|
122 |
} |
|
ce03bda…
|
lmata
|
123 |
slug := channel[1:] // strip leading # |
|
ce03bda…
|
lmata
|
124 |
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, t.apiURL+"/v1/topology/channels/"+slug, nil) |
|
ce03bda…
|
lmata
|
125 |
if err != nil { |
|
ce03bda…
|
lmata
|
126 |
return fmt.Errorf("topology: build request: %w", err) |
|
ce03bda…
|
lmata
|
127 |
} |
|
ce03bda…
|
lmata
|
128 |
req.Header.Set("Authorization", "Bearer "+t.token) |
|
ce03bda…
|
lmata
|
129 |
resp, err := t.http.Do(req) |
|
ce03bda…
|
lmata
|
130 |
if err != nil { |
|
ce03bda…
|
lmata
|
131 |
return fmt.Errorf("topology: drop channel: %w", err) |
|
ce03bda…
|
lmata
|
132 |
} |
|
ce03bda…
|
lmata
|
133 |
defer resp.Body.Close() |
|
ce03bda…
|
lmata
|
134 |
if resp.StatusCode != http.StatusNoContent { |
|
336984b…
|
lmata
|
135 |
var apiErr struct { |
|
336984b…
|
lmata
|
136 |
Error string `json:"error"` |
|
336984b…
|
lmata
|
137 |
} |
|
ce03bda…
|
lmata
|
138 |
_ = json.NewDecoder(resp.Body).Decode(&apiErr) |
|
ce03bda…
|
lmata
|
139 |
return fmt.Errorf("topology: drop channel: %s", apiErr.Error) |
|
ce03bda…
|
lmata
|
140 |
} |
|
ce03bda…
|
lmata
|
141 |
return nil |
|
ce03bda…
|
lmata
|
142 |
} |
|
ce03bda…
|
lmata
|
143 |
|
|
ce03bda…
|
lmata
|
144 |
type topologyResp struct { |
|
ce03bda…
|
lmata
|
145 |
StaticChannels []string `json:"static_channels"` |
|
ce03bda…
|
lmata
|
146 |
Types []ChannelTypeInfo `json:"types"` |
|
ce03bda…
|
lmata
|
147 |
} |
|
ce03bda…
|
lmata
|
148 |
|
|
ce03bda…
|
lmata
|
149 |
// GetTopology returns the channel type rules and static channels from the server. |
|
ce03bda…
|
lmata
|
150 |
func (t *TopologyClient) GetTopology(ctx context.Context) ([]string, []ChannelTypeInfo, error) { |
|
ce03bda…
|
lmata
|
151 |
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.apiURL+"/v1/topology", nil) |
|
ce03bda…
|
lmata
|
152 |
if err != nil { |
|
ce03bda…
|
lmata
|
153 |
return nil, nil, fmt.Errorf("topology: build request: %w", err) |
|
ce03bda…
|
lmata
|
154 |
} |
|
ce03bda…
|
lmata
|
155 |
req.Header.Set("Authorization", "Bearer "+t.token) |
|
ce03bda…
|
lmata
|
156 |
resp, err := t.http.Do(req) |
|
ce03bda…
|
lmata
|
157 |
if err != nil { |
|
ce03bda…
|
lmata
|
158 |
return nil, nil, fmt.Errorf("topology: get topology: %w", err) |
|
ce03bda…
|
lmata
|
159 |
} |
|
ce03bda…
|
lmata
|
160 |
defer resp.Body.Close() |
|
ce03bda…
|
lmata
|
161 |
if resp.StatusCode != http.StatusOK { |
|
ce03bda…
|
lmata
|
162 |
return nil, nil, fmt.Errorf("topology: get topology: status %d", resp.StatusCode) |
|
ce03bda…
|
lmata
|
163 |
} |
|
ce03bda…
|
lmata
|
164 |
var body topologyResp |
|
ce03bda…
|
lmata
|
165 |
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
|
ce03bda…
|
lmata
|
166 |
return nil, nil, fmt.Errorf("topology: decode response: %w", err) |
|
ce03bda…
|
lmata
|
167 |
} |
|
ce03bda…
|
lmata
|
168 |
return body.StaticChannels, body.Types, nil |
|
ce03bda…
|
lmata
|
169 |
} |
|
ce03bda…
|
lmata
|
170 |
|
|
ce03bda…
|
lmata
|
171 |
// PostActivity sends a structured message to the task/activity channel. |
|
ce03bda…
|
lmata
|
172 |
// It is a convenience wrapper around client.Send for the dual-channel pattern. |
|
ce03bda…
|
lmata
|
173 |
func PostActivity(ctx context.Context, c *Client, channel, msgType string, payload any) error { |
|
ce03bda…
|
lmata
|
174 |
return c.Send(ctx, channel, msgType, payload) |
|
ce03bda…
|
lmata
|
175 |
} |
|
ce03bda…
|
lmata
|
176 |
|
|
ce03bda…
|
lmata
|
177 |
// PostSummary sends a structured message to the supervision channel. |
|
ce03bda…
|
lmata
|
178 |
// supervision is the channel returned by CreateChannel (info.Supervision). |
|
ce03bda…
|
lmata
|
179 |
// It is a no-op if supervision is empty. |
|
ce03bda…
|
lmata
|
180 |
func PostSummary(ctx context.Context, c *Client, supervision, msgType string, payload any) error { |
|
ce03bda…
|
lmata
|
181 |
if supervision == "" { |
|
ce03bda…
|
lmata
|
182 |
return nil |
|
ce03bda…
|
lmata
|
183 |
} |
|
ce03bda…
|
lmata
|
184 |
return c.Send(ctx, supervision, msgType, payload) |
|
ce03bda…
|
lmata
|
185 |
} |