okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: webhooks on listView: filter, pane, confirm only for destructive actions, add on ctrl+o82b9457

main
14.1 KiB471 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"cmp"
6	"context"
7	"errors"
8	"slices"
9	"strings"
10
11	tea "charm.land/bubbletea/v2"
12	"github.com/charmbracelet/x/ansi"
13)
14
15// WebhooksPageModel is the Webhooks screen: every signed endpoint grouped by
16// the session it wakes, their inboxes, and whether the agent of the session it
17// was opened from may manage its own. Changes use the typed webhook API and
18// reload the list after saving.
19type WebhooksPageModel struct {
20	Conn *daemon.Connection
21	Form *webhookForm
22	// Reveal holds a generated secret until it is dismissed; it is never
23	// fetched again.
24	Reveal         *webhookSecret
25	SessionID      string
26	PermissionETag string
27	// Sessions are the ones a hook can target, the current one first.
28	Sessions []daemon.Session
29	Hooks    []webhookEntry
30	pageStatus
31	listView
32	confirm
33	AgentManagement bool
34	// Mounted reports whether the extension is on globally; the daemon only
35	// listens for deliveries then.
36	Mounted bool
37	Loaded  bool
38}
39
40type webhookEntry struct {
41	ETag     string
42	ID       string
43	Session  string
44	Name     string
45	Address  string
46	URL      string
47	Header   string
48	Prefix   string
49	Deferred string
50	Queued   int
51	Enabled  bool
52}
53
54type webhookSecret struct{ Hook, Session, Secret string }
55
56type webhooksLoadedMsg struct {
57	PermissionETag string
58	Err            error
59	Sessions       []daemon.Session
60	Hooks          []webhookEntry
61	Gen            int
62	Agent          bool
63	Mounted        bool
64}
65
66type webhooksSavedMsg struct {
67	Err    error
68	Reveal *webhookSecret
69	Notice string
70	Gen    int
71}
72
73type WebhooksPageDoneMsg struct{}
74type ChatOpenWebhooksPageMsg struct{}
75
76const (
77	defaultSignatureHeader = "x-albedo-signature"
78	defaultSignaturePrefix = "sha256="
79)
80
81func NewWebhooksPageModel(conn *daemon.Connection, sessionID string) WebhooksPageModel {
82	return WebhooksPageModel{
83		Conn:       conn,
84		SessionID:  sessionID,
85		Mounted:    true,
86		pageStatus: newPageStatus(true),
87		listView:   newListView("Search webhooks"),
88	}
89}
90
91func (m *WebhooksPageModel) SetSize(width, height int) {
92	m.pageStatus.SetSize(width, height)
93	m.listView.setSize(width, height)
94}
95
96// Confirming reports a question waiting for enter or esc.
97func (m WebhooksPageModel) Confirming() bool { return m.confirm.asking() }
98
99func (m WebhooksPageModel) Init() tea.Cmd { return m.loadCmd(m.Generation) }
100
101func (m WebhooksPageModel) loadCmd(gen int) tea.Cmd {
102	return func() tea.Msg {
103		if m.Conn == nil {
104			return webhooksLoadedMsg{Gen: gen, Err: errors.New("daemon connection unavailable")}
105		}
106		mounted := true
107		extensions, err := daemon.ListExtensions(context.Background(), m.Conn, m.SessionID)
108		if err != nil {
109			return webhooksLoadedMsg{Gen: gen, Err: err}
110		}
111		if i := slices.IndexFunc(extensions, func(ext ExtensionItem) bool { return ext.Name == "webhooks" }); i >= 0 {
112			mounted = extensions[i].GlobalEnabled
113			if !extensions[i].Enabled {
114				return webhooksLoadedMsg{Gen: gen, Err: errors.New("webhooks are off for this session; turn them on in /extensions")}
115			}
116		}
117		all, err := daemon.ListAllSessions(context.Background(), m.Conn)
118		if err != nil {
119			return webhooksLoadedMsg{Gen: gen, Err: err}
120		}
121		var sessions []daemon.Session
122		for _, s := range all {
123			if s.ID == m.SessionID {
124				sessions = slices.Insert(sessions, 0, s)
125			} else {
126				sessions = append(sessions, s)
127			}
128		}
129		rows, err := daemon.ListWebhooks(context.Background(), m.Conn, "")
130		if err != nil {
131			return webhooksLoadedMsg{Gen: gen, Err: err}
132		}
133		permission, err := daemon.GetWebhookPermission(context.Background(), m.Conn, m.SessionID)
134		if err != nil {
135			return webhooksLoadedMsg{Gen: gen, Err: err}
136		}
137		agent := permission.AgentManagement
138		hooks := make([]webhookEntry, len(rows))
139		for i, entry := range rows {
140			hook := entry.Hook
141			deferred := ""
142			if entry.Deferred != nil {
143				deferred = *entry.Deferred
144			}
145			hooks[i] = webhookEntry{
146				ETag: hook.ETag, ID: hook.ID, Session: hook.Session, Name: hook.Name, URL: hook.URL, Address: hook.Address,
147				Header: hook.Header, Prefix: hook.Prefix, Enabled: hook.Enabled,
148				Queued: entry.Queued, Deferred: deferred,
149			}
150		}
151		// Group by session in the order they are offered, current first.
152		rank := func(id string) int {
153			if i := slices.IndexFunc(sessions, func(s daemon.Session) bool { return s.ID == id }); i >= 0 {
154				return i
155			}
156			return len(sessions)
157		}
158		slices.SortStableFunc(hooks, func(a, b webhookEntry) int { return cmp.Compare(rank(a.Session), rank(b.Session)) })
159		return webhooksLoadedMsg{Sessions: sessions, Hooks: hooks, Agent: agent, Mounted: mounted, PermissionETag: permission.ETag, Gen: gen}
160	}
161}
162
163// save runs steps in order and stops at the first failure; a generated secret
164// from any step is carried back to be shown once.
165func (m WebhooksPageModel) save(gen int, notice string, steps ...daemon.WebhookRequest) tea.Cmd {
166	return func() tea.Msg {
167		var reveal *webhookSecret
168		created, etag := "", ""
169		for _, step := range steps {
170			if step.HookID == "" {
171				step.HookID = created
172			}
173			if etag != "" && step.HookID == created {
174				step.ETag = etag
175			}
176			var res *daemon.WebhookResult
177			var err error
178			switch step.Action {
179			case daemon.WebhookCreate:
180				res, err = daemon.CreateWebhook(context.Background(), m.Conn, daemon.WebhookCreateRequest{SessionID: step.SessionID, Name: step.Name, Secret: optionalWebhookText(step.Secret), SignatureHeader: optionalWebhookText(step.Header), SignaturePrefix: &step.Prefix})
181			case daemon.WebhookSignature:
182				res, err = daemon.EditWebhook(context.Background(), m.Conn, step.HookID, step.ETag, daemon.WebhookPatch{SignatureHeader: &step.Header, SignaturePrefix: &step.Prefix})
183			case daemon.WebhookRotate:
184				res, err = daemon.RotateWebhookSecret(context.Background(), m.Conn, step.HookID, step.ETag, step.Secret)
185			case daemon.WebhookEnable, daemon.WebhookDisable:
186				enabled := step.Action == daemon.WebhookEnable
187				res, err = daemon.EditWebhook(context.Background(), m.Conn, step.HookID, step.ETag, daemon.WebhookPatch{Enabled: &enabled})
188			case daemon.WebhookDelete:
189				res, err = daemon.DeleteWebhook(context.Background(), m.Conn, step.HookID, step.ETag)
190			case daemon.WebhookAgentEnable, daemon.WebhookAgentDisable:
191				res, err = daemon.SetWebhookPermission(context.Background(), m.Conn, m.SessionID, step.ETag, step.Action == daemon.WebhookAgentEnable)
192			default:
193				err = errors.New("unknown webhook form action")
194			}
195			if err != nil {
196				// The hook exists with this secret even though a later step
197				// failed, so it still has to be shown.
198				return webhooksSavedMsg{Reveal: reveal, Gen: gen, Err: err}
199			}
200			if res.Message != "" {
201				notice = res.Message
202			}
203			if res.Hook != nil {
204				created, etag = res.Hook.ID, res.Hook.ETag
205				if res.Secret != "" {
206					reveal = &webhookSecret{Hook: res.Hook.Name, Session: res.Hook.Session, Secret: res.Secret}
207				}
208			}
209		}
210		return webhooksSavedMsg{Notice: notice, Reveal: reveal, Gen: gen}
211	}
212}
213
214// sessionLabel names a session plainly enough to tell it apart from the
215// others. Whether it is the session this screen was opened from comes first,
216// and the title is capped, so a long title never hides the rest.
217func sessionLabel(sessions []daemon.Session, id, current string) string {
218	parts := []string{"session " + id[:min(8, len(id))]}
219	if i := slices.IndexFunc(sessions, func(s daemon.Session) bool { return s.ID == id }); i >= 0 {
220		s := sessions[i]
221		parts = []string{ansi.Truncate(sessionTitle(s), 32, "…")}
222		if s.Workspace != "" {
223			parts = append(parts, homePath(s.Workspace))
224		}
225	}
226	if id == current {
227		parts = append([]string{"this session"}, parts...)
228	}
229	return strings.Join(parts, " · ")
230}
231
232// setHooks lists the hooks under the sessions they wake, keeping the cursor
233// on the hook it was on.
234func (m *WebhooksPageModel) setHooks(hooks []webhookEntry) {
235	m.Hooks = hooks
236	rows := make([]listEntry, len(hooks))
237	for i, hook := range hooks {
238		rows[i] = m.entry(hook)
239	}
240	m.setRows(rows)
241}
242
243// hook is the listed hook with this ID.
244func (m WebhooksPageModel) hook(id string) (webhookEntry, bool) {
245	i := slices.IndexFunc(m.Hooks, func(h webhookEntry) bool { return h.ID == id })
246	if i < 0 {
247		return webhookEntry{}, false
248	}
249	return m.Hooks[i], true
250}
251
252// current is the hook under the cursor.
253func (m WebhooksPageModel) current() (webhookEntry, bool) {
254	row, ok := m.highlighted()
255	if !ok {
256		return webhookEntry{}, false
257	}
258	return m.hook(row.key)
259}
260
261func (m WebhooksPageModel) begin(notice string, steps ...daemon.WebhookRequest) (WebhooksPageModel, tea.Cmd) {
262	for i := range steps {
263		if steps[i].Action == daemon.WebhookAgentEnable || steps[i].Action == daemon.WebhookAgentDisable {
264			steps[i].ETag = m.PermissionETag
265		} else if hook, ok := m.hook(steps[i].HookID); ok {
266			steps[i].ETag = hook.ETag
267		}
268	}
269	m.Error, m.Notice = "", ""
270	m.Saving = true
271	m.Generation = nextPageGeneration()
272	return m, m.save(m.Generation, notice, steps...)
273}
274
275func (m WebhooksPageModel) Update(msg tea.Msg) (WebhooksPageModel, tea.Cmd) {
276	switch msg := msg.(type) {
277	case webhooksLoadedMsg:
278		if !m.settle(msg.Gen, msg.Err, &m.Loading) {
279			return m, nil
280		}
281		m.Loaded = true
282		m.PermissionETag = msg.PermissionETag
283		m.Sessions, m.AgentManagement, m.Mounted = msg.Sessions, msg.Agent, msg.Mounted
284		m.setHooks(msg.Hooks)
285		return m, nil
286	case webhooksSavedMsg:
287		if msg.Gen != m.Generation {
288			return m, nil
289		}
290		m.Saving, m.Reveal = false, msg.Reveal
291		if msg.Err != nil {
292			m.Error = msg.Err.Error()
293			if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](msg.Err); uncertain {
294				return m, nil
295			}
296			if msg.Reveal == nil {
297				return m, nil
298			}
299		} else {
300			m.Form, m.Notice = nil, msg.Notice
301		}
302		m.Loading, m.Generation = true, nextPageGeneration()
303		return m, m.loadCmd(m.Generation)
304	case tea.KeyPressMsg:
305		return m.handleKey(msg)
306	case tea.PasteMsg:
307		if m.Saving || m.Reveal != nil || m.Form == nil {
308			return m, nil
309		}
310		_, cmd := m.Form.update(msg)
311		return m, cmd
312	}
313	return m, nil
314}
315
316// handleKey routes a key to the open part of the screen: the secret, the
317// form, the question, or the list.
318func (m WebhooksPageModel) handleKey(msg tea.KeyPressMsg) (WebhooksPageModel, tea.Cmd) {
319	key := msg.String()
320	switch {
321	case m.Reveal != nil:
322		return m.revealKey(key)
323	case m.Form != nil:
324		return m.formKey(msg)
325	case m.confirm.asking():
326		return m.confirmKey(msg)
327	case key == "esc" || key == "ctrl+c":
328		return m, func() tea.Msg { return WebhooksPageDoneMsg{} }
329	case m.Saving || m.Loading:
330		return m, nil
331	}
332	return m.browseKey(msg)
333}
334
335func (m WebhooksPageModel) revealKey(key string) (WebhooksPageModel, tea.Cmd) {
336	switch key {
337	case "ctrl+l":
338		m.Error, m.Notice = "", "Secret copied."
339		return m, CopyText(m.Reveal.Secret)
340	case "enter", "esc":
341		m.Reveal = nil
342	}
343	return m, nil
344}
345
346func (m WebhooksPageModel) formKey(msg tea.KeyPressMsg) (WebhooksPageModel, tea.Cmd) {
347	if msg.String() == "esc" {
348		m.Form, m.Error = nil, ""
349		return m, nil
350	}
351	if m.Saving {
352		return m, nil
353	}
354	submit, cmd := m.Form.update(msg)
355	if !submit {
356		return m, cmd
357	}
358	steps, notice, err := m.Form.steps()
359	if err != nil {
360		m.Error = err.Error()
361		return m, nil
362	}
363	if len(steps) == 0 {
364		m.Form = nil
365		m.Error, m.Notice = "", "No changes made."
366		return m, nil
367	}
368	return m.begin(notice, steps...)
369}
370
371// confirmKey runs the change a question asked about on enter; esc cancels and
372// every other key is ignored.
373func (m WebhooksPageModel) confirmKey(msg tea.KeyPressMsg) (WebhooksPageModel, tea.Cmd) {
374	if !m.confirm.key(msg) {
375		return m, nil
376	}
377	what, id := m.confirm.what, m.confirm.target
378	m.confirm.dismiss()
379	hook, ok := m.hook(id)
380	if !ok {
381		return m, nil
382	}
383	action := daemon.WebhookAction(what)
384	note := "New secret for " + hook.Name
385	switch action {
386	case daemon.WebhookEnable:
387		note = hook.Name + " enabled."
388	case daemon.WebhookDisable:
389		note = hook.Name + " disabled. New deliveries will receive a 404 response."
390	case daemon.WebhookDelete:
391		note = "Deleted " + hook.Name
392	}
393	return m.begin(note, daemon.WebhookRequest{Action: action, HookID: hook.ID})
394}
395
396func (m WebhooksPageModel) browseKey(msg tea.KeyPressMsg) (WebhooksPageModel, tea.Cmd) {
397	key := msg.String()
398	if key == "ctrl+r" {
399		m.Error, m.Notice = "", ""
400		m.Loading = true
401		m.Generation = nextPageGeneration()
402		return m, m.loadCmd(m.Generation)
403	}
404	if !m.Loaded {
405		return m, nil
406	}
407	hook, selected := m.current()
408	switch key {
409	case "enter":
410		if selected {
411			return m.toggleHook(hook)
412		}
413	case "ctrl+o":
414		m.Error, m.Notice = "", ""
415		m.Form = newWebhookForm(nil, m.Sessions, m.SessionID)
416	case "ctrl+e":
417		if selected {
418			m.Error, m.Notice = "", ""
419			m.Form = newWebhookForm(&hook, m.Sessions, m.SessionID)
420		}
421	case "ctrl+d":
422		if selected {
423			m.askHook(daemon.WebhookDelete, hook, "delete", "Its URL will stop working; accepted deliveries stay in the inbox. Delete "+hook.Name+"?")
424		}
425	case "ctrl+g":
426		if selected {
427			m.askHook(daemon.WebhookRotate, hook, "new secret", "The sender must use the new secret. Replace the secret for "+hook.Name+"?")
428		}
429	case "ctrl+t":
430		return m.toggleAgent()
431	case "ctrl+l":
432		if selected {
433			m.Error, m.Notice = "", "URL copied."
434			return m, CopyText(cmp.Or(hook.Address, hook.URL))
435		}
436	default:
437		cmd := m.listView.update(msg)
438		return m, cmd
439	}
440	return m, nil
441}
442
443// toggleHook switches a hook on or off now; an off hook answers new deliveries
444// with a 404, so the notice says so.
445func (m WebhooksPageModel) toggleHook(hook webhookEntry) (WebhooksPageModel, tea.Cmd) {
446	action, note := daemon.WebhookEnable, hook.Name+" enabled."
447	if hook.Enabled {
448		action, note = daemon.WebhookDisable, hook.Name+" disabled. New deliveries will receive a 404 response."
449	}
450	return m.begin(note, daemon.WebhookRequest{Action: action, HookID: hook.ID})
451}
452
453func (m WebhooksPageModel) toggleAgent() (WebhooksPageModel, tea.Cmd) {
454	action, note := daemon.WebhookAgentEnable, "This session’s agent can now manage its own webhooks."
455	if m.AgentManagement {
456		action, note = daemon.WebhookAgentDisable, "This session’s agent can no longer manage its webhooks."
457	}
458	return m.begin(note, daemon.WebhookRequest{Action: action})
459}
460
461func (m *WebhooksPageModel) askHook(action daemon.WebhookAction, hook webhookEntry, verb, prompt string) {
462	m.Error = ""
463	m.confirm.ask(string(action), hook.ID, verb, prompt)
464}
465
466func optionalWebhookText(text string) *string {
467	if text == "" {
468		return nil
469	}
470	return &text
471}