From d0e43cc63b03ade5e1fe650bc19d7b34b1100a9a Mon Sep 17 00:00:00 2001 From: diamondburned Date: Wed, 8 Jul 2020 01:35:30 -0700 Subject: [PATCH] cchat to v0.0.42; added embed and attachment support; colored mentions --- go.mod | 2 +- go.sum | 2 + message.go | 2 +- segments/blockquote.go | 4 +- segments/codeblock.go | 4 +- segments/{segments.go => colored.go} | 13 +- segments/embed.go | 30 +++-- segments/emoji.go | 2 +- segments/inline_attr.go | 4 +- segments/md.go | 64 ++++++--- segments/mention.go | 191 +++++++++++++++++++++++++-- urlutils/urlutils.go | 13 ++ 12 files changed, 279 insertions(+), 52 deletions(-) rename segments/{segments.go => colored.go} (62%) diff --git a/go.mod b/go.mod index 59944a4..53cdde8 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.14 require ( github.com/diamondburned/arikawa v0.9.5 - github.com/diamondburned/cchat v0.0.41 + github.com/diamondburned/cchat v0.0.42 github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249 github.com/dustin/go-humanize v1.0.0 github.com/go-test/deep v1.0.6 diff --git a/go.sum b/go.sum index 5d7d196..9766a5d 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/diamondburned/cchat v0.0.40 h1:38gPyJnnDoNDHrXcV8Qchfv3y6jlS3Fzz/6FY0 github.com/diamondburned/cchat v0.0.40/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.41 h1:6y32s2wWTiDw4hWN/Gna6ay3uUrRAW5V8Cj0/xLKovw= github.com/diamondburned/cchat v0.0.41/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.42 h1:FVMLy9hOTxKju8OWDBIStrekbgTHCaH8+GVnV4LOByg= +github.com/diamondburned/cchat v0.0.42/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249 h1:yP7kJ+xCGpDz6XbcfACJcju4SH1XDPwlrvbofz3lP8I= github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249/go.mod h1:xW9hpBZsGi8KpAh10TyP+YQlYBo+Xc+2w4TR6N0951A= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= diff --git a/message.go b/message.go index 1ebdecb..67c4e25 100644 --- a/message.go +++ b/message.go @@ -54,7 +54,7 @@ func (m messageHeader) Time() time.Time { // AvatarURL wraps the URL with URL queries for the avatar. func AvatarURL(URL string) string { - return urlutils.Sized(URL, 64) + return urlutils.AvatarURL(URL) } type Author struct { diff --git a/segments/blockquote.go b/segments/blockquote.go index ad0d646..bfd2bf2 100644 --- a/segments/blockquote.go +++ b/segments/blockquote.go @@ -17,7 +17,7 @@ func (r *TextRenderer) blockquote(n *ast.Blockquote, enter bool) ast.WalkStatus defer r.endBlock() // Create a segment. - var seg = BlockquoteSegment{start: r.i()} + var seg = BlockquoteSegment{start: r.buf.Len()} // A blockquote contains a paragraph each line. Because Discord. for child := n.FirstChild(); child != nil; child = child.NextSibling() { @@ -34,7 +34,7 @@ func (r *TextRenderer) blockquote(n *ast.Blockquote, enter bool) ast.WalkStatus } // Write the end of the segment. - seg.end = r.i() + seg.end = r.buf.Len() r.append(seg) } diff --git a/segments/codeblock.go b/segments/codeblock.go index a1ff21a..45e7072 100644 --- a/segments/codeblock.go +++ b/segments/codeblock.go @@ -20,7 +20,7 @@ func (r *TextRenderer) codeblock(n *ast.FencedCodeBlock, enter bool) ast.WalkSta // Create a segment. seg := CodeblockSegment{ - start: r.i(), + start: r.buf.Len(), language: string(n.Language(r.src)), } @@ -33,7 +33,7 @@ func (r *TextRenderer) codeblock(n *ast.FencedCodeBlock, enter bool) ast.WalkSta } // Close the segment. - seg.end = r.i() + seg.end = r.buf.Len() r.append(seg) // Close the block. diff --git a/segments/segments.go b/segments/colored.go similarity index 62% rename from segments/segments.go rename to segments/colored.go index 712ca29..a46db9e 100644 --- a/segments/segments.go +++ b/segments/colored.go @@ -3,8 +3,9 @@ package segments import "github.com/diamondburned/cchat/text" type Colored struct { - strlen int - color uint32 + start int + end int + color uint32 } var ( @@ -13,11 +14,15 @@ var ( ) func NewColored(strlen int, color uint32) Colored { - return Colored{strlen, color} + return Colored{0, strlen, color} +} + +func NewColoredSegment(start, end int, color uint32) Colored { + return Colored{start, end, color} } func (color Colored) Bounds() (start, end int) { - return 0, color.strlen + return color.start, color.end } func (color Colored) Color() uint32 { diff --git a/segments/embed.go b/segments/embed.go index 463a320..2284d8a 100644 --- a/segments/embed.go +++ b/segments/embed.go @@ -28,7 +28,7 @@ func (r *TextRenderer) renderEmbeds(embeds []discord.Embed, m *discord.Message, func (r *TextRenderer) renderEmbed(embed discord.Embed, m *discord.Message, s state.Store) { if a := embed.Author; a != nil && a.Name != "" { if a.ProxyIcon != "" { - r.append(EmbedAuthor(r.i(), *a)) + r.append(EmbedAuthor(r.buf.Len(), *a)) r.buf.WriteByte(' ') } @@ -57,6 +57,12 @@ func (r *TextRenderer) renderEmbed(embed discord.Embed, m *discord.Message, s st } } + // If we have a thumbnail, then write one. + if embed.Thumbnail != nil { + r.append(EmbedThumbnail(r.buf.Len(), *embed.Thumbnail)) + r.buf.WriteByte('\n') + } + if embed.Description != "" { // Since Discord embeds' descriptions are technically Markdown, we can // borrow our Markdown parser for this. @@ -84,7 +90,7 @@ func (r *TextRenderer) renderEmbed(embed discord.Embed, m *discord.Message, s st if f := embed.Footer; f != nil && f.Text != "" { if f.ProxyIcon != "" { - r.append(EmbedFooter(r.i(), *f)) + r.append(EmbedFooter(r.buf.Len(), *f)) r.buf.WriteByte(' ') } @@ -100,6 +106,12 @@ func (r *TextRenderer) renderEmbed(embed discord.Embed, m *discord.Message, s st r.buf.WriteString(embed.Timestamp.Format(time.RFC1123)) r.buf.WriteByte('\n') } + + // Write an image if there's one. + if embed.Image != nil { + r.append(EmbedImage(r.buf.Len(), *embed.Image)) + r.buf.WriteByte('\n') + } } func (r *TextRenderer) renderAttachments(attachments []discord.Attachment) { @@ -108,8 +120,8 @@ func (r *TextRenderer) renderAttachments(attachments []discord.Attachment) { return } - // Start a new block before rendering attachments. - r.startBlock() + // Start a (small) new block before rendering attachments. + r.startBlockN(1) // Render all attachments. Newline delimited. for i, attachment := range attachments { @@ -123,7 +135,7 @@ func (r *TextRenderer) renderAttachments(attachments []discord.Attachment) { func (r *TextRenderer) renderAttachment(a discord.Attachment) { if urlutils.ExtIs(a.Proxy, imageExts) { - r.append(EmbedAttachment(r.i(), a)) + r.append(EmbedAttachment(r.buf.Len(), a)) return } @@ -187,23 +199,23 @@ type ImageSegment struct { text string } -func EmbedImage(start int, i discord.EmbedImage, text string) ImageSegment { +func EmbedImage(start int, i discord.EmbedImage) ImageSegment { return ImageSegment{ start: start, url: i.Proxy, w: int(i.Width), h: int(i.Height), - text: text, + text: fmt.Sprintf("Image (%s)", urlutils.Name(i.URL)), } } -func EmbedThumbnail(start int, t discord.EmbedThumbnail, text string) ImageSegment { +func EmbedThumbnail(start int, t discord.EmbedThumbnail) ImageSegment { return ImageSegment{ start: start, url: t.Proxy, w: int(t.Width), h: int(t.Height), - text: text, + text: fmt.Sprintf("Thumbnail (%s)", urlutils.Name(t.URL)), } } diff --git a/segments/emoji.go b/segments/emoji.go index fea5557..35c591b 100644 --- a/segments/emoji.go +++ b/segments/emoji.go @@ -23,7 +23,7 @@ var _ text.Imager = (*EmojiSegment)(nil) func (r *TextRenderer) emoji(n *md.Emoji, enter bool) ast.WalkStatus { if enter { r.append(EmojiSegment{ - start: r.i(), + start: r.buf.Len(), name: n.Name, large: n.Large, emojiURL: n.EmojiURL() + "&size=64", diff --git a/segments/inline_attr.go b/segments/inline_attr.go index 5197494..00a4035 100644 --- a/segments/inline_attr.go +++ b/segments/inline_attr.go @@ -71,7 +71,7 @@ func (r *TextRenderer) inline(n *md.Inline, enter bool) ast.WalkStatus { // Pop the last segment if it's not empty. if !r.inls.empty() { - r.inls.end = r.i() + r.inls.end = r.buf.Len() // Only use this section if the length is not zero. if r.inls.start != r.inls.end { @@ -86,7 +86,7 @@ func (r *TextRenderer) inline(n *md.Inline, enter bool) ast.WalkStatus { } // Update the start pointer of the current segment. - r.inls.start = r.i() + r.inls.start = r.buf.Len() return ast.WalkContinue } diff --git a/segments/md.go b/segments/md.go index 38d4866..918168b 100644 --- a/segments/md.go +++ b/segments/md.go @@ -16,9 +16,13 @@ func ParseMessage(m *discord.Message, s state.Store) text.Rich { var node = md.ParseWithMessage(content, s, m, true) r := NewTextReader(content, node) + // Register the needed states for some renderers. + r.WithState(m, s) + // Render the main message body. r.walk(node) - r.renderEmbeds(m.Embeds, m, s) + // Render the extra bits. r.renderAttachments(m.Attachments) + r.renderEmbeds(m.Embeds, m, s) return text.Rich{ Content: r.String(), @@ -51,6 +55,10 @@ type TextRenderer struct { src []byte segs []text.Segment inls inlineState + + // these fields can be nil + msg *discord.Message + store state.Store } func NewTextReader(src []byte, node ast.Node) TextRenderer { @@ -64,6 +72,11 @@ func NewTextReader(src []byte, node ast.Node) TextRenderer { } } +func (r *TextRenderer) WithState(m *discord.Message, s state.Store) { + r.msg = m + r.store = s +} + // String returns a stringified version of Bytes(). func (r *TextRenderer) String() string { return string(r.Bytes()) @@ -97,41 +110,34 @@ func (r *TextRenderer) Bytes() []byte { return trbuf } -// i returns the current cursor position. -func (r *TextRenderer) i() int { - return r.buf.Len() -} - func (r *TextRenderer) writeStringf(f string, v ...interface{}) (start, end int) { return r.writeString(fmt.Sprintf(f, v...)) } func (r *TextRenderer) writeString(s string) (start, end int) { - start = r.i() - r.buf.WriteString(s) - end = r.i() - return + return writestringbuf(r.buf, s) } func (r *TextRenderer) write(b []byte) (start, end int) { - start = r.i() - r.buf.Write(b) - end = r.i() - return + return writebuf(r.buf, b) } // startBlock guarantees enough indentation to start a new block. func (r *TextRenderer) startBlock() { + r.startBlockN(2) +} + +// startBlockN allows a custom block level. +func (r *TextRenderer) startBlockN(n int) { var maxNewlines = 0 // Peek twice. If the last character is already a new line or we're only at // the start of line (length 0), then don't pad. if r.buf.Len() > 0 { - if r.peekLast(0) != '\n' { - maxNewlines++ - } - if r.peekLast(1) != '\n' { - maxNewlines++ + for i := 0; i < n; i++ { + if r.peekLast(i) != '\n' { + maxNewlines++ + } } } @@ -218,3 +224,23 @@ func (r *TextRenderer) renderNode(n ast.Node, enter bool) (ast.WalkStatus, error return ast.WalkContinue, nil } + +// helper global functions + +func writebuf(w *bytes.Buffer, b []byte) (start, end int) { + start = w.Len() + w.Write(b) + end = w.Len() + return start, end +} + +func writestringbuf(w *bytes.Buffer, b string) (start, end int) { + start = w.Len() + w.WriteString(b) + end = w.Len() + return start, end +} + +func segmentadd(r *text.Rich, seg ...text.Segment) { + r.Segments = append(r.Segments, seg...) +} diff --git a/segments/mention.go b/segments/mention.go index 78616c7..69c455d 100644 --- a/segments/mention.go +++ b/segments/mention.go @@ -1,37 +1,86 @@ package segments import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/cchat-discord/urlutils" "github.com/diamondburned/cchat/text" "github.com/diamondburned/ningen/md" "github.com/yuin/goldmark/ast" ) -const ( - mentionChannel uint8 = iota - mentionUser - mentionRole -) +const blurple = 0x7289DA + +type roleInfo struct { + name string + color uint32 + position int // used for sorting +} + +func (r *TextRenderer) userRoles(user *discord.GuildUser) []roleInfo { + if user.Member == nil || r.msg == nil || !r.msg.GuildID.Valid() { + return nil + } + + var roles = make([]roleInfo, 0, len(user.Member.RoleIDs)) + + for _, roleID := range user.Member.RoleIDs { + r, err := r.store.Role(r.msg.GuildID, roleID) + if err != nil { + continue + } + + roles = append(roles, roleInfo{ + name: r.Name, + color: r.Color.Uint32(), // default 0 + position: r.Position, + }) + } + + // Sort the roles so the first roles stay in front. We need to do this to + // both render properly and to get the right role color. + sort.Slice(roles, func(i, j int) bool { + return roles[i].position < roles[j].position + }) + + return roles +} type MentionSegment struct { start, end int + *md.Mention + + // only non-nil if GuildUser is not nil and is in a guild. + roles []roleInfo } -var _ text.Segment = (*MentionSegment)(nil) +var ( + _ text.Segment = (*MentionSegment)(nil) + _ text.Colorer = (*MentionSegment)(nil) + _ text.Mentioner = (*MentionSegment)(nil) +) func (r *TextRenderer) mention(n *md.Mention, enter bool) ast.WalkStatus { if enter { - seg := MentionSegment{start: r.i()} + var seg = MentionSegment{Mention: n} switch { case n.Channel != nil: - r.buf.WriteString("#" + n.Channel.Name) + seg.start, seg.end = r.writeString("#" + n.Channel.Name) case n.GuildUser != nil: - r.buf.WriteString("@" + n.GuildUser.Username) + seg.start, seg.end = r.writeString("@" + n.GuildUser.Username) + seg.roles = r.userRoles(n.GuildUser) // get roles as well case n.GuildRole != nil: - r.buf.WriteString("@" + n.GuildRole.Name) + seg.start, seg.end = r.writeString("@" + n.GuildRole.Name) + default: + // Unexpected error; skip. + return ast.WalkSkipChildren } - seg.end = r.i() r.append(seg) } @@ -42,7 +91,127 @@ func (m MentionSegment) Bounds() (start, end int) { return m.start, m.end } +// Color tries to return the color of the mention segment, or it returns the +// usual blurple if none. +func (m MentionSegment) Color() uint32 { + // Try digging through what we have for a color. + switch { + case len(m.roles) > 0: + for _, role := range m.roles { + if role.color > 0 { + return role.color + } + } + case m.GuildRole != nil && m.GuildRole.Color > 0: + return m.GuildRole.Color.Uint32() + } + + return blurple +} + // TODO func (m MentionSegment) MentionInfo() text.Rich { + switch { + case m.Channel != nil: + return m.channelInfo() + case m.GuildUser != nil: + return m.userInfo() + case m.GuildRole != nil: + return m.roleInfo() + } + + // Unknown; return an empty text. return text.Rich{} } + +func (m MentionSegment) channelInfo() text.Rich { + content := strings.Builder{} + content.WriteByte('#') + content.WriteString(m.Channel.Name) + + if m.Channel.NSFW { + content.WriteString(" (NSFW)") + } + + if m.Channel.Topic != "" { + content.WriteByte('\n') + content.WriteString(m.Channel.Topic) + } + + return text.Rich{ + Content: content.String(), + } +} + +func (m MentionSegment) userInfo() text.Rich { + var content bytes.Buffer + var segment text.Rich + + // Make a large avatar if there's one. + if m.GuildUser != nil { + segment.Segments = append(segment.Segments, AvatarSegment{ + start: 0, + url: urlutils.AvatarURL(m.GuildUser.AvatarURL()), + text: "Avatar", + }) + // Space out. + content.WriteByte(' ') + } + + // Write the nickname if there's one; else, write the username only. + if m.GuildUser.Member != nil && m.GuildUser.Member.Nick != "" { + content.WriteString(m.GuildUser.Member.Nick) + content.WriteByte(' ') + + start, end := writestringbuf(&content, fmt.Sprintf( + "(%s#%s)", + m.GuildUser.Username, + m.GuildUser.Discriminator, + )) + + segmentadd(&segment, InlineSegment{ + start: start, + end: end, + attributes: text.AttrDimmed, + }) + } else { + content.WriteString(m.GuildUser.Username) + content.WriteByte('#') + content.WriteString(m.GuildUser.Discriminator) + } + + // Write roles, if any. + if len(m.roles) > 0 { + // Write a prepended new line, as role writes will always prepend a new + // line. This is to prevent a trailing new line. + content.WriteString("\n---\nRoles") + + for _, role := range m.roles { + // Prepend a new line before each item. + content.WriteByte('\n') + // Write exactly the role name, then grab the segment and color it. + start, end := writestringbuf(&content, role.name) + segmentadd(&segment, NewColoredSegment(start, end, role.color)) + } + } + + // Assign the written content into the text segment and return it. + segment.Content = content.String() + return segment +} + +func (m MentionSegment) roleInfo() text.Rich { + // We don't have much to write here. + var segment = text.Rich{ + Content: m.GuildRole.Name, + } + + // Maybe add a color if we have any. + if c := m.GuildRole.Color.Uint32(); c > 0 { + segment.Segments = []text.Segment{ + NewColored(len(m.GuildRole.Name), m.GuildRole.Color.Uint32()), + } + } + + return segment +} diff --git a/urlutils/urlutils.go b/urlutils/urlutils.go index 4759bcb..3e3cd8f 100644 --- a/urlutils/urlutils.go +++ b/urlutils/urlutils.go @@ -7,6 +7,11 @@ import ( "strings" ) +// AvatarURL wraps the URL with URL queries for the avatar. +func AvatarURL(URL string) string { + return Sized(URL, 64) +} + // Sized wraps the URL with the size query. func Sized(URL string, size int) string { u, err := url.Parse(URL) @@ -31,6 +36,14 @@ func Ext(URL string) string { return strings.ToLower(path.Ext(u.Path)) } +func Name(URL string) string { + u, err := url.Parse(URL) + if err != nil { + return URL + } + return path.Base(u.Path) +} + func ExtIs(URL string, exts []string) bool { var ext = Ext(URL)