diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md index c213aa80b..233f5c0a3 100644 --- a/docs/channels/matrix/README.md +++ b/docs/channels/matrix/README.md @@ -22,7 +22,8 @@ Add this to `config.json`: "enabled": true, "text": "Thinking..." }, - "reasoning_channel_id": "" + "reasoning_channel_id": "", + "message_format": "richtext" } } } @@ -42,10 +43,12 @@ Add this to `config.json`: | group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) | | placeholder | object | No | Placeholder message config | | reasoning_channel_id | string | No | Target channel for reasoning output | +| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only | ## 3. Currently Supported -- Text message send/receive +- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.) +- Configurable message format (`richtext` / `plain`) - Incoming image/audio/video/file download (MediaStore first, local path fallback) - Incoming audio normalization into existing transcription flow (`[audio: ...]`) - Outgoing image/audio/video/file upload and send diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index d51eee8fb..a45207f12 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -13,6 +13,9 @@ import ( "sync" "time" + "github.com/gomarkdown/markdown" + mdhtml "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -268,6 +271,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } +func markdownToHTML(md string) string { + p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) + renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) + return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) +} + func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning @@ -283,16 +292,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } - _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - }) + _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return fmt.Errorf("matrix send: %w", channels.ErrTemporary) } return nil } +func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { + mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text} + if c.config.MessageFormat != "plain" { + mc.Format = event.FormatHTML + mc.FormattedBody = markdownToHTML(text) + } + return mc +} + // SendMedia implements channels.MediaSender. func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { @@ -482,10 +497,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return fmt.Errorf("matrix message ID is empty") } - editContent := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - } + editContent := c.messageContent(content) editContent.SetEdit(id.EventID(messageID)) _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent) diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index e76db0d3e..806a98739 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -4,12 +4,15 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestMatrixLocalpartMentionRegexp(t *testing.T) { @@ -289,3 +292,50 @@ func TestMatrixOutboundContent(t *testing.T) { t.Fatalf("unexpected fallback body: %q", noCaption.Body) } } + +func TestMarkdownToHTML(t *testing.T) { + tests := []struct { + name string + input string + contains string + }{ + {"bold", "**hello**", "hello"}, + {"italic", "_world_", "world"}, + {"header", "### Title", ""}, + {"inline code", "`x`", "x"}, + {"plain text", "just text", "just text"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := markdownToHTML(tt.input) + if !strings.Contains(got, tt.contains) { + t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains) + } + }) + } +} + +func TestMessageContent(t *testing.T) { + richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}} + plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}} + defaultt := &MatrixChannel{config: config.MatrixConfig{}} + + for _, c := range []*MatrixChannel{richtext, defaultt} { + mc := c.messageContent("**hi**") + if mc.Format != event.FormatHTML { + t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format) + } + if !strings.Contains(mc.FormattedBody, "hi") { + t.Errorf("format %q: FormattedBody %q missing ", c.config.MessageFormat, mc.FormattedBody) + } + if mc.Body != "**hi**" { + t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body) + } + } + + mc := plain.messageContent("**hi**") + if mc.Format != "" || mc.FormattedBody != "" { + t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 161290c64..d3b4dcc0c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -350,16 +350,17 @@ type SlackConfig struct { } type MatrixConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` - Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` - UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` - DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` - JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` + Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` + UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` + DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` + JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` + MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` } type LINEConfig struct {