Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/gtkcord/state.go
365 views
1
package gtkcord
2
3
import (
4
"context"
5
"fmt"
6
"html"
7
"log/slog"
8
"math"
9
"net/http"
10
"net/url"
11
"os"
12
"path/filepath"
13
"reflect"
14
"runtime"
15
"strconv"
16
"strings"
17
"sync"
18
"sync/atomic"
19
20
"github.com/diamondburned/arikawa/v3/api"
21
"github.com/diamondburned/arikawa/v3/discord"
22
"github.com/diamondburned/arikawa/v3/gateway"
23
"github.com/diamondburned/arikawa/v3/state"
24
"github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver"
25
"github.com/diamondburned/arikawa/v3/utils/ws"
26
"github.com/diamondburned/chatkit/components/author"
27
"github.com/diamondburned/gotk4/pkg/glib/v2"
28
"github.com/diamondburned/gotk4/pkg/gtk/v4"
29
"github.com/diamondburned/gotkit/app/locale"
30
"github.com/diamondburned/gotkit/app/prefs"
31
"github.com/diamondburned/gotkit/gtkutil"
32
"github.com/diamondburned/ningen/v3"
33
"github.com/diamondburned/ningen/v3/discordmd"
34
"libdb.so/dissent/internal/colorhash"
35
36
coreglib "github.com/diamondburned/gotk4/pkg/core/glib"
37
)
38
39
func init() {
40
hostname, err := os.Hostname()
41
if err != nil {
42
hostname = "PC"
43
}
44
45
api.UserAgent = "Dissent (https://libdb.so/dissent)"
46
gateway.DefaultIdentity = gateway.IdentifyProperties{
47
OS: runtime.GOOS,
48
Device: "Arikawa",
49
Browser: "Dissent on " + hostname,
50
}
51
}
52
53
// AllowedChannelTypes are the channel types that are shown.
54
var AllowedChannelTypes = []discord.ChannelType{
55
discord.GuildText,
56
discord.GuildCategory,
57
discord.GuildPublicThread,
58
discord.GuildPrivateThread,
59
discord.GuildForum,
60
discord.GuildAnnouncement,
61
discord.GuildAnnouncementThread,
62
discord.GuildVoice,
63
discord.GuildStageVoice,
64
}
65
66
type ctxKey uint8
67
68
const (
69
_ ctxKey = iota
70
stateKey
71
)
72
73
// State extends the Discord state controller.
74
type State struct {
75
*MainThreadHandler
76
*ningen.State
77
}
78
79
// FromContext gets the Discord state controller from the given context.
80
func FromContext(ctx context.Context) *State {
81
state, _ := ctx.Value(stateKey).(*State)
82
if state != nil {
83
return state.WithContext(ctx)
84
}
85
return nil
86
}
87
88
// Wrap wraps the given state.
89
func Wrap(state *state.State) *State {
90
c := state.Client.Client
91
c.OnRequest = append(c.OnRequest, func(r httpdriver.Request) error {
92
// req := (*http.Request)(r.(*httpdriver.DefaultRequest))
93
// log.Println("Discord API:", req.Method, req.URL.Path)
94
return nil
95
})
96
c.OnResponse = append(c.OnResponse, func(dreq httpdriver.Request, dresp httpdriver.Response) error {
97
req := (*http.Request)(dreq.(*httpdriver.DefaultRequest))
98
if dresp == nil {
99
return nil
100
}
101
102
resp := (*http.Response)(dresp.(*httpdriver.DefaultResponse))
103
if resp.StatusCode >= 400 {
104
slog.Warn(
105
"Discord API returned HTTP error",
106
"method", req.Method,
107
"path", req.URL.Path,
108
"status", resp.Status)
109
}
110
111
return nil
112
})
113
114
state.StateLog = func(err error) {
115
slog.Error(
116
"unexpected Discord state error occured",
117
"err", err)
118
}
119
120
if os.Getenv("DISSENT_DEBUG_DUMP_ALL_EVENTS_PLEASE") == "1" {
121
dir := filepath.Join(os.TempDir(), "gtkcord4-events")
122
slog.Warn(
123
"ATTENTION: DISSENT_DEBUG_DUMP_ALL_EVENTS_PLEASE is set to 1, dumping all raw events",
124
"dir", dir)
125
dumpRawEvents(state, dir)
126
}
127
128
ningen := ningen.FromState(state)
129
return &State{
130
MainThreadHandler: NewMainThreadHandler(ningen.Handler),
131
State: ningen,
132
}
133
}
134
135
var rawEventsOnce sync.Once
136
137
func dumpRawEvents(state *state.State, dir string) {
138
rawEventsOnce.Do(func() {
139
ws.EnableRawEvents = true
140
})
141
142
os.RemoveAll(dir)
143
144
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
145
slog.Error(
146
"cannot mkdir -p for debug event logging, not logging events",
147
"dir", dir,
148
"err", err)
149
return
150
}
151
152
var atom uint64
153
state.AddHandler(func(ev *ws.RawEvent) {
154
id := atomic.AddUint64(&atom, 1)
155
156
f, err := os.Create(filepath.Join(
157
dir,
158
fmt.Sprintf("%05d-%d-%s.json", id, ev.OriginalCode, ev.OriginalType),
159
))
160
if err != nil {
161
slog.Error(
162
"cannot create file to log one debug event",
163
"event_code", ev.OriginalCode,
164
"event_type", ev.OriginalType,
165
"err", err)
166
return
167
}
168
defer f.Close()
169
170
if _, err := f.Write(ev.Raw); err != nil {
171
slog.Error(
172
"cannot write file to log one debug event",
173
"event_code", ev.OriginalCode,
174
"event_type", ev.OriginalType,
175
"err", err)
176
return
177
}
178
})
179
}
180
181
// InjectState injects the given state to a new context.
182
func InjectState(ctx context.Context, state *State) context.Context {
183
return context.WithValue(ctx, stateKey, state)
184
}
185
186
// Offline creates a copy of State with a new offline state.
187
func (s *State) Offline() *State {
188
s2 := *s
189
s2.State = s.State.Offline()
190
return &s2
191
}
192
193
// Online creates a copy of State with a new online state.
194
func (s *State) Online() *State {
195
s2 := *s
196
s2.State = s.State.Online()
197
return &s2
198
}
199
200
// WithContext creates a copy of State with a new context.
201
func (s *State) WithContext(ctx context.Context) *State {
202
s2 := *s
203
s2.State = s.State.WithContext(ctx)
204
return &s2
205
}
206
207
// BindHandler is similar to BindWidgetHandler, except the lifetime of the
208
// handler is bound to the context.
209
func (s *State) BindHandler(ctx gtkutil.Cancellable, fn func(gateway.Event), filters ...gateway.Event) {
210
eventTypes := make([]reflect.Type, len(filters))
211
for i, filter := range filters {
212
eventTypes[i] = reflect.TypeOf(filter)
213
}
214
ctx.OnRenew(func(context.Context) func() {
215
return s.AddSyncHandler(func(ev gateway.Event) {
216
// Optionally filter out events.
217
if len(eventTypes) > 0 {
218
evType := reflect.TypeOf(ev)
219
220
for _, typ := range eventTypes {
221
if typ == evType {
222
goto filtered
223
}
224
}
225
226
return
227
}
228
229
filtered:
230
glib.IdleAddPriority(glib.PriorityDefault, func() { fn(ev) })
231
})
232
})
233
}
234
235
// BindWidget is similar to BindHandler, except it doesn't rely on contexts.
236
func (s *State) BindWidget(w gtk.Widgetter, fn func(gateway.Event), filters ...gateway.Event) {
237
eventTypes := make([]reflect.Type, len(filters))
238
for i, filter := range filters {
239
eventTypes[i] = reflect.TypeOf(filter)
240
}
241
242
ref := coreglib.NewWeakRef(w)
243
244
var unbind func()
245
bind := func() {
246
if unbind != nil {
247
return
248
}
249
250
w := ref.Get()
251
slog.Debug(
252
"binding state handler lifetime to widget",
253
"widget_type", fmt.Sprintf("%T", w),
254
"event_types", eventTypes)
255
256
unbind = s.AddSyncHandler(func(ev gateway.Event) {
257
// Optionally filter out events.
258
if len(eventTypes) > 0 {
259
evType := reflect.TypeOf(ev)
260
261
for _, typ := range eventTypes {
262
if typ == evType {
263
goto filtered
264
}
265
}
266
267
return
268
}
269
270
filtered:
271
glib.IdleAddPriority(glib.PriorityDefault, func() { fn(ev) })
272
})
273
}
274
275
bind()
276
277
base := gtk.BaseWidget(w)
278
base.NotifyProperty("parent", func() {
279
if base.Parent() != nil {
280
return
281
}
282
283
if unbind != nil {
284
unbind()
285
unbind = nil
286
287
slog.Debug(
288
"widget unparented, unbinded handler",
289
"func", "BindWidget",
290
"widget_type", gtk.BaseWidget(w).Type())
291
}
292
})
293
base.ConnectDestroy(func() {
294
if unbind != nil {
295
unbind()
296
unbind = nil
297
298
slog.Debug(
299
"widget destroyed, unbinded handler",
300
"func", "BindWidget",
301
"widget_type", gtk.BaseWidget(w).Type())
302
}
303
})
304
}
305
306
// AddHandler adds a handler to the state. The handler is removed when the
307
// returned function is called.
308
func (s *State) AddHandler(fns ...any) func() {
309
if len(fns) == 1 {
310
return s.MainThreadHandler.AddHandler(fns[0])
311
}
312
313
unbinds := make([]func(), 0, len(fns))
314
for _, fn := range fns {
315
unbind := s.MainThreadHandler.AddHandler(fn)
316
unbinds = append(unbinds, unbind)
317
}
318
319
return func() {
320
for _, unbind := range unbinds {
321
unbind()
322
}
323
unbinds = unbinds[:0]
324
}
325
}
326
327
// AddHandlerForWidget replaces BindWidget and provides a way to bind a handler
328
// that only receives events as long as the widget is mapped. As soon as the
329
// widget is unmapped, the handler is unbound.
330
func (s *State) AddHandlerForWidget(w gtk.Widgetter, fns ...any) func() {
331
unbinds := make([]func(), 0, len(fns))
332
333
unbind := func() {
334
for _, unbind := range unbinds {
335
unbind()
336
}
337
unbinds = unbinds[:0]
338
}
339
340
bind := func() {
341
for _, fn := range fns {
342
unbind := s.AddHandler(fn)
343
unbinds = append(unbinds, unbind)
344
}
345
}
346
347
bind()
348
349
base := gtk.BaseWidget(w)
350
base.NotifyProperty("parent", func() {
351
unbind()
352
if base.Parent() != nil {
353
bind()
354
} else {
355
slog.Debug(
356
"widget unparented, unbinding handler",
357
"func", "AddHandlerForWidget",
358
"widget_type", gtk.BaseWidget(w).Type())
359
}
360
})
361
362
return unbind
363
}
364
365
// AuthorMarkup renders the markup for the message author's name. It makes no
366
// API calls.
367
func (s *State) AuthorMarkup(m *gateway.MessageCreateEvent, mods ...author.MarkupMod) string {
368
user := &discord.GuildUser{User: m.Author, Member: m.Member}
369
return s.MemberMarkup(m.GuildID, user, mods...)
370
}
371
372
// UserMarkup is like AuthorMarkup but for any user optionally inside a guild.
373
func (s *State) UserMarkup(gID discord.GuildID, u *discord.User, mods ...author.MarkupMod) string {
374
user := &discord.GuildUser{User: *u}
375
return s.MemberMarkup(gID, user, mods...)
376
}
377
378
// UserIDMarkup gets the User markup from just the channel and user IDs.
379
func (s *State) UserIDMarkup(chID discord.ChannelID, uID discord.UserID, mods ...author.MarkupMod) string {
380
chs, err := s.Cabinet.Channel(chID)
381
if err != nil {
382
return html.EscapeString(uID.Mention())
383
}
384
385
if chs.GuildID.IsValid() {
386
member, err := s.Cabinet.Member(chs.GuildID, uID)
387
if err != nil {
388
return html.EscapeString(uID.Mention())
389
}
390
391
return s.MemberMarkup(chs.GuildID, &discord.GuildUser{
392
User: member.User,
393
Member: member,
394
}, mods...)
395
}
396
397
for _, recipient := range chs.DMRecipients {
398
if recipient.ID == uID {
399
return s.UserMarkup(0, &recipient)
400
}
401
}
402
403
return html.EscapeString(uID.Mention())
404
}
405
406
var overrideMemberColors = prefs.NewBool(false, prefs.PropMeta{
407
Name: "Override Member Colors",
408
Section: "Discord",
409
Description: "Use generated colors instead of role colors for members.",
410
})
411
412
// MemberMarkup is like AuthorMarkup but for any member inside a guild.
413
func (s *State) MemberMarkup(gID discord.GuildID, u *discord.GuildUser, mods ...author.MarkupMod) string {
414
name := u.DisplayOrUsername()
415
416
var suffix string
417
var prefixMods []author.MarkupMod
418
419
if gID.IsValid() {
420
if u.Member == nil {
421
u.Member, _ = s.Cabinet.Member(gID, u.ID)
422
}
423
424
if u.Member == nil {
425
s.MemberState.RequestMember(gID, u.ID)
426
goto noMember
427
}
428
429
if u.Member != nil && u.Member.Nick != "" {
430
name = u.Member.Nick
431
suffix += fmt.Sprintf(
432
` <span weight="normal">(%s)</span>`,
433
html.EscapeString(u.Member.User.Tag()),
434
)
435
}
436
437
if !overrideMemberColors.Value() {
438
c, ok := state.MemberColor(u.Member, func(id discord.RoleID) *discord.Role {
439
role, _ := s.Cabinet.Role(gID, id)
440
return role
441
})
442
if ok {
443
prefixMods = append(prefixMods, author.WithColor(c.String()))
444
}
445
}
446
}
447
448
if overrideMemberColors.Value() {
449
prefixMods = append(prefixMods, author.WithColor(hashUserColor(&u.User)))
450
}
451
452
noMember:
453
if u.Bot {
454
bot := "bot"
455
if u.Discriminator == "0000" {
456
bot = "webhook"
457
}
458
suffix += ` <span color="#6f78db" weight="normal">(` + bot + `)</span>`
459
}
460
461
if suffix != "" {
462
suffix = strings.TrimSpace(suffix)
463
prefixMods = append(prefixMods, author.WithSuffixMarkup(suffix))
464
}
465
466
return author.Markup(name, append(prefixMods, mods...)...)
467
}
468
469
func hashUserColor(user *discord.User) string {
470
input := user.Tag()
471
color := colorhash.DefaultHasher().Hash(input)
472
return colorhash.RGBHex(color)
473
}
474
475
// MessagePreview renders the message into a short content string.
476
func (s *State) MessagePreview(msg *discord.Message) string {
477
b := strings.Builder{}
478
b.Grow(len(msg.Content))
479
480
src := []byte(msg.Content)
481
node := discordmd.ParseWithMessage(src, *s.Cabinet, msg, true)
482
discordmd.DefaultRenderer.Render(&b, src, node)
483
484
preview := strings.TrimRight(b.String(), "\n")
485
if preview != "" {
486
return preview
487
}
488
489
if len(msg.Attachments) > 0 {
490
for _, attachment := range msg.Attachments {
491
preview += fmt.Sprintf("%s, ", attachment.Filename)
492
}
493
preview = strings.TrimSuffix(preview, ", ")
494
return preview
495
}
496
497
if len(msg.Embeds) > 0 {
498
return "[embed]"
499
}
500
501
return ""
502
}
503
504
// InjectAvatarSize calls InjectSize with size being 64px.
505
func InjectAvatarSize(urlstr string) string {
506
return InjectSize(urlstr, 64)
507
}
508
509
// InjectSize injects the size query parameter into the URL. Size is
510
// automatically scaled up to 2x or more.
511
func InjectSize(urlstr string, size int) string {
512
if urlstr == "" {
513
return ""
514
}
515
516
if scale := gtkutil.ScaleFactor(); scale > 2 {
517
size *= scale
518
} else {
519
size *= 2
520
}
521
522
return InjectSizeUnscaled(urlstr, size)
523
}
524
525
// InjectSizeUnscaled is like InjectSize, except the size is not scaled
526
// according to the scale factor.
527
func InjectSizeUnscaled(urlstr string, size int) string {
528
// Round size up to the nearest power of 2.
529
size = roundSize(size)
530
531
u, err := url.Parse(urlstr)
532
if err != nil {
533
return urlstr
534
}
535
536
q := u.Query()
537
q.Set("size", strconv.Itoa(size))
538
u.RawQuery = q.Encode()
539
540
return u.String()
541
}
542
543
func roundSize(size int) int {
544
// Round size up to the nearest power of 2.
545
return int(math.Pow(2, math.Ceil(math.Log2(float64(size)))))
546
}
547
548
// EmojiURL returns a sized emoji URL.
549
func EmojiURL(emojiID string, gif bool) string {
550
return InjectSize(discordmd.EmojiURL(emojiID, gif), 64)
551
}
552
553
// WindowTitleFromID returns the window title from the channel with the given
554
// ID.
555
func WindowTitleFromID(ctx context.Context, id discord.ChannelID) string {
556
state := FromContext(ctx)
557
ch, _ := state.Cabinet.Channel(id)
558
if ch == nil {
559
return ""
560
}
561
562
title := ChannelName(ch)
563
if ch.GuildID.IsValid() {
564
guild, _ := state.Cabinet.Guild(ch.GuildID)
565
if guild != nil {
566
title += " - " + guild.Name
567
}
568
}
569
570
return title
571
}
572
573
// ChannelNameFromID returns the channel's name in plain text from the channel
574
// with the given ID.
575
func ChannelNameFromID(ctx context.Context, id discord.ChannelID) string {
576
state := FromContext(ctx)
577
ch, _ := state.Cabinet.Channel(id)
578
return ChannelName(ch)
579
}
580
581
// ChannelName returns the channel's name in plain text.
582
func ChannelName(ch *discord.Channel) string {
583
return channelName(ch, true)
584
}
585
586
// ChannelNameWithoutHash returns the channel's name in plain text without the
587
// hash.
588
func ChannelNameWithoutHash(ch *discord.Channel) string {
589
return channelName(ch, false)
590
}
591
592
func channelName(ch *discord.Channel, hash bool) string {
593
if ch == nil {
594
return locale.Get("Unknown channel")
595
}
596
switch ch.Type {
597
case discord.DirectMessage:
598
if len(ch.DMRecipients) == 0 {
599
return RecipientNames(ch)
600
}
601
return userName(&ch.DMRecipients[0])
602
case discord.GroupDM:
603
if ch.Name != "" {
604
return ch.Name
605
}
606
return RecipientNames(ch)
607
case discord.GuildPublicThread, discord.GuildPrivateThread:
608
return ch.Name
609
default:
610
if hash {
611
return "#" + ch.Name
612
}
613
return ch.Name
614
}
615
}
616
617
// RecipientNames formats the string for the list of recipients inside the given
618
// channel.
619
func RecipientNames(ch *discord.Channel) string {
620
name := func(ix int) string { return userName(&ch.DMRecipients[ix]) }
621
622
// TODO: localize
623
624
switch len(ch.DMRecipients) {
625
case 0:
626
return "Empty channel"
627
case 1:
628
return name(0)
629
case 2:
630
return name(0) + " and " + name(1)
631
default:
632
var str strings.Builder
633
for _, u := range ch.DMRecipients[:len(ch.DMRecipients)-1] {
634
str.WriteString(userName(&u))
635
str.WriteString(", ")
636
}
637
str.WriteString(" and ")
638
str.WriteString(userName(&ch.DMRecipients[len(ch.DMRecipients)-1]))
639
return str.String()
640
}
641
}
642
643
func userName(u *discord.User) string {
644
if u.DisplayName == "" {
645
return u.Username
646
}
647
if strings.EqualFold(u.DisplayName, u.Username) {
648
return u.DisplayName
649
}
650
return fmt.Sprintf("%s (%s)", u.DisplayName, u.Username)
651
}
652
653
// SnowflakeVariant is the variant type for a [discord.Snowflake].
654
var SnowflakeVariant = glib.NewVariantType("x")
655
656
// NewSnowflakeVariant creates a new Snowflake variant.
657
func NewSnowflakeVariant(snowflake discord.Snowflake) *glib.Variant {
658
return glib.NewVariantInt64(int64(snowflake))
659
}
660
661
// NewChannelIDVariant creates a new ChannelID variant.
662
func NewChannelIDVariant(id discord.ChannelID) *glib.Variant {
663
return glib.NewVariantInt64(int64(id))
664
}
665
666
// NewGuildIDVariant creates a new GuildID variant.
667
func NewGuildIDVariant(id discord.GuildID) *glib.Variant {
668
return glib.NewVariantInt64(int64(id))
669
}
670
671
// NewMessageIDVariant creates a new MessageID variant.
672
func NewMessageIDVariant(id discord.MessageID) *glib.Variant {
673
return glib.NewVariantInt64(int64(id))
674
}
675
676