hicli: add support for sending markdown and rainbows

This commit is contained in:
Tulir Asokan 2024-10-12 14:52:21 +03:00
commit 190760cd65
6 changed files with 220 additions and 2 deletions

View file

@ -0,0 +1,120 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package rainbow
import (
"fmt"
"unicode"
"github.com/rivo/uniseg"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
"go.mau.fi/util/random"
)
// Extension is a goldmark extension that adds rainbow text coloring to the HTML renderer.
var Extension = &extRainbow{}
type extRainbow struct{}
type rainbowRenderer struct {
HardWraps bool
ColorID string
}
var defaultRB = &rainbowRenderer{HardWraps: true, ColorID: random.String(16)}
func (er *extRainbow) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(defaultRB, 0)))
}
func (rb *rainbowRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindText, rb.renderText)
reg.Register(ast.KindString, rb.renderString)
}
type rainbowBufWriter struct {
util.BufWriter
ColorID string
}
func (rbw rainbowBufWriter) WriteString(s string) (int, error) {
i := 0
graphemes := uniseg.NewGraphemes(s)
for graphemes.Next() {
runes := graphemes.Runes()
if len(runes) == 1 && unicode.IsSpace(runes[0]) {
i2, err := rbw.BufWriter.WriteRune(runes[0])
i += i2
if err != nil {
return i, err
}
continue
}
i2, err := fmt.Fprintf(rbw.BufWriter, "<font color=\"%s\">%s</font>", rbw.ColorID, graphemes.Str())
i += i2
if err != nil {
return i, err
}
}
return i, nil
}
func (rbw rainbowBufWriter) Write(data []byte) (int, error) {
return rbw.WriteString(string(data))
}
func (rbw rainbowBufWriter) WriteByte(c byte) error {
_, err := rbw.WriteRune(rune(c))
return err
}
func (rbw rainbowBufWriter) WriteRune(r rune) (int, error) {
if unicode.IsSpace(r) {
return rbw.BufWriter.WriteRune(r)
} else {
return fmt.Fprintf(rbw.BufWriter, "<font color=\"%s\">%c</font>", rbw.ColorID, r)
}
}
func (rb *rainbowRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
segment := n.Segment
if n.IsRaw() {
html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
} else {
html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
if n.HardLineBreak() || (n.SoftLineBreak() && rb.HardWraps) {
_, _ = w.WriteString("<br>\n")
} else if n.SoftLineBreak() {
_ = w.WriteByte('\n')
}
}
return ast.WalkContinue, nil
}
func (rb *rainbowRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.String)
if n.IsCode() {
_, _ = w.Write(n.Value)
} else {
if n.IsRaw() {
html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, n.Value)
} else {
html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, n.Value)
}
}
return ast.WalkContinue, nil
}

View file

@ -0,0 +1,56 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package rainbow
import (
"regexp"
"strings"
"github.com/lucasb-eyer/go-colorful"
)
// GradientTable from https://github.com/lucasb-eyer/go-colorful/blob/master/doc/gradientgen/gradientgen.go
type GradientTable []struct {
Col colorful.Color
Pos float64
}
func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color {
for i := 0; i < len(gt)-1; i++ {
c1 := gt[i]
c2 := gt[i+1]
if c1.Pos <= t && t <= c2.Pos {
t := (t - c1.Pos) / (c2.Pos - c1.Pos)
return c1.Col.BlendHcl(c2.Col, t).Clamped()
}
}
return gt[len(gt)-1].Col
}
var Gradient = GradientTable{
{colorful.LinearRgb(1, 0, 0), 0 / 11.0},
{colorful.LinearRgb(1, 0.5, 0), 1 / 11.0},
{colorful.LinearRgb(1, 1, 0), 2 / 11.0},
{colorful.LinearRgb(0.5, 1, 0), 3 / 11.0},
{colorful.LinearRgb(0, 1, 0), 4 / 11.0},
{colorful.LinearRgb(0, 1, 0.5), 5 / 11.0},
{colorful.LinearRgb(0, 1, 1), 6 / 11.0},
{colorful.LinearRgb(0, 0.5, 1), 7 / 11.0},
{colorful.LinearRgb(0, 0, 1), 8 / 11.0},
{colorful.LinearRgb(0.5, 0, 1), 9 / 11.0},
{colorful.LinearRgb(1, 0, 1), 10 / 11.0},
{colorful.LinearRgb(1, 0, 0.5), 11 / 11.0},
}
func ApplyColor(htmlBody string) string {
count := strings.Count(htmlBody, defaultRB.ColorID)
i := -1
return regexp.MustCompile(defaultRB.ColorID).ReplaceAllStringFunc(htmlBody, func(match string) string {
i++
return Gradient.GetInterpolatedColorFor(float64(i) / float64(count)).Hex()
})
}

2
go.mod
View file

@ -10,7 +10,9 @@ require (
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.23
github.com/rivo/uniseg v0.4.7
github.com/rs/xid v1.6.0
github.com/rs/zerolog v1.33.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e

4
go.sum
View file

@ -19,6 +19,8 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -31,6 +33,8 @@ github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7c
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=

View file

@ -38,7 +38,11 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return true, nil
})
case "send_message":
return unmarshalAndCall(req.Data, func(params *sendParams) (*database.Event, error) {
return unmarshalAndCall(req.Data, func(params *sendMessageParams) (*database.Event, error) {
return h.SendMessage(ctx, params.RoomID, params.Text, params.MediaPath)
})
case "send_event":
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
return h.Send(ctx, params.RoomID, params.EventType, params.Content)
})
case "get_event":
@ -100,7 +104,13 @@ type cancelRequestParams struct {
Reason string `json:"reason"`
}
type sendParams struct {
type sendMessageParams struct {
RoomID id.RoomID `json:"room_id"`
Text string `json:"text"`
MediaPath string `json:"media_path"`
}
type sendEventParams struct {
RoomID id.RoomID `json:"room_id"`
EventType event.Type `json:"type"`
Content json.RawMessage `json:"content"`

View file

@ -11,17 +11,43 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/yuin/goldmark"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/format/mdext/rainbow"
"maunium.net/go/mautrix/hicli/database"
"maunium.net/go/mautrix/id"
)
var (
rainbowWithHTML = goldmark.New(format.Extensions, format.HTMLOptions, goldmark.WithExtensions(rainbow.Extension))
)
func (h *HiClient) SendMessage(ctx context.Context, roomID id.RoomID, text, mediaPath string) (*database.Event, error) {
var content event.MessageEventContent
if strings.HasPrefix(text, "/rainbow ") {
text = strings.TrimPrefix(text, "/rainbow ")
content = format.RenderMarkdownCustom(text, rainbowWithHTML)
content.FormattedBody = rainbow.ApplyColor(content.FormattedBody)
} else if strings.HasPrefix(text, "/plain ") {
text = strings.TrimPrefix(text, "/plain ")
content = format.RenderMarkdown(text, false, false)
} else if strings.HasPrefix(text, "/html ") {
text = strings.TrimPrefix(text, "/html ")
content = format.RenderMarkdown(text, false, true)
} else {
content = format.RenderMarkdown(text, true, false)
}
return h.Send(ctx, roomID, event.EventMessage, &content)
}
func (h *HiClient) Send(ctx context.Context, roomID id.RoomID, evtType event.Type, content any) (*database.Event, error) {
roomMeta, err := h.DB.Room.Get(ctx, roomID)
if err != nil {