Merge pull request #1220 from horsley/feat/matrix-channel-support

feat: add Matrix channel support
This commit is contained in:
美電球
2026-03-08 22:58:16 +08:00
committed by GitHub
22 changed files with 1818 additions and 8 deletions
+8
View File
@@ -61,6 +61,7 @@ var channelRateConfig = map[string]float64{
"telegram": 20,
"discord": 1,
"slack": 1,
"matrix": 2,
"line": 10,
"irc": 2,
}
@@ -244,6 +245,13 @@ func (m *Manager) initChannels() error {
m.initChannel("slack", "Slack")
}
if m.config.Channels.Matrix.Enabled &&
m.config.Channels.Matrix.Homeserver != "" &&
m.config.Channels.Matrix.UserID != "" &&
m.config.Channels.Matrix.AccessToken != "" {
m.initChannel("matrix", "Matrix")
}
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
m.initChannel("line", "LINE")
}
+13
View File
@@ -0,0 +1,13 @@
package matrix
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewMatrixChannel(cfg.Channels.Matrix, b)
})
}
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
package matrix
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
re := localpartMentionRegexp("picoclaw")
cases := []struct {
text string
want bool
}{
{text: "@picoclaw hello", want: true},
{text: "hi @picoclaw:matrix.org", want: true},
{
text: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
want: false, // historical false-positive case in PR #356
},
{text: "mail test@example.com", want: false},
}
for _, tc := range cases {
if got := re.MatchString(tc.text); got != tc.want {
t.Fatalf("text=%q match=%v want=%v", tc.text, got, tc.want)
}
}
}
func TestStripUserMention(t *testing.T) {
userID := id.UserID("@picoclaw:matrix.org")
cases := []struct {
in string
want string
}{
{in: "@picoclaw:matrix.org hello", want: "hello"},
{in: "@picoclaw, hello", want: "hello"},
{in: "no mention here", want: "no mention here"},
}
for _, tc := range cases {
if got := stripUserMention(tc.in, userID); got != tc.want {
t.Fatalf("stripUserMention(%q)=%q want=%q", tc.in, got, tc.want)
}
}
}
func TestIsBotMentioned(t *testing.T) {
ch := &MatrixChannel{
client: &mautrix.Client{
UserID: id.UserID("@picoclaw:matrix.org"),
},
}
cases := []struct {
name string
msg event.MessageEventContent
want bool
}{
{
name: "mentions field",
msg: event.MessageEventContent{
Body: "hello",
Mentions: &event.Mentions{
UserIDs: []id.UserID{id.UserID("@picoclaw:matrix.org")},
},
},
want: true,
},
{
name: "full user id in body",
msg: event.MessageEventContent{
Body: "@picoclaw:matrix.org hello",
},
want: true,
},
{
name: "localpart with at sign",
msg: event.MessageEventContent{
Body: "@picoclaw hello",
},
want: true,
},
{
name: "localpart without at sign should not match",
msg: event.MessageEventContent{
Body: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
},
want: false,
},
{
name: "formatted mention href matrix.to plain",
msg: event.MessageEventContent{
Body: "hello bot",
FormattedBody: `<a href="https://matrix.to/#/@picoclaw:matrix.org">PicoClaw</a> hello`,
},
want: true,
},
{
name: "formatted mention href matrix.to encoded",
msg: event.MessageEventContent{
Body: "hello bot",
FormattedBody: `<a href="https://matrix.to/#/%40picoclaw%3Amatrix.org">PicoClaw</a> hello`,
},
want: true,
},
}
for _, tc := range cases {
if got := ch.isBotMentioned(&tc.msg); got != tc.want {
t.Fatalf("%s: got=%v want=%v", tc.name, got, tc.want)
}
}
}
func TestRoomKindCache_ExpiresEntries(t *testing.T) {
cache := newRoomKindCache(4, 5*time.Second)
now := time.Unix(100, 0)
cache.set("!room:matrix.org", true, now)
if got, ok := cache.get("!room:matrix.org", now.Add(2*time.Second)); !ok || !got {
t.Fatalf("expected cached group room before ttl, got ok=%v group=%v", ok, got)
}
if _, ok := cache.get("!room:matrix.org", now.Add(6*time.Second)); ok {
t.Fatal("expected cache miss after ttl expiry")
}
}
func TestRoomKindCache_EvictsOldestWhenFull(t *testing.T) {
cache := newRoomKindCache(2, time.Minute)
now := time.Unix(200, 0)
cache.set("!room1:matrix.org", false, now)
cache.set("!room2:matrix.org", false, now.Add(1*time.Second))
cache.set("!room3:matrix.org", true, now.Add(2*time.Second))
if _, ok := cache.get("!room1:matrix.org", now.Add(2*time.Second)); ok {
t.Fatal("expected oldest cache entry to be evicted")
}
if got, ok := cache.get("!room2:matrix.org", now.Add(2*time.Second)); !ok || got {
t.Fatalf("expected room2 to remain and be direct, got ok=%v group=%v", ok, got)
}
if got, ok := cache.get("!room3:matrix.org", now.Add(2*time.Second)); !ok || !got {
t.Fatalf("expected room3 to remain and be group, got ok=%v group=%v", ok, got)
}
}
func TestMatrixMediaTempDir(t *testing.T) {
dir, err := matrixMediaTempDir()
if err != nil {
t.Fatalf("matrixMediaTempDir failed: %v", err)
}
if filepath.Base(dir) != matrixMediaTempDirName {
t.Fatalf("unexpected media dir base: %q", filepath.Base(dir))
}
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("media dir not created: %v", err)
}
if !info.IsDir() {
t.Fatalf("expected directory, got mode=%v", info.Mode())
}
}
func TestMatrixMediaExt(t *testing.T) {
if got := matrixMediaExt("photo.png", "", "image"); got != ".png" {
t.Fatalf("filename extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "image/webp", "image"); got != ".webp" {
t.Fatalf("content-type extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "", "image"); got != ".jpg" {
t.Fatalf("default image extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "", "audio"); got != ".ogg" {
t.Fatalf("default audio extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "", "video"); got != ".mp4" {
t.Fatalf("default video extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "", "file"); got != ".bin" {
t.Fatalf("default file extension mismatch: got=%q", got)
}
}
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
ch := &MatrixChannel{}
msg := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "test.png",
}
content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
if !ok {
t.Fatal("expected ok for image fallback")
}
if content != "[image: test.png]" {
t.Fatalf("unexpected content: %q", content)
}
if len(mediaRefs) != 0 {
t.Fatalf("expected no media refs, got %d", len(mediaRefs))
}
}
func TestExtractInboundContent_AudioNoURLFallback(t *testing.T) {
ch := &MatrixChannel{}
msg := &event.MessageEventContent{
MsgType: event.MsgAudio,
FileName: "voice.ogg",
Body: "please transcribe",
}
content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
if !ok {
t.Fatal("expected ok for audio fallback")
}
if content != "please transcribe\n[audio: voice.ogg]" {
t.Fatalf("unexpected content: %q", content)
}
if len(mediaRefs) != 0 {
t.Fatalf("expected no media refs, got %d", len(mediaRefs))
}
}
func TestMatrixOutboundMsgType(t *testing.T) {
cases := []struct {
name string
partType string
filename string
contentType string
want event.MessageType
}{
{name: "explicit image", partType: "image", want: event.MsgImage},
{name: "explicit audio", partType: "audio", want: event.MsgAudio},
{name: "mime fallback video", contentType: "video/mp4", want: event.MsgVideo},
{name: "extension fallback audio", filename: "voice.ogg", want: event.MsgAudio},
{name: "unknown defaults file", filename: "report.txt", want: event.MsgFile},
}
for _, tc := range cases {
if got := matrixOutboundMsgType(tc.partType, tc.filename, tc.contentType); got != tc.want {
t.Fatalf("%s: got=%q want=%q", tc.name, got, tc.want)
}
}
}
func TestMatrixOutboundContent(t *testing.T) {
content := matrixOutboundContent(
"please review",
"voice.ogg",
event.MsgAudio,
"audio/ogg",
1234,
id.ContentURIString("mxc://matrix.org/abc"),
)
if content.Body != "please review" {
t.Fatalf("unexpected body: %q", content.Body)
}
if content.FileName != "voice.ogg" {
t.Fatalf("unexpected filename: %q", content.FileName)
}
if content.Info == nil || content.Info.MimeType != "audio/ogg" {
t.Fatalf("unexpected content type: %+v", content.Info)
}
if content.Info == nil || content.Info.Size != 1234 {
t.Fatalf("unexpected size: %+v", content.Info)
}
noCaption := matrixOutboundContent(
"",
"image.png",
event.MsgImage,
"image/png",
0,
id.ContentURIString("mxc://matrix.org/def"),
)
if noCaption.Body != "image.png" {
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
}
}
+14
View File
@@ -225,6 +225,7 @@ type ChannelsConfig struct {
QQ QQConfig `json:"qq"`
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
@@ -333,6 +334,19 @@ type SlackConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
}
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"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+3
View File
@@ -283,6 +283,9 @@ func TestDefaultConfig_Channels(t *testing.T) {
if cfg.Channels.Slack.Enabled {
t.Error("Slack should be disabled by default")
}
if cfg.Channels.Matrix.Enabled {
t.Error("Matrix should be disabled by default")
}
}
// TestDefaultConfig_WebTools verifies web tools config
+16
View File
@@ -97,6 +97,22 @@ func DefaultConfig() *Config {
AppToken: "",
AllowFrom: FlexibleStringSlice{},
},
Matrix: MatrixConfig{
Enabled: false,
Homeserver: "https://matrix.org",
UserID: "",
AccessToken: "",
DeviceID: "",
JoinOnInvite: true,
AllowFrom: FlexibleStringSlice{},
GroupTrigger: GroupTriggerConfig{
MentionOnly: true,
},
Placeholder: PlaceholderConfig{
Enabled: true,
Text: "Thinking... 💭",
},
},
LINE: LINEConfig{
Enabled: false,
ChannelSecret: "",
+1
View File
@@ -22,6 +22,7 @@ var supportedChannels = map[string]bool{
"qq": true,
"dingtalk": true,
"slack": true,
"matrix": true,
"line": true,
"onebot": true,
"wecom": true,
@@ -371,6 +371,8 @@ func (c *OpenClawConfig) IsChannelEnabled(name string) bool {
return c.Channels.Discord == nil || c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled
case "slack":
return c.Channels.Slack == nil || c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled
case "matrix":
return c.Channels.Matrix == nil || c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled
case "whatsapp":
return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled
case "feishu":
@@ -397,6 +399,11 @@ func GetChannelAllowFrom(ch any) []string {
return nil
}
return c.AllowFrom
case *OpenClawMatrixConfig:
if c == nil {
return nil
}
return c.AllowFrom
case *OpenClawWhatsAppConfig:
if c == nil {
return nil
@@ -627,6 +634,7 @@ type ChannelsConfig struct {
QQ QQConfig `json:"qq"`
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"`
}
@@ -687,6 +695,14 @@ type SlackConfig struct {
AllowFrom []string `json:"allow_from"`
}
type MatrixConfig struct {
Enabled bool `json:"enabled"`
Homeserver string `json:"homeserver"`
UserID string `json:"user_id"`
AccessToken string `json:"access_token"`
AllowFrom []string `json:"allow_from"`
}
type LINEConfig struct {
Enabled bool `json:"enabled"`
ChannelSecret string `json:"channel_secret"`
@@ -862,12 +878,26 @@ func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig {
}
}
if c.Channels.Matrix != nil && supportedChannels["matrix"] {
enabled := c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled
channels.Matrix = MatrixConfig{
Enabled: enabled,
AllowFrom: c.Channels.Matrix.AllowFrom,
}
if c.Channels.Matrix.Homeserver != nil {
channels.Matrix.Homeserver = *c.Channels.Matrix.Homeserver
}
if c.Channels.Matrix.UserID != nil {
channels.Matrix.UserID = *c.Channels.Matrix.UserID
}
if c.Channels.Matrix.AccessToken != nil {
channels.Matrix.AccessToken = *c.Channels.Matrix.AccessToken
}
}
if c.Channels.Signal != nil {
*warnings = append(*warnings, "Channel 'signal': No PicoClaw adapter available")
}
if c.Channels.Matrix != nil {
*warnings = append(*warnings, "Channel 'matrix': No PicoClaw adapter available")
}
if c.Channels.IRC != nil {
*warnings = append(*warnings, "Channel 'irc': No PicoClaw adapter available")
}
@@ -1020,6 +1050,14 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
BotToken: c.Slack.BotToken,
AppToken: c.Slack.AppToken,
},
Matrix: config.MatrixConfig{
Enabled: c.Matrix.Enabled,
Homeserver: c.Matrix.Homeserver,
UserID: c.Matrix.UserID,
AccessToken: c.Matrix.AccessToken,
AllowFrom: c.Matrix.AllowFrom,
JoinOnInvite: true,
},
LINE: config.LINEConfig{
Enabled: c.LINE.Enabled,
ChannelSecret: c.LINE.ChannelSecret,
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -375,6 +376,96 @@ func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
}
}
func TestConvertToPicoClawWithMatrix(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
testConfig := `{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.example.com",
"userId": "@bot:matrix.example.com",
"accessToken": "syt_test_token",
"allowFrom": ["@alice:matrix.example.com"]
}
}
}`
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
cfg, err := LoadOpenClawConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
picoCfg, warnings, err := cfg.ConvertToPicoClaw("")
if err != nil {
t.Fatalf("failed to convert config: %v", err)
}
if !picoCfg.Channels.Matrix.Enabled {
t.Error("matrix should be enabled")
}
if picoCfg.Channels.Matrix.Homeserver != "https://matrix.example.com" {
t.Errorf("expected matrix homeserver, got %q", picoCfg.Channels.Matrix.Homeserver)
}
if picoCfg.Channels.Matrix.UserID != "@bot:matrix.example.com" {
t.Errorf("expected matrix user_id, got %q", picoCfg.Channels.Matrix.UserID)
}
if picoCfg.Channels.Matrix.AccessToken != "syt_test_token" {
t.Errorf("expected matrix access_token, got %q", picoCfg.Channels.Matrix.AccessToken)
}
if len(picoCfg.Channels.Matrix.AllowFrom) != 1 ||
picoCfg.Channels.Matrix.AllowFrom[0] != "@alice:matrix.example.com" {
t.Errorf("unexpected matrix allow_from: %#v", picoCfg.Channels.Matrix.AllowFrom)
}
for _, w := range warnings {
if strings.Contains(w, "Channel 'matrix'") {
t.Fatalf("matrix should no longer be reported as unsupported, warning=%q", w)
}
}
}
func TestConvertToPicoClawWithMatrixDisabled(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
testConfig := `{
"channels": {
"matrix": {
"enabled": false,
"homeserver": "https://matrix.example.com",
"userId": "@bot:matrix.example.com",
"accessToken": "syt_test_token"
}
}
}`
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
cfg, err := LoadOpenClawConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
picoCfg, _, err := cfg.ConvertToPicoClaw("")
if err != nil {
t.Fatalf("failed to convert config: %v", err)
}
if picoCfg.Channels.Matrix.Enabled {
t.Error("matrix should respect enabled=false from source config")
}
}
func TestOpenClawAgentModel(t *testing.T) {
model := &OpenClawAgentModel{
Primary: strPtr("anthropic/claude-3-opus"),
@@ -425,6 +516,9 @@ func TestChannelEnabled(t *testing.T) {
if !cfg.IsChannelEnabled("slack") {
t.Error("slack should be enabled (explicitly set)")
}
if !cfg.IsChannelEnabled("matrix") {
t.Error("matrix should be enabled (nil config defaults to enabled)")
}
if cfg.IsChannelEnabled("line") {
t.Error("line should return false (not in switch cases)")
}