diff --git a/channel.go b/channel.go index 829afd4..7dace03 100644 --- a/channel.go +++ b/channel.go @@ -428,11 +428,10 @@ func (ch *Channel) TypingTimeout() time.Duration { func (ch *Channel) TypingSubscribe(ti cchat.TypingIndicator) (func(), error) { return ch.session.AddHandler(func(t *gateway.TypingStartEvent) { - if t.ChannelID != ch.id { - return - } - if t, err := NewTyper(ch.session.Store, t); err == nil { - ti.AddTyper(t) + if t.ChannelID == ch.id { + if typer, err := NewTyper(ch.session.Store, t); err == nil { + ti.AddTyper(typer) + } } }), nil } diff --git a/channel_completion.go b/channel_completion.go index 632d8e6..c62cd5e 100644 --- a/channel_completion.go +++ b/channel_completion.go @@ -6,6 +6,7 @@ import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/state" "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-discord/urlutils" "github.com/diamondburned/cchat/text" ) @@ -179,7 +180,7 @@ func (ch *Channel) completeEmojis(word string) (entries []cchat.CompletionEntry) entries = append(entries, cchat.CompletionEntry{ Raw: emoji.String(), Text: text.Rich{Content: ":" + emoji.Name + ":"}, - IconURL: URLSized(emoji.EmojiURL(), 32), // small + IconURL: urlutils.Sized(emoji.EmojiURL(), 32), // small Image: true, }) if len(entries) >= MaxCompletion { diff --git a/go.mod b/go.mod index 3b88b41..59944a4 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,9 @@ go 1.14 require ( github.com/diamondburned/arikawa v0.9.5 - github.com/diamondburned/cchat v0.0.40 + github.com/diamondburned/cchat v0.0.41 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 github.com/pkg/errors v0.9.1 github.com/yuin/goldmark v1.1.30 diff --git a/go.sum b/go.sum index 11f7c29..5d7d196 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,12 @@ github.com/diamondburned/cchat v0.0.39 h1:Hxd7swmAIECm0MBd5wb1IFvreChwDFwnAshqgA github.com/diamondburned/cchat v0.0.39/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.40 h1:38gPyJnnDoNDHrXcV8Qchfv3y6jlS3Fzz/6FY0BPH6I= 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/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= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= diff --git a/message.go b/message.go index a972528..1ebdecb 100644 --- a/message.go +++ b/message.go @@ -1,14 +1,13 @@ package discord import ( - "net/url" - "strconv" "time" "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/gateway" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-discord/segments" + "github.com/diamondburned/cchat-discord/urlutils" "github.com/diamondburned/cchat/text" ) @@ -55,21 +54,7 @@ func (m messageHeader) Time() time.Time { // AvatarURL wraps the URL with URL queries for the avatar. func AvatarURL(URL string) string { - return URLSized(URL, 64) -} - -// URLSized wraps the URL with the size query. -func URLSized(URL string, size int) string { - u, err := url.Parse(URL) - if err != nil { - return URL - } - - q := u.Query() - q.Set("size", strconv.Itoa(size)) - u.RawQuery = q.Encode() - - return u.String() + return urlutils.Sized(URL, 64) } type Author struct { @@ -202,6 +187,9 @@ func NewMessage(m discord.Message, s *Session, author Author) Message { } func (m Message) Author() cchat.MessageAuthor { + if !m.author.id.Valid() { + return nil + } return m.author } diff --git a/segments/codeblock.go b/segments/codeblock.go index dfa3fa7..a1ff21a 100644 --- a/segments/codeblock.go +++ b/segments/codeblock.go @@ -14,8 +14,9 @@ var _ text.Codeblocker = (*CodeblockSegment)(nil) func (r *TextRenderer) codeblock(n *ast.FencedCodeBlock, enter bool) ast.WalkStatus { if enter { + // Open the block by adding formatting and all. r.startBlock() - defer r.endBlock() + r.buf.WriteString("---\n") // Create a segment. seg := CodeblockSegment{ @@ -34,6 +35,10 @@ func (r *TextRenderer) codeblock(n *ast.FencedCodeBlock, enter bool) ast.WalkSta // Close the segment. seg.end = r.i() r.append(seg) + + // Close the block. + r.buf.WriteString("\n---") + r.endBlock() } return ast.WalkContinue diff --git a/segments/embed.go b/segments/embed.go new file mode 100644 index 0000000..479befe --- /dev/null +++ b/segments/embed.go @@ -0,0 +1,235 @@ +package segments + +import ( + "fmt" + "time" + + "github.com/diamondburned/arikawa/discord" + "github.com/diamondburned/arikawa/state" + "github.com/diamondburned/cchat-discord/urlutils" + "github.com/diamondburned/ningen/md" + "github.com/dustin/go-humanize" +) + +var imageExts = []string{".jpg", ".jpeg", ".png", ".webp", ".gif"} + +func (r *TextRenderer) renderEmbeds(embeds []discord.Embed, m *discord.Message, s state.Store) { + for _, embed := range embeds { + r.startBlock() + r.buf.WriteString("---\n") + + r.renderEmbed(embed, m, s) + + r.buf.WriteString("---") // render prepends newline already + r.endBlock() + } +} + +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.buf.WriteByte(' ') + } + + start, end := r.writeString(a.Name) + r.buf.WriteByte('\n') + + if a.URL != "" { + r.append(LinkSegment{ + start, + end, + a.URL, + }) + } + } + + if embed.Title != "" { + start, end := r.writeString(embed.Title) + r.buf.WriteByte('\n') + + if embed.URL != "" { + r.append(LinkSegment{ + start, + end, + embed.URL, + }) + } + } + + if embed.Description != "" { + // Since Discord embeds' descriptions are technically Markdown, we can + // borrow our Markdown parser for this. + node := md.ParseWithMessage([]byte(embed.Description), s, m, false) + // Create a new renderer with inherited state and buffer but a new byte + // source. + desc := r.clone([]byte(embed.Description)) + // Walk using the newly created state. + desc.walk(node) + // Join the created state. + r.join(desc) + // Write a new line. + r.buf.WriteByte('\n') + } + + if len(embed.Fields) > 0 { + // Pad another new line. + r.buf.WriteByte('\n') + + // Write fields indented once. + for _, field := range embed.Fields { + fmt.Fprintf(r.buf, "\t%s: %s\n", field.Name, field.Value) + } + } + + if f := embed.Footer; f != nil && f.Text != "" { + if f.ProxyIcon != "" { + r.append(EmbedFooter(r.i(), *f)) + r.buf.WriteByte(' ') + } + + r.buf.WriteString(f.Text) + r.buf.WriteByte('\n') + } + + if embed.Timestamp.Valid() { + if embed.Footer != nil { + r.buf.WriteString(" - ") + } + + r.buf.WriteString(embed.Timestamp.Format(time.RFC1123)) + r.buf.WriteByte('\n') + } +} + +func (r *TextRenderer) renderAttachments(attachments []discord.Attachment) { + // Don't do anything if there are no attachments. + if len(attachments) == 0 { + return + } + + // Start a new block before rendering attachments. + r.startBlock() + + // Render all attachments. Newline delimited. + for i, attachment := range attachments { + r.renderAttachment(attachment) + + if i != len(attachments) { + r.buf.WriteByte('\n') + } + } +} + +func (r *TextRenderer) renderAttachment(a discord.Attachment) { + if urlutils.ExtIs(a.Proxy, imageExts) { + r.append(EmbedAttachment(r.i(), a)) + return + } + + start, end := r.writeStringf( + "File: %s (%s)", + a.Filename, humanize.Bytes(a.Size), + ) + + r.append(LinkSegment{ + start, + end, + a.URL, + }) +} + +type AvatarSegment struct { + start int + url string + text string +} + +func EmbedAuthor(start int, a discord.EmbedAuthor) AvatarSegment { + return AvatarSegment{ + start: start, + url: a.ProxyIcon, + text: "Avatar", + } +} + +// EmbedFooter uses an avatar segment to comply with Discord. +func EmbedFooter(start int, a discord.EmbedFooter) AvatarSegment { + return AvatarSegment{ + start: start, + url: a.ProxyIcon, + text: "Icon", + } +} + +func (a AvatarSegment) Bounds() (int, int) { + return a.start, a.start +} + +// Avatar returns the avatar URL. +func (a AvatarSegment) Avatar() (url string) { + return a.url +} + +// AvatarSize returns the size of a small emoji. +func (a AvatarSegment) AvatarSize() int { + return InlineEmojiSize +} + +func (a AvatarSegment) AvatarText() string { + return a.text +} + +type ImageSegment struct { + start int + url string + w, h int + text string +} + +func EmbedImage(start int, i discord.EmbedImage, text string) ImageSegment { + return ImageSegment{ + start: start, + url: i.Proxy, + w: int(i.Width), + h: int(i.Height), + text: text, + } +} + +func EmbedThumbnail(start int, t discord.EmbedThumbnail, text string) ImageSegment { + return ImageSegment{ + start: start, + url: t.Proxy, + w: int(t.Width), + h: int(t.Height), + text: text, + } +} + +func EmbedAttachment(start int, a discord.Attachment) ImageSegment { + return ImageSegment{ + start: start, + url: a.Proxy, + w: int(a.Width), + h: int(a.Height), + text: fmt.Sprintf("%s (%s)", a.Filename, humanize.Bytes(a.Size)), + } +} + +func (i ImageSegment) Bounds() (start, end int) { + return i.start, i.start +} + +// Image returns the URL. +func (i ImageSegment) Image() string { + return i.url +} + +func (i ImageSegment) ImageSize() (w, h int) { + return i.w, i.h +} + +func (i ImageSegment) ImageText() string { + return i.text +} diff --git a/segments/link.go b/segments/link.go index 40f2664..0b3f4df 100644 --- a/segments/link.go +++ b/segments/link.go @@ -14,18 +14,15 @@ var _ text.Linker = (*LinkSegment)(nil) func (r *TextRenderer) link(n *ast.Link, enter bool) ast.WalkStatus { if enter { - // Make a segment with a start pointing to the end of buffer. - seg := LinkSegment{ - start: r.i(), - url: string(n.Destination), - } - // Write the actual title. - r.buf.Write(n.Title) + start, end := r.write(n.Title) // Close the segment. - seg.end = r.i() - r.append(seg) + r.append(LinkSegment{ + start, + end, + string(n.Destination), + }) } return ast.WalkContinue @@ -33,15 +30,13 @@ func (r *TextRenderer) link(n *ast.Link, enter bool) ast.WalkStatus { func (r *TextRenderer) autoLink(n *ast.AutoLink, enter bool) ast.WalkStatus { if enter { - seg := LinkSegment{ - start: r.i(), - url: string(n.URL(r.src)), - } + start, end := r.write(n.URL(r.src)) - r.buf.Write(n.URL(r.src)) - - seg.end = r.i() - r.append(seg) + r.append(LinkSegment{ + start, + end, + string(n.URL((r.src))), + }) } return ast.WalkContinue diff --git a/segments/md.go b/segments/md.go index 87da62b..38d4866 100644 --- a/segments/md.go +++ b/segments/md.go @@ -2,7 +2,7 @@ package segments import ( "bytes" - "strings" + "fmt" "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/state" @@ -11,18 +11,22 @@ import ( "github.com/yuin/goldmark/ast" ) -type TextRenderer struct { - buf *bytes.Buffer - src []byte - segs []text.Segment - inls inlineState -} - func ParseMessage(m *discord.Message, s state.Store) text.Rich { - return ParseWithMessage([]byte(m.Content), s, m, true) + var content = []byte(m.Content) + var node = md.ParseWithMessage(content, s, m, true) + + r := NewTextReader(content, node) + r.walk(node) + r.renderEmbeds(m.Embeds, m, s) + r.renderAttachments(m.Attachments) + + return text.Rich{ + Content: r.String(), + Segments: r.segs, + } } -func ParseWithMessage(b []byte, s state.Store, m *discord.Message, msg bool) text.Rich { +func ParseWithMessage(b []byte, m *discord.Message, s state.Store, msg bool) text.Rich { node := md.ParseWithMessage(b, s, m, msg) return RenderNode(b, node) } @@ -33,28 +37,89 @@ func Parse(b []byte) text.Rich { } func RenderNode(source []byte, n ast.Node) text.Rich { - buf := &bytes.Buffer{} - buf.Grow(len(source)) - - r := TextRenderer{ - src: source, - buf: buf, - segs: make([]text.Segment, 0, n.ChildCount()), - } - - ast.Walk(n, r.renderNode) + r := NewTextReader(source, n) + r.walk(n) return text.Rich{ - Content: buf.String(), + Content: r.String(), Segments: r.segs, } } +type TextRenderer struct { + buf *bytes.Buffer + src []byte + segs []text.Segment + inls inlineState +} + +func NewTextReader(src []byte, node ast.Node) TextRenderer { + buf := &bytes.Buffer{} + buf.Grow(len(src)) + + return TextRenderer{ + src: src, + buf: buf, + segs: make([]text.Segment, 0, node.ChildCount()), + } +} + +// String returns a stringified version of Bytes(). +func (r *TextRenderer) String() string { + return string(r.Bytes()) +} + +// Bytes returns the plain content of the buffer with right spaces trimmed as +// best as it could, that is, the function will not trim right spaces that +// segments use. +func (r *TextRenderer) Bytes() []byte { + // Get the rightmost index out of all the segments. + var rightmost int + for _, seg := range r.segs { + if _, end := seg.Bounds(); end > rightmost { + rightmost = end + } + } + + // Get the original byte slice. + org := r.buf.Bytes() + + // Trim the right spaces. + trbuf := bytes.TrimRight(org, "\n") + + // If we trimmed way too far, then slice so that we get as far as the + // rightmost segment. + if len(trbuf) < rightmost { + return org[:rightmost] + } + + // Else, we're safe returning the trimmed slice. + 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 +} + +func (r *TextRenderer) write(b []byte) (start, end int) { + start = r.i() + r.buf.Write(b) + end = r.i() + return +} + // startBlock guarantees enough indentation to start a new block. func (r *TextRenderer) startBlock() { var maxNewlines = 0 @@ -71,7 +136,10 @@ func (r *TextRenderer) startBlock() { } // Write the padding. - r.buf.WriteString(strings.Repeat("\n", maxNewlines)) + r.buf.Grow(maxNewlines) + for i := 0; i < maxNewlines; i++ { + r.buf.WriteByte('\n') + } } func (r *TextRenderer) endBlock() { @@ -79,6 +147,8 @@ func (r *TextRenderer) endBlock() { r.startBlock() } +// peekLast returns the previous byte that matches the offset, or 0 if the +// offset goes past the first byte. func (r *TextRenderer) peekLast(offset int) byte { if i := r.buf.Len() - offset - 1; i > 0 { return r.buf.Bytes()[i] @@ -90,6 +160,23 @@ func (r *TextRenderer) append(segs ...text.Segment) { r.segs = append(r.segs, segs...) } +// clone returns a shallow copy of TextRenderer with the new source. +func (r *TextRenderer) clone(src []byte) *TextRenderer { + cpy := *r + cpy.src = src + return &cpy +} + +// join combines the states from renderer with r. Use this with clone. +func (r *TextRenderer) join(renderer *TextRenderer) { + r.segs = renderer.segs + r.inls = renderer.inls +} + +func (r *TextRenderer) walk(n ast.Node) { + ast.Walk(n, r.renderNode) +} + func (r *TextRenderer) renderNode(n ast.Node, enter bool) (ast.WalkStatus, error) { switch n := n.(type) { case *ast.Document: diff --git a/urlutils/urlutils.go b/urlutils/urlutils.go new file mode 100644 index 0000000..4759bcb --- /dev/null +++ b/urlutils/urlutils.go @@ -0,0 +1,44 @@ +package urlutils + +import ( + "net/url" + "path" + "strconv" + "strings" +) + +// Sized wraps the URL with the size query. +func Sized(URL string, size int) string { + u, err := url.Parse(URL) + if err != nil { + return URL + } + + q := u.Query() + q.Set("size", strconv.Itoa(size)) + u.RawQuery = q.Encode() + + return u.String() +} + +// Ext returns the lowercased file extension of the URL. +func Ext(URL string) string { + u, err := url.Parse(URL) + if err != nil { + return "" + } + + return strings.ToLower(path.Ext(u.Path)) +} + +func ExtIs(URL string, exts []string) bool { + var ext = Ext(URL) + + for _, e := range exts { + if e == ext { + return true + } + } + + return false +}