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