mautrix-slack/formatter.go

357 lines
9.6 KiB
Go

// mautrix-slack - A Matrix-Slack puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/slack-go/slack"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
goldmarkUtil "github.com/yuin/goldmark/util"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/util"
"go.mau.fi/mautrix-slack/database"
)
var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
const mentionedUsersContextKey = "fi.mau.slack.mentioned_users"
func (portal *Portal) renderSlackMarkdown(text string) *event.MessageEventContent {
text = replaceShortcodesWithEmojis(text)
text = escapeFixer.ReplaceAllStringFunc(text, func(s string) string {
return s[:2] + `\` + s[2:]
})
mdRenderer := goldmark.New(
format.Extensions, format.HTMLOptions,
goldmark.WithExtensions(&SlackTag{portal}),
)
content := format.RenderMarkdownCustom(text, mdRenderer)
return &content
}
func (portal *Portal) renderSlackFile(file slack.File) event.MessageEventContent {
content := event.MessageEventContent{
Info: &event.FileInfo{
MimeType: file.Mimetype,
Size: int(file.Size),
},
}
if file.OriginalW != 0 {
content.Info.Width = file.OriginalW
}
if file.OriginalH != 0 {
content.Info.Height = file.OriginalH
}
if file.Name != "" {
content.Body = file.Name
} else {
mimeClass := strings.Split(file.Mimetype, "/")[0]
switch mimeClass {
case "application":
content.Body = "file"
default:
content.Body = mimeClass
}
content.Body += util.ExtensionFromMimetype(file.Mimetype)
}
if strings.HasPrefix(file.Mimetype, "image") {
content.MsgType = event.MsgImage
} else if strings.HasPrefix(file.Mimetype, "video") {
content.MsgType = event.MsgVideo
} else if strings.HasPrefix(file.Mimetype, "audio") {
content.MsgType = event.MsgAudio
} else {
content.MsgType = event.MsgFile
}
return content
}
func (bridge *SlackBridge) ParseMatrix(html string) string {
ctx := format.NewContext()
return bridge.MatrixHTMLParser.Parse(html, ctx)
}
func NewParser(bridge *SlackBridge) *format.HTMLParser {
return &format.HTMLParser{
TabsToSpaces: 4,
Newline: "\n",
PillConverter: func(displayname, mxid, eventID string, _ format.Context) string {
if mxid[0] == '@' {
_, user, success := bridge.ParsePuppetMXID(id.UserID(mxid))
if success {
return fmt.Sprintf("<@%s>", strings.ToUpper(user))
}
}
return fmt.Sprintf("@%s", displayname)
},
BoldConverter: func(text string, _ format.Context) string { return fmt.Sprintf("*%s*", text) },
ItalicConverter: func(text string, _ format.Context) string { return fmt.Sprintf("_%s_", text) },
StrikethroughConverter: func(text string, _ format.Context) string { return fmt.Sprintf("~%s~", text) },
MonospaceConverter: func(text string, _ format.Context) string { return fmt.Sprintf("`%s`", text) },
MonospaceBlockConverter: func(text, language string, _ format.Context) string { return fmt.Sprintf("```%s```", text) },
}
}
type astSlackTag struct {
ast.BaseInline
label string
}
var _ ast.Node = (*astSlackTag)(nil)
var astKindSlackTag = ast.NewNodeKind("SlackTag")
func (n *astSlackTag) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}
func (n *astSlackTag) Kind() ast.NodeKind {
return astKindSlackTag
}
type astSlackUserMention struct {
astSlackTag
userID string
}
func (n *astSlackUserMention) String() string {
if n.label != "" {
return fmt.Sprintf("<@%s|%s>", n.userID, n.label)
} else {
return fmt.Sprintf("<@%s>", n.userID)
}
}
type astSlackChannelMention struct {
astSlackTag
channelID string
}
func (n *astSlackChannelMention) String() string {
if n.label != "" {
return fmt.Sprintf("<#%s|%s>", n.channelID, n.label)
} else {
return fmt.Sprintf("<#%s>", n.channelID)
}
}
type astSlackURL struct {
astSlackTag
url string
}
func (n *astSlackURL) String() string {
if n.label != n.url {
return fmt.Sprintf("<%s|%s>", n.url, n.label)
} else {
return fmt.Sprintf("<%s>", n.url)
}
}
type astSlackSpecialMention struct {
astSlackTag
content string
}
var slackSpecialMentionRegex = regexp.MustCompile(`<(#|@|!|)([^|>]+)(\|([^|>]*))?>`)
func (n *astSlackSpecialMention) String() string {
if n.label != "" {
return fmt.Sprintf("<!%s|%s>", n.content, n.label)
} else {
return fmt.Sprintf("<!%s>", n.content)
}
}
type slackTagParser struct{}
// Regex matching Slack docs at https://api.slack.com/reference/surfaces/formatting#retrieving-messages
var slackTagRegex = regexp.MustCompile(`<(#|@|!|)([^|>]+)(\|([^|>]*))?>`)
var defaultSlackTagParser = &slackTagParser{}
func (s *slackTagParser) Trigger() []byte {
return []byte{'<'}
}
func (s *slackTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
//before := block.PrecendingCharacter()
line, _ := block.PeekLine()
match := slackTagRegex.FindSubmatch(line)
if match == nil {
return nil
}
//seg := segment.WithStop(segment.Start + len(match[0]))
block.Advance(len(match[0]))
sigil := string(match[1])
content := string(match[2])
text := string(match[4])
tag := astSlackTag{label: text}
switch sigil {
case "@":
return &astSlackUserMention{astSlackTag: tag, userID: content}
case "#":
return &astSlackChannelMention{astSlackTag: tag, channelID: content}
case "!":
return &astSlackSpecialMention{astSlackTag: tag, content: content}
case "":
return &astSlackURL{astSlackTag: tag, url: content}
default:
return nil
}
}
func (s *slackTagParser) CloseBlock(parent ast.Node, pc parser.Context) {
// nothing to do
}
type slackTagHTMLRenderer struct {
portal *Portal
}
func (r *slackTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(astKindSlackTag, r.renderSlackTag)
}
func (r *slackTagHTMLRenderer) renderSlackTag(w goldmarkUtil.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) {
status = ast.WalkContinue
if !entering {
return
}
switch node := n.(type) {
case *astSlackUserMention:
puppet := r.portal.bridge.GetPuppetByID(r.portal.Key.TeamID, node.userID)
if puppet != nil && puppet.GetCustomOrGhostMXID() != "" {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s">%s</a>`, puppet.GetCustomOrGhostMXID(), puppet.Name)
} else { // TODO: get puppet info if not exist
if node.label != "" {
_, _ = fmt.Fprintf(w, `@%s`, node.label)
} else {
_, _ = fmt.Fprintf(w, `@%s`, node.userID)
}
}
return
case *astSlackChannelMention:
portal := r.portal.bridge.DB.Portal.GetByID(database.PortalKey{
TeamID: r.portal.Key.TeamID,
ChannelID: node.channelID,
})
if portal != nil && portal.MXID != "" {
_, _ = fmt.Fprintf(w, `<a href="https://matrix.to/#/%s?via=%s">%s</a>`, portal.MXID, r.portal.bridge.AS.HomeserverDomain, portal.Name)
} else { // TODO: get portal info if not exist
if node.label != "" {
_, _ = fmt.Fprintf(w, `#%s`, node.label)
} else {
_, _ = fmt.Fprintf(w, `#%s`, node.channelID)
}
}
return
case *astSlackSpecialMention:
parts := strings.Split(node.content, "^")
switch parts[0] {
case "date":
timestamp, converr := strconv.ParseInt(parts[1], 10, 64)
if converr != nil {
return
}
t := time.Unix(timestamp, 0)
mapping := map[string]string{
"{date_num}": t.Local().Format("2006-01-02"),
"{date}": t.Local().Format("January 2, 2006"),
"{date_pretty}": t.Local().Format("January 2, 2006"),
"{date_short}": t.Local().Format("Jan 2, 2006"),
"{date_short_pretty}": t.Local().Format("Jan 2, 2006"),
"{date_long}": t.Local().Format("Monday, January 2, 2006"),
"{date_long_pretty}": t.Local().Format("Monday, January 2, 2006"),
"{time}": t.Local().Format("15:04 MST"),
"{time_secs}": t.Local().Format("15:04:05 MST"),
}
for k, v := range mapping {
parts[2] = strings.ReplaceAll(parts[2], k, v)
}
if len(parts) > 3 {
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, parts[3], parts[2])
} else {
_, _ = w.WriteString(parts[2])
}
return
case "channel", "everyone", "here":
// do @room mentions?
return
case "subteam":
// do subteam handling? more spaces?
return
default:
return
}
case *astSlackURL:
label := node.label
if label == "" {
label = node.url
}
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, node.url, label)
return
}
stringifiable, ok := n.(fmt.Stringer)
if ok {
_, _ = w.WriteString(stringifiable.String())
} else {
_, _ = w.Write(source)
}
return
}
type SlackTag struct {
Portal *Portal
}
func (e *SlackTag) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
goldmarkUtil.Prioritized(defaultSlackTagParser, 150),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
goldmarkUtil.Prioritized(&slackTagHTMLRenderer{e.Portal}, 150),
))
}