commit 8a399a9

delthas  ·  2025-11-07 12:06:29 +0000 UTC
parent fa9130a
Support opening irc:// links in senpai
13 files changed,  +405, -33
M app.go
M go.mod
M go.sum
M app.go
+159, -8
  1@@ -28,6 +28,7 @@ import (
  2 	"unicode"
  3 	"unicode/utf8"
  4 
  5+	"git.sr.ht/~delthas/senpai/varlinkservice"
  6 	"git.sr.ht/~rockorager/vaxis"
  7 	"golang.org/x/net/proxy"
  8 
  9@@ -97,6 +98,10 @@ type event struct {
 10 	content interface{}
 11 }
 12 
 13+type linkEvent struct {
 14+	link string
 15+}
 16+
 17 type boundKey struct {
 18 	netID  string
 19 	target string
 20@@ -134,8 +139,8 @@ type App struct {
 21 
 22 	monitor map[string]map[string]struct{} // set of targets we want to monitor per netID, best-effort. netID->target->{}
 23 
 24-	networkLock sync.RWMutex        // locks networks
 25-	networks    map[string]struct{} // set of network IDs we want to connect to; to be locked with networkLock
 26+	networkLock sync.RWMutex                 // locks networks
 27+	networks    map[string]map[string]string // set of network IDs to attributes we want to connect to; to be locked with networkLock
 28 
 29 	pendingCompletions    map[string][]pendingCompletion
 30 	pendingCompletionsOff int
 31@@ -170,7 +175,7 @@ func NewApp(cfg Config) (app *App, err error) {
 32 	}
 33 
 34 	app = &App{
 35-		networks: map[string]struct{}{
 36+		networks: map[string]map[string]string{
 37 			"": {}, // add the master network by default
 38 		},
 39 		pendingCompletions: make(map[string][]pendingCompletion),
 40@@ -385,6 +390,41 @@ func (app *App) wantsNetwork(netID string) bool {
 41 	return ok
 42 }
 43 
 44+func (app *App) findNetworkByLink(link string) (host string, target string, netID string) {
 45+	// links often do not contain encoded #
 46+	link = strings.ReplaceAll(link, "#", "%23")
 47+
 48+	u, err := url.Parse(link)
 49+	if err != nil {
 50+		return "", "", ""
 51+	}
 52+	host, port, err := net.SplitHostPort(u.Host)
 53+	if err != nil {
 54+		host = u.Host
 55+		port = "6697"
 56+	}
 57+	target = strings.TrimLeft(u.Path, "/")
 58+	if i := strings.IndexAny(target, "/,"); i >= 0 {
 59+		target = target[:i]
 60+	}
 61+	app.networkLock.Lock()
 62+	defer app.networkLock.Unlock()
 63+	for netID, attrs := range app.networks {
 64+		if attrs["host"] != host {
 65+			continue
 66+		}
 67+		p := attrs["port"]
 68+		if p == "" {
 69+			p = "6697"
 70+		}
 71+		if p != port {
 72+			continue
 73+		}
 74+		return u.Host, target, netID
 75+	}
 76+	return u.Host, target, ""
 77+}
 78+
 79 // ircLoop maintains a connection to the IRC server by connecting and then
 80 // forwarding IRC events to app.events repeatedly.
 81 func (app *App) ircLoop(netID string) {
 82@@ -645,6 +685,38 @@ func (app *App) handleUIEvent(ev interface{}) bool {
 83 		} else {
 84 			app.uploadingProgress = &ev.Progress
 85 		}
 86+	case *events.EventOpenLink:
 87+		host, target, netID := app.findNetworkByLink(ev.Link)
 88+		if netID != "" {
 89+			if cNetID, _ := app.win.CurrentBuffer(); cNetID != netID {
 90+				app.win.JumpBufferNetwork(netID, "")
 91+			}
 92+			if target == "" {
 93+				break
 94+			}
 95+			if !app.win.JumpBufferNetwork(netID, target) {
 96+				if s := app.sessions[netID]; s != nil && s.IsChannel(target) {
 97+					cNetID, cTarget := app.win.CurrentBuffer()
 98+					app.win.AddLine(cNetID, cTarget, ui.Line{
 99+						At:   time.Now(),
100+						Head: "--",
101+						Body: ui.PlainString("An IRC link of a new channel was opened. Enter to add and join that channel."),
102+					})
103+					app.win.InputSet(fmt.Sprintf("/join %v", target))
104+				} else {
105+					i, _ := app.addUserBuffer(netID, target, time.Time{})
106+					app.win.JumpBufferIndex(i)
107+				}
108+			}
109+		} else {
110+			netID, target := app.win.CurrentBuffer()
111+			app.win.AddLine(netID, target, ui.Line{
112+				At:   time.Now(),
113+				Head: "--",
114+				Body: ui.PlainString("An IRC link of a new network was opened. Enter to add and join that network."),
115+			})
116+			app.win.InputSet(fmt.Sprintf("/bouncer network create -addr %q", host))
117+		}
118 	default:
119 		// TODO: missing event types
120 	}
121@@ -1456,6 +1528,37 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
122 			// TODO: batch MONITOR +
123 			s.MonitorAdd(target)
124 		}
125+
126+		if netID == "" || app.cfg.OpenLink == "" {
127+			break
128+		}
129+		_, target, foundNetID := app.findNetworkByLink(app.cfg.OpenLink)
130+		if netID != foundNetID {
131+			break
132+		}
133+		app.cfg.OpenLink = ""
134+		app.lastNetID = ""
135+		app.lastBuffer = ""
136+		if cNetID, _ := app.win.CurrentBuffer(); cNetID != netID {
137+			app.win.JumpBufferNetwork(netID, "")
138+		}
139+		if target == "" {
140+			break
141+		}
142+		if !app.win.JumpBufferNetwork(netID, target) {
143+			if s.IsChannel(target) {
144+				cNetID, cTarget := app.win.CurrentBuffer()
145+				app.win.AddLine(cNetID, cTarget, ui.Line{
146+					At:   time.Now(),
147+					Head: "--",
148+					Body: ui.PlainString("An IRC link of a new channel was opened. Enter to add and join that channel."),
149+				})
150+				app.win.InputSet(fmt.Sprintf("/join %v", target))
151+			} else {
152+				i, _ := app.addUserBuffer(netID, target, time.Time{})
153+				app.win.JumpBufferIndex(i)
154+			}
155+		}
156 	case irc.SelfNickEvent:
157 		if !app.cfg.StatusEnabled {
158 			break
159@@ -1707,11 +1810,17 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
160 		}
161 	case irc.BouncerNetworkEvent:
162 		if !ev.Delete {
163-			_, added := app.win.AddBuffer(ev.ID, ev.Name, "")
164+			app.networkLock.Lock()
165+			if _, ok := app.networks[ev.ID]; !ok {
166+				app.networks[ev.ID] = ev.Attrs
167+			} else {
168+				for k, v := range ev.Attrs {
169+					app.networks[ev.ID][k] = v
170+				}
171+			}
172+			app.networkLock.Unlock()
173+			_, added := app.win.AddBuffer(ev.ID, ev.Attrs["name"], "")
174 			if added {
175-				app.networkLock.Lock()
176-				app.networks[ev.ID] = struct{}{}
177-				app.networkLock.Unlock()
178 				go app.ircLoop(ev.ID)
179 			}
180 		} else {
181@@ -1727,6 +1836,39 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
182 			}
183 			app.win.RemoveNetworkBuffers(ev.ID)
184 		}
185+	case irc.BouncerNetworkListEvent:
186+		for _, ev := range ev {
187+			app.networkLock.Lock()
188+			if _, ok := app.networks[ev.ID]; !ok {
189+				app.networks[ev.ID] = ev.Attrs
190+			} else {
191+				for k, v := range ev.Attrs {
192+					app.networks[ev.ID][k] = v
193+				}
194+			}
195+			app.networkLock.Unlock()
196+			_, added := app.win.AddBuffer(ev.ID, ev.Attrs["name"], "")
197+			if added {
198+				go app.ircLoop(ev.ID)
199+			}
200+		}
201+
202+		if app.cfg.OpenLink == "" {
203+			break
204+		}
205+		host, _, foundNetID := app.findNetworkByLink(app.cfg.OpenLink)
206+		if foundNetID != "" {
207+			// We will handle the link in the session for that network.
208+			break
209+		}
210+		app.cfg.OpenLink = ""
211+		netID, target := app.win.CurrentBuffer()
212+		app.win.AddLine(netID, target, ui.Line{
213+			At:   time.Now(),
214+			Head: "--",
215+			Body: ui.PlainString("An IRC link of a new network was opened. Enter to add and join that network."),
216+		})
217+		app.win.InputSet(fmt.Sprintf("/bouncer network create -addr %q", host))
218 	case irc.ListEvent:
219 		for _, item := range ev {
220 			text := fmt.Sprintf("There are %4s users on channel %s", item.Count, item.Channel)
221@@ -1757,7 +1899,6 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
222 				Foreground: app.cfg.Colors.Status,
223 			}),
224 		})
225-		return
226 	case irc.ErrorEvent:
227 		var head string
228 		var body string
229@@ -2444,6 +2585,16 @@ func (app *App) addUserBuffer(netID, buffer string, t time.Time) (i int, added b
230 	return
231 }
232 
233+func (app *App) OpenLink(in *varlinkservice.OpenLinkIn) (*varlinkservice.OpenLinkOut, error) {
234+	app.postEvent(event{
235+		src: "*",
236+		content: &events.EventOpenLink{
237+			Link: in.Link,
238+		},
239+	})
240+	return nil, nil
241+}
242+
243 func keyNameMatch(name string) *keyMatch {
244 	parts := strings.Split(name, "+")
245 	mods := parts[:len(parts)-1]
+83, -0
  1@@ -6,16 +6,21 @@ import (
  2 	"fmt"
  3 	"io"
  4 	"math/rand"
  5+	"net"
  6+	"net/url"
  7 	"os"
  8 	"os/signal"
  9 	"os/user"
 10 	"path"
 11+	"path/filepath"
 12 	"runtime"
 13 	"strings"
 14 	"syscall"
 15 	"time"
 16 
 17 	"git.sr.ht/~delthas/senpai"
 18+	"git.sr.ht/~delthas/senpai/varlinkservice"
 19+	"github.com/emersion/go-varlink"
 20 )
 21 
 22 func main() {
 23@@ -40,6 +45,26 @@ func main() {
 24 		return
 25 	}
 26 
 27+	socketDir := os.Getenv("XDG_RUNTIME_DIR")
 28+	if socketDir == "" {
 29+		socketDir = os.TempDir()
 30+	}
 31+	socketDir = filepath.Join(socketDir, "senpai")
 32+
 33+	var link string
 34+	if _, err := url.Parse(flag.Arg(0)); err == nil {
 35+		link = flag.Arg(0)
 36+	}
 37+	if link != "" {
 38+		ok, err := sendOpenLink(socketDir, link)
 39+		if err != nil {
 40+			fmt.Fprintf(os.Stderr, "failed to send open link to another instance: %v\n", err)
 41+		} else if ok {
 42+			// Sent link open command to another instance, exit.
 43+			return
 44+		}
 45+	}
 46+
 47 	var previousConfigPath string
 48 	if configPath == "" {
 49 		configDir, err := os.UserConfigDir()
 50@@ -171,6 +196,7 @@ func main() {
 51 		fmt.Fprintf(os.Stderr, "\n")
 52 	}
 53 
 54+	cfg.OpenLink = link
 55 	cfg.Debug = cfg.Debug || debug
 56 	if nickname != "" {
 57 		cfg.Nick = nickname
 58@@ -197,6 +223,14 @@ func main() {
 59 		app.Close()
 60 	}()
 61 
 62+	if cfg.LocalIntegrations {
 63+		socketPath := filepath.Join(socketDir, fmt.Sprintf("%v.sock", os.Getpid()))
 64+		if err := listenVarlink(socketPath, app); err != nil {
 65+			fmt.Fprintf(os.Stderr, "failed to send open link to another instance: %v\n", err)
 66+		}
 67+		defer os.Remove(socketPath)
 68+	}
 69+
 70 	app.Run()
 71 	app.Close()
 72 	if !cfg.Transient {
 73@@ -309,3 +343,52 @@ func writeLastStamp(app *senpai.App) {
 74 		fmt.Fprintf(os.Stderr, "failed to write last stamp at %q: %s\n", lastStampPath, err)
 75 	}
 76 }
 77+
 78+func sendOpenLink(socketDir string, link string) (ok bool, err error) {
 79+	es, err := os.ReadDir(socketDir)
 80+	if os.IsNotExist(err) {
 81+		return false, nil
 82+	}
 83+	if err != nil {
 84+		return false, err
 85+	}
 86+	for _, e := range es {
 87+		if filepath.Ext(e.Name()) != ".sock" {
 88+			continue
 89+		}
 90+		conn, err := net.Dial("unix", filepath.Join(socketDir, e.Name()))
 91+		if err != nil {
 92+			return false, err
 93+		}
 94+		c := varlinkservice.Client{Client: varlink.NewClient(conn)}
 95+		defer c.Close()
 96+		if _, err := c.OpenLink(&varlinkservice.OpenLinkIn{Link: link}); err != nil {
 97+			return false, err
 98+		}
 99+		return true, nil
100+	}
101+	return false, nil
102+}
103+
104+func listenVarlink(socketPath string, backend varlinkservice.Backend) error {
105+	if err := os.MkdirAll(filepath.Dir(socketPath), 0700); err != nil {
106+		return err
107+	}
108+	if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
109+		return err
110+	}
111+	l, err := net.Listen("unix", socketPath)
112+	if err != nil {
113+		return err
114+	}
115+	server := varlink.NewServer()
116+	server.Handler = varlinkservice.Handler{
117+		Backend: backend,
118+	}
119+	go func() {
120+		if err := server.Serve(l); err != nil {
121+			fmt.Fprintf(os.Stderr, "failed to serve varlink server: %v\n", err)
122+		}
123+	}()
124+	return nil
125+}
+1, -0
1@@ -115,6 +115,7 @@ type Config struct {
2 
3 	Colors ui.ConfigColors
4 
5+	OpenLink          string
6 	Debug             bool
7 	Transient         bool
8 	LocalIntegrations bool
+2, -1
1@@ -8,6 +8,7 @@ Keywords=Chat;IRC;IM;Messaging
2 Categories=Network;InstantMessaging;Chat;IRCClient;ConsoleOnly
3 
4 Type=Application
5+MimeType=x-scheme-handler/irc;x-scheme-handler/ircs
6 Icon=senpai
7 Terminal=true
8-Exec=senpai
9+Exec=senpai %u
+4, -1
 1@@ -6,7 +6,7 @@ senpai - your everyday IRC student
 2 
 3 # SYNOPSIS
 4 
 5-*senpai* [options...]
 6+*senpai* [options...] [link]
 7 
 8 # OPTIONS
 9 
10@@ -24,6 +24,9 @@ senpai - your everyday IRC student
11 *-version*
12 	Display version info.
13 
14+senpai can also be opened with an _irc://_ link to a server or target. In that
15+case, the link is opened in an existing instance if available.
16+
17 # DESCRIPTION
18 
19 senpai is an IRC client made for bouncers.  It supports the newest IRC
+4, -0
1@@ -45,3 +45,7 @@ type EventFileUpload struct {
2 	Location string
3 	Error    string
4 }
5+
6+type EventOpenLink struct {
7+	Link string
8+}
M go.mod
+3, -1
 1@@ -9,17 +9,19 @@ require (
 2 	github.com/delthas/go-libnp v0.1.0
 3 	github.com/delthas/go-localeinfo v0.1.0
 4 	github.com/disintegration/imaging v1.6.2
 5+	github.com/emersion/go-varlink v0.0.0-20251006073247-e7ad9f7950c7
 6 	github.com/godbus/dbus/v5 v5.1.0
 7 	github.com/rivo/uniseg v0.4.7
 8+	golang.org/x/image v0.24.0
 9 	golang.org/x/net v0.35.0
10 	golang.org/x/time v0.10.0
11 	mvdan.cc/xurls/v2 v2.5.0
12 )
13 
14 require (
15+	github.com/dave/jennifer v1.7.1 // indirect
16 	github.com/mattn/go-runewidth v0.0.16 // indirect
17 	github.com/mattn/go-sixel v0.0.5 // indirect
18 	github.com/soniakeys/quant v1.0.0 // indirect
19-	golang.org/x/image v0.24.0 // indirect
20 	golang.org/x/sys v0.30.0 // indirect
21 )
M go.sum
+4, -0
 1@@ -4,6 +4,8 @@ git.sr.ht/~rockorager/vaxis v0.13.0 h1:+F3ze1t4X5x87QEsyn/b9b9pWXw9MDqoHynoR/PHq
 2 git.sr.ht/~rockorager/vaxis v0.13.0/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns=
 3 github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
 4 github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 5+github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
 6+github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
 7 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 8 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 9 github.com/delthas/go-libnp v0.1.0 h1:4YmxYd6yBI1gDzSeqCS7+0wrHcYPPo54FrTaFIbEd4U=
10@@ -12,6 +14,8 @@ github.com/delthas/go-localeinfo v0.1.0 h1:2tN27F8QcWVVn218n6raAYa5sL7gDLVGEw5x3
11 github.com/delthas/go-localeinfo v0.1.0/go.mod h1:sG54BxlyQgIskYURLrg7mvhoGBe0Qq12DNtYRALwNa4=
12 github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
13 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
14+github.com/emersion/go-varlink v0.0.0-20251006073247-e7ad9f7950c7 h1:GtfymsmxgcEdOuYLm5CR6VS5pTEXfUKkQDp/wRvvyA8=
15+github.com/emersion/go-varlink v0.0.0-20251006073247-e7ad9f7950c7/go.mod h1:0/V0Ta8VUzKRLXUtyZS49soMI93Taqlm63wX5nx6YEo=
16 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
17 github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
18 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+3, -1
 1@@ -126,6 +126,8 @@ type MetadataChangeEvent struct {
 2 
 3 type BouncerNetworkEvent struct {
 4 	ID     string
 5-	Name   string
 6+	Attrs  map[string]string
 7 	Delete bool
 8 }
 9+
10+type BouncerNetworkListEvent []BouncerNetworkEvent
+38, -21
  1@@ -129,14 +129,15 @@ type Session struct {
  2 	typings      *Typings               // incoming typing notifications.
  3 	typingStamps map[string]typingStamp // user typing instants.
  4 
  5-	nick   string
  6-	nickCf string // casemapped nickname.
  7-	user   string
  8-	real   string
  9-	acct   string
 10-	host   string
 11-	netID  string
 12-	auth   SASLClient
 13+	nick     string
 14+	nickCf   string // casemapped nickname.
 15+	user     string
 16+	real     string
 17+	acct     string
 18+	host     string
 19+	netID    string
 20+	netAttrs map[string]string
 21+	auth     SASLClient
 22 
 23 	availableCaps map[string]string
 24 	enabledCaps   map[string]struct{}
 25@@ -157,17 +158,19 @@ type Session struct {
 26 	listMask      bool
 27 	upload        string
 28 
 29-	users          map[string]*User        // known users.
 30-	channels       map[string]Channel      // joined channels.
 31-	metadata       map[string]Metadata     // known target metadata.
 32-	chBatches      map[string]HistoryEvent // channel history batches being processed.
 33-	chReqs         map[string]struct{}     // set of targets for which history is currently requested.
 34-	targetsBatchID string                  // ID of the channel history targets batch being processed.
 35-	targetsBatch   HistoryTargetsEvent     // channel history targets batch being processed.
 36-	searchBatchID  string                  // ID of the search targets batch being processed.
 37-	searchBatch    SearchEvent             // search batch being processed.
 38-	monitors       map[string]struct{}     // set of users we want to monitor (and keep even if they are disconnected).
 39-	pendingList    ListEvent               // current list response being received (flushed on list end).
 40+	users                  map[string]*User        // known users.
 41+	channels               map[string]Channel      // joined channels.
 42+	metadata               map[string]Metadata     // known target metadata.
 43+	chBatches              map[string]HistoryEvent // channel history batches being processed.
 44+	chReqs                 map[string]struct{}     // set of targets for which history is currently requested.
 45+	targetsBatchID         string                  // ID of the channel history targets batch being processed.
 46+	targetsBatch           HistoryTargetsEvent     // channel history targets batch being processed.
 47+	searchBatchID          string                  // ID of the search targets batch being processed.
 48+	searchBatch            SearchEvent             // search batch being processed.
 49+	bouncerNetworksBatchID string                  // ID of the bouncer network batch being processed.
 50+	bouncerNetworksBatch   BouncerNetworkListEvent // bouncer network batch being processed.
 51+	monitors               map[string]struct{}     // set of users we want to monitor (and keep even if they are disconnected).
 52+	pendingList            ListEvent               // current list response being received (flushed on list end).
 53 
 54 	pendingChannels map[string]time.Time // set of join requests stamps for channels.
 55 
 56@@ -783,6 +786,15 @@ func (s *Session) handleRegistered(msg Message) (Event, error) {
 57 				s.searchBatch.Messages = append(s.searchBatch.Messages, ev)
 58 				return nil, nil
 59 			}
 60+		} else if id == s.bouncerNetworksBatchID {
 61+			ev, err := s.handleMessageRegistered(msg, true)
 62+			if err != nil {
 63+				return nil, err
 64+			}
 65+			if ev, ok := ev.(BouncerNetworkEvent); ok {
 66+				s.bouncerNetworksBatch = append(s.bouncerNetworksBatch, ev)
 67+				return nil, nil
 68+			}
 69 		} else if b, ok := s.chBatches[id]; ok {
 70 			ev, err := s.handleMessageRegistered(msg, true)
 71 			if err != nil {
 72@@ -1444,6 +1456,9 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
 73 			case "soju.im/search":
 74 				s.searchBatchID = id
 75 				s.searchBatch = SearchEvent{}
 76+			case "soju.im/bouncer-networks":
 77+				s.bouncerNetworksBatchID = id
 78+				s.bouncerNetworksBatch = BouncerNetworkListEvent{}
 79 			}
 80 		} else {
 81 			if b, ok := s.chBatches[id]; ok {
 82@@ -1457,6 +1472,9 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
 83 			} else if s.searchBatchID == id {
 84 				s.searchBatchID = ""
 85 				return s.searchBatch, nil
 86+			} else if s.bouncerNetworksBatchID == id {
 87+				s.bouncerNetworksBatchID = ""
 88+				return s.bouncerNetworksBatch, nil
 89 			}
 90 		}
 91 	case "NICK":
 92@@ -1564,8 +1582,7 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
 93 			ID: id,
 94 		}
 95 		if msg.Params[2] != "*" {
 96-			attrs := parseTags(msg.Params[2])
 97-			event.Name = attrs["name"]
 98+			event.Attrs = parseTags(msg.Params[2])
 99 		} else {
100 			event.Delete = true
101 		}
+92, -0
 1@@ -0,0 +1,92 @@
 2+// Code generated by go-varlink/varlinkgen. DO NOT EDIT.
 3+
 4+package varlinkservice
 5+
 6+import (
 7+	"encoding/json"
 8+
 9+	govarlink "github.com/emersion/go-varlink"
10+)
11+
12+type OpenLinkIn struct {
13+	Link string `json:"link"`
14+}
15+type OpenLinkOut struct{}
16+
17+type Client struct {
18+	*govarlink.Client
19+}
20+
21+func unmarshalError(err error) error {
22+	verr, ok := err.(*govarlink.ClientError)
23+	if !ok {
24+		return err
25+	}
26+	var v error
27+	switch verr.Name {
28+	default:
29+		return err
30+	}
31+	if err := json.Unmarshal(verr.Parameters, v); err != nil {
32+		return err
33+	}
34+	return v
35+}
36+func (c Client) OpenLink(in *OpenLinkIn) (*OpenLinkOut, error) {
37+	if in == nil {
38+		in = new(OpenLinkIn)
39+	}
40+	out := new(OpenLinkOut)
41+	err := c.Client.Do("fr.delthas.senpai.OpenLink", in, out)
42+	return out, unmarshalError(err)
43+}
44+
45+type Backend interface {
46+	OpenLink(*OpenLinkIn) (*OpenLinkOut, error)
47+}
48+
49+type Handler struct {
50+	Backend Backend
51+}
52+
53+func marshalError(err error) error {
54+	var name string
55+	switch err.(type) {
56+	default:
57+		return err
58+	}
59+	return &govarlink.ServerError{
60+		Name:       name,
61+		Parameters: err,
62+	}
63+}
64+func (h Handler) HandleVarlink(call *govarlink.ServerCall, req *govarlink.ServerRequest) error {
65+	var (
66+		out interface{}
67+		err error
68+	)
69+	switch req.Method {
70+	case "fr.delthas.senpai.OpenLink":
71+		in := new(OpenLinkIn)
72+		if err := json.Unmarshal(req.Parameters, in); err != nil {
73+			return err
74+		}
75+		out, err = h.Backend.OpenLink(in)
76+	default:
77+		err = &govarlink.ServerError{
78+			Name:       "org.varlink.service.MethodNotFound",
79+			Parameters: map[string]string{"method": req.Method},
80+		}
81+	}
82+	if err != nil {
83+		return marshalError(err)
84+	}
85+	return call.CloseWithReply(out)
86+}
87+
88+func (h Handler) Register(reg *govarlink.Registry) {
89+	reg.Add(&govarlink.RegistryInterface{
90+		Definition: "interface fr.delthas.senpai\n\nmethod OpenLink(link: string) -> ()\n",
91+		Name:       "fr.delthas.senpai",
92+	}, h)
93+}
1@@ -0,0 +1,3 @@
2+interface fr.delthas.senpai
3+
4+method OpenLink(link: string) -> ()
+9, -0
 1@@ -0,0 +1,9 @@
 2+//go:build generate
 3+
 4+package varlinkservice
 5+
 6+import (
 7+	_ "github.com/emersion/go-varlink/cmd/varlinkgen"
 8+)
 9+
10+//go:generate go run github.com/emersion/go-varlink/cmd/varlinkgen -i fr.delthas.senpai.varlink