feat(channels/matrix): add Matrix channel integration

Adds a full Matrix protocol channel using mautrix-go, enabling agents to
communicate over any Matrix homeserver (matrix.org, self-hosted Synapse,
Conduit, etc.).

Features:
- Text messages: Markdown → Matrix HTML (m.text / m.notice)
- Inbound voice: audio events passed through the Transcriber interface
  (Whisper or Groq) → text before reaching the agent
- Outbound media: m.image / m.audio / m.video / m.file events with
  proper MIME detection and Matrix content repository upload
- Outbound voice: works via voice=true on the message tool (TTS PR)
- Native typing indicator: PUT /typing instead of a placeholder message
- Group chat: configurable require_mention_in_group (default: true)
- invite handling: join_on_invite (default: true)
- allow_from filter: restrict to specific Matrix user IDs
- Historical event guard: events before process start are ignored

New files:
- pkg/channels/matrix.go: MatrixChannel implementation
- docs/MATRIX_SETUP.md: step-by-step setup guide

Config changes:
- pkg/config/config.go: MatrixConfig added to ChannelsConfig
- config/config.example.json: matrix, tts, and whisper example sections

Dependency: maunium.net/go/mautrix v0.26.3 (go.mod / go.sum updated)

Config example:
  "channels": {
    "matrix": {
      "enabled": true,
      "homeserver": "https://matrix.example.com",
      "user_id": "@bot:matrix.example.com",
      "access_token": "syt_...",
      "allow_from": [],
      "join_on_invite": true,
      "require_mention_in_group": true
    }
  }

Depends-on:
  - refactor(voice): introduce Transcriber interface
  - feat(voice/tts): add TTS synthesis and voice parameter on message tool
This commit is contained in:
Myka 2026-02-17 11:13:44 +03:00
parent b21ff3e6c9
commit 886d3088f2
8 changed files with 981 additions and 0 deletions

View file

@ -632,6 +632,12 @@ func gatewayCmd() {
logger.InfoC("voice", "Transcription attached to Slack channel")
}
}
if matrixChannel, ok := channelManager.GetChannel("matrix"); ok {
if mc, ok := matrixChannel.(*channels.MatrixChannel); ok {
mc.SetTranscriber(transcriber)
logger.InfoC("voice", "Transcription attached to Matrix channel")
}
}
}
// Attach TTS synthesis callbacks to the message tool (enables voice=true).

View file

@ -70,6 +70,16 @@
"reconnect_interval": 5,
"group_trigger_prefix": [],
"allow_from": []
},
"matrix": {
"enabled": false,
"homeserver": "https://matrix.example.com",
"user_id": "@bot:matrix.example.com",
"access_token": "syt_YOUR_ACCESS_TOKEN_HERE",
"device_id": "",
"allow_from": [],
"join_on_invite": true,
"require_mention_in_group": true
}
},
"providers": {

148
docs/MATRIX_SETUP.md Normal file
View file

@ -0,0 +1,148 @@
# Matrix Integration Setup
This guide shows you how to connect PicoClaw to a Matrix homeserver.
## Prerequisites
1. A Matrix account (e.g., @bot:matrix.org or @bot:matrix.example.com)
2. An access token for your Matrix bot account
## Getting a Matrix Access Token
### Method 1: Using Element Web Client
1. Log in to Element (https://app.element.io or your homeserver's web client)
2. Go to **Settings** → **Help & About**
3. Scroll down to **Advanced** section
4. Click on `<click to reveal>` next to **Access Token**
5. Copy the token (it starts with `syt_` or `MDAxOG...`)
### Method 2: Using curl
```bash
curl -X POST https://matrix.org/_matrix/client/r0/login \
-H "Content-Type: application/json" \
-d '{
"type": "m.login.password",
"user": "your_username",
"password": "your_password"
}'
```
The response will include an `access_token` field.
## Configuration
Edit your `~/.picoclaw/config.json`:
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"user_id": "@bot:matrix.org",
"access_token": "syt_YOUR_ACCESS_TOKEN_HERE",
"device_id": "",
"allow_from": [],
"join_on_invite": true,
"require_mention_in_group": true
}
}
}
```
### Configuration Options
- **`enabled`**: Set to `true` to enable Matrix integration
- **`homeserver`**: Your Matrix homeserver URL (e.g., `https://matrix.org`, `https://matrix.example.com`)
- **`user_id`**: Full Matrix user ID including homeserver (e.g., `@bot:matrix.org`)
- **`access_token`**: The access token obtained from your Matrix account
- **`device_id`**: (Optional) Specific device ID, leave empty to auto-generate
- **`allow_from`**: (Optional) List of Matrix user IDs allowed to interact with the bot. Empty array = allow all
- **`join_on_invite`**: Set to `true` to auto-join rooms when invited
- **`require_mention_in_group`**: (Default: `true`) Only respond in group chats (3+ members) when the bot is mentioned. Set to `false` to respond to all messages in groups
### Access Control Example
To restrict bot access to specific users:
```json
"allow_from": [
"@admin:matrix.org",
"@user1:example.com"
]
```
## Running PicoClaw with Matrix
```bash
picoclaw gateway
```
The bot will:
- Connect to the Matrix homeserver
- Auto-join any rooms it's invited to (if `join_on_invite: true`)
- Listen for messages and respond using the configured AI provider
## Testing
1. Invite your bot to a Matrix room or direct message
2. Send a message like "Hello!"
3. The bot should respond using your configured AI model
## Logs
Matrix-specific logs appear with the `[matrix]` component tag:
```
[INFO] matrix: Starting Matrix client...
[INFO] matrix: Auto-joining room after invite {room_id=!abc123:matrix.org}
[INFO] matrix: Successfully joined room {room_id=!abc123:matrix.org}
[INFO] matrix: Received message {sender=@user:matrix.org, room=Room Name, content=Hello!}
```
## Troubleshooting
### "Failed to create matrix client: M_UNKNOWN_TOKEN"
- Your access token is invalid or expired
- Regenerate the token and update config.json
### "Failed to join room: M_FORBIDDEN"
- The bot doesn't have permission to join
- Check room settings or reinvite the bot
### Bot doesn't respond
- Check `allow_from` configuration - empty array allows everyone
- Verify the AI provider is configured correctly in `agents.defaults.provider`
- Check logs for errors: `picoclaw gateway` will show detailed logs
## Security Notes
- **Never commit your access token to git!**
- Store `config.json` securely with restricted permissions (`chmod 600 ~/.picoclaw/config.json`)
- Consider using environment variables or secrets management for production deployments
- Matrix access tokens grant full account access - treat them like passwords
## Advanced: Using with Docker
Mount your config as a volume:
```bash
docker run -v ~/.picoclaw/config.json:/app/config.json picoclaw gateway
```
Or use environment variables:
```bash
docker run \
-e MATRIX_HOMESERVER=https://matrix.org \
-e MATRIX_USER_ID=@bot:matrix.org \
-e MATRIX_ACCESS_TOKEN=syt_... \
picoclaw gateway
```
---
**Last Updated:** February 16, 2026
**PicoClaw Version:** v0.1.1+

8
go.mod
View file

@ -21,9 +21,17 @@ require (
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
go.mau.fi/util v0.9.6 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mautrix v0.26.3 // indirect
)
require (

26
go.sum
View file

@ -1,4 +1,6 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
@ -25,6 +27,7 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -41,6 +44,7 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -88,6 +92,13 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@ -103,11 +114,15 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -149,6 +164,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
@ -161,6 +178,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -207,8 +226,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
@ -224,6 +246,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -259,3 +283,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=

View file

@ -176,6 +176,19 @@ func (m *Manager) initChannels() error {
}
}
if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.AccessToken != "" {
logger.DebugC("channels", "Attempting to initialize Matrix channel")
matrix, err := NewMatrixChannel(m.config.Channels.Matrix, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Matrix channel", map[string]interface{}{
"error": err.Error(),
})
} else {
m.channels["matrix"] = matrix
logger.InfoC("channels", "Matrix channel enabled successfully")
}
}
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
"enabled_channels": len(m.channels),
})

748
pkg/channels/matrix.go Normal file
View file

@ -0,0 +1,748 @@
package channels
import (
"context"
"fmt"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/voice"
)
type MatrixChannel struct {
*BaseChannel
client *mautrix.Client
matrixConfig config.MatrixConfig
syncer *mautrix.DefaultSyncer
stopSyncer context.CancelFunc
startTime time.Time // events before this timestamp are ignored (initial sync flood guard)
roomNames sync.Map // roomID -> room name
typing sync.Map // roomID -> bool (active typing indicator)
transcriber voice.Transcriber
}
func NewMatrixChannel(matrixCfg config.MatrixConfig, bus *bus.MessageBus) (*MatrixChannel, error) {
// Create Matrix client
client, err := mautrix.NewClient(matrixCfg.Homeserver, id.UserID(matrixCfg.UserID), matrixCfg.AccessToken)
if err != nil {
return nil, fmt.Errorf("failed to create matrix client: %w", err)
}
// Set device ID if provided
if matrixCfg.DeviceID != "" {
client.DeviceID = id.DeviceID(matrixCfg.DeviceID)
}
base := NewBaseChannel("matrix", matrixCfg, bus, matrixCfg.AllowFrom)
syncer := client.Syncer.(*mautrix.DefaultSyncer)
return &MatrixChannel{
BaseChannel: base,
client: client,
matrixConfig: matrixCfg,
syncer: syncer,
startTime: time.Now(),
roomNames: sync.Map{},
typing: sync.Map{},
transcriber: nil,
}, nil
}
func (c *MatrixChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber
}
func (c *MatrixChannel) Start(ctx context.Context) error {
logger.InfoC("matrix", "Starting Matrix client...")
// Set up event handlers
c.syncer.OnEventType(event.EventMessage, c.handleMessage)
c.syncer.OnEventType(event.StateMember, c.handleMemberEvent)
// Create a cancellable context for the syncer
syncCtx, cancel := context.WithCancel(ctx)
c.stopSyncer = cancel
// Start syncing in background
go func() {
err := c.client.SyncWithContext(syncCtx)
if err != nil && syncCtx.Err() == nil {
logger.ErrorCF("matrix", "Sync error", map[string]interface{}{
"error": err.Error(),
})
}
}()
c.setRunning(true)
logger.InfoC("matrix", "Matrix client started successfully")
return nil
}
func (c *MatrixChannel) Stop(ctx context.Context) error {
logger.InfoC("matrix", "Stopping Matrix client...")
if c.stopSyncer != nil {
c.stopSyncer()
}
c.setRunning(false)
logger.InfoC("matrix", "Matrix client stopped")
return nil
}
func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) {
memberEvt := evt.Content.AsMember()
// Auto-join rooms if invited and JoinOnInvite is enabled
if memberEvt.Membership == event.MembershipInvite &&
evt.GetStateKey() == string(c.client.UserID) &&
c.matrixConfig.JoinOnInvite {
roomID := evt.RoomID
logger.InfoCF("matrix", "Auto-joining room after invite", map[string]interface{}{
"room_id": roomID.String(),
})
_, err := c.client.JoinRoomByID(ctx, roomID)
if err != nil {
logger.ErrorCF("matrix", "Failed to join room", map[string]interface{}{
"room_id": roomID.String(),
"error": err.Error(),
})
} else {
logger.InfoCF("matrix", "Successfully joined room", map[string]interface{}{
"room_id": roomID.String(),
})
}
}
}
func (c *MatrixChannel) handleMessage(ctx context.Context, evt *event.Event) {
// Ignore our own messages
if evt.Sender == c.client.UserID {
return
}
// Ignore historical events delivered on initial sync (flood guard).
// Matrix timestamps are in milliseconds.
if time.UnixMilli(evt.Timestamp).Before(c.startTime) {
logger.DebugCF("matrix", "Ignoring historical event", map[string]interface{}{
"event_id": evt.ID.String(),
"event_ts": evt.Timestamp,
"start_ts": c.startTime.UnixMilli(),
})
return
}
msgEvt := evt.Content.AsMessage()
roomID := evt.RoomID.String()
senderID := evt.Sender.String()
// Ignore edit events (m.replace relations)
if msgEvt.RelatesTo != nil && msgEvt.RelatesTo.Type == event.RelReplace {
return
}
// Check if sender is allowed
if !c.IsAllowed(senderID) {
logger.WarnCF("matrix", "Ignoring message from unauthorized user", map[string]interface{}{
"sender_id": senderID,
})
return
}
// Get or cache room name
roomName := c.getRoomName(ctx, evt.RoomID)
// Get sender display name
senderName := c.getUserDisplayName(ctx, evt.RoomID, evt.Sender)
messageText := msgEvt.Body
mediaPaths := []string{}
localFiles := []string{}
// Clean up temp files when done
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("matrix", "Failed to cleanup temp file", map[string]interface{}{
"file": file,
"error": err.Error(),
})
}
}
}()
// Handle different message types
switch msgEvt.MsgType {
case event.MsgText:
// Text already in messageText
case event.MsgImage:
// Download and process image
if msgEvt.URL != "" {
imagePath := c.downloadMedia(ctx, msgEvt.URL, msgEvt.Body, ".jpg")
if imagePath != "" {
localFiles = append(localFiles, imagePath)
mediaPaths = append(mediaPaths, imagePath)
if messageText != "" {
messageText += "\n"
}
messageText += fmt.Sprintf("[image: %s]", msgEvt.Body)
}
}
case event.MsgAudio, event.MsgVideo:
// Download and transcribe audio/video
if msgEvt.URL != "" {
ext := ".ogg"
if msgEvt.MsgType == event.MsgVideo {
ext = ".mp4"
}
mediaPath := c.downloadMedia(ctx, msgEvt.URL, msgEvt.Body, ext)
if mediaPath != "" {
localFiles = append(localFiles, mediaPath)
mediaPaths = append(mediaPaths, mediaPath)
// Try transcription for audio/video
transcribedText := ""
if c.transcriber != nil && c.transcriber.IsAvailable() {
tCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, err := c.transcriber.Transcribe(tCtx, mediaPath)
if err != nil {
logger.ErrorCF("matrix", "Transcription failed", map[string]interface{}{
"error": err.Error(),
"path": mediaPath,
})
transcribedText = fmt.Sprintf("[%s (transcription failed)]", msgEvt.MsgType)
} else {
transcribedText = fmt.Sprintf("[%s transcription: %s]", msgEvt.MsgType, result.Text)
logger.InfoCF("matrix", "Media transcribed successfully", map[string]interface{}{
"type": msgEvt.MsgType,
"text": result.Text,
})
}
} else {
transcribedText = fmt.Sprintf("[%s: %s]", msgEvt.MsgType, msgEvt.Body)
}
if messageText != "" {
messageText += "\n"
}
messageText += transcribedText
}
}
case event.MsgFile:
// Download generic file
if msgEvt.URL != "" {
filePath := c.downloadMedia(ctx, msgEvt.URL, msgEvt.Body, "")
if filePath != "" {
localFiles = append(localFiles, filePath)
mediaPaths = append(mediaPaths, filePath)
if messageText != "" {
messageText += "\n"
}
messageText += fmt.Sprintf("[file: %s]", msgEvt.Body)
}
}
default:
// Unsupported message type
logger.DebugCF("matrix", "Ignoring unsupported message type", map[string]interface{}{
"type": msgEvt.MsgType,
})
return
}
logger.InfoCF("matrix", "Received message", map[string]interface{}{
"sender": senderName,
"room": roomName,
"content": messageText,
"type": msgEvt.MsgType,
})
// Check if it's a group chat
memberCount := c.getRoomMemberCount(ctx, evt.RoomID)
isGroup := memberCount > 2
// In group chats, check mention requirement
if isGroup && c.matrixConfig.RequireMentionInGroup {
mentioned := c.isBotMentioned(msgEvt, c.client.UserID)
if !mentioned {
logger.InfoCF("matrix", "Ignoring group message (not mentioned)", map[string]interface{}{
"room": roomName,
"sender": senderName,
})
return
}
logger.InfoCF("matrix", "Bot mentioned in group chat", map[string]interface{}{
"room": roomName,
"sender": senderName,
})
// Remove the mention from the message text
messageText = c.removeMention(messageText, c.client.UserID)
}
// Show typing indicator (native Matrix — no message sent)
if _, err := c.client.UserTyping(ctx, evt.RoomID, true, 60*time.Second); err != nil {
logger.WarnCF("matrix", "Failed to send typing indicator", map[string]interface{}{
"error": err.Error(),
})
} else {
c.typing.Store(roomID, true)
}
// Prepare metadata
metadata := map[string]string{
"sender_name": senderName,
"room_name": roomName,
"timestamp": fmt.Sprintf("%d", evt.Timestamp),
}
if isGroup {
metadata["is_group_chat"] = "true"
}
// Check for reply-to
replyToID := c.getReplyToID(msgEvt)
if replyToID != "" {
metadata["reply_to_msg_id"] = replyToID
}
// Handle the message through base channel
c.HandleMessage(senderID, roomID, messageText, mediaPaths, metadata)
}
// ─── Send (outbound) ──────────────────────────────────────────────────────────
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
roomID := id.RoomID(msg.ChatID)
// Always clear the typing indicator first
if _, active := c.typing.LoadAndDelete(msg.ChatID); active {
if _, err := c.client.UserTyping(ctx, roomID, false, 0); err != nil {
logger.WarnCF("matrix", "Failed to clear typing indicator", map[string]interface{}{
"error": err.Error(),
})
}
}
// 1. Send any media files (each as its own Matrix event)
for _, mediaPath := range msg.Media {
if err := c.sendMediaFile(ctx, roomID, mediaPath); err != nil {
logger.ErrorCF("matrix", "Failed to send media file", map[string]interface{}{
"error": err.Error(),
"path": mediaPath,
})
}
}
// 2. Send text content
if msg.Content != "" {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: msg.Content,
}
if hasMarkdown(msg.Content) {
content.Format = event.FormatHTML
content.FormattedBody = markdownToMatrixHTML(msg.Content)
}
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, content)
if err != nil {
return fmt.Errorf("failed to send matrix message: %w", err)
}
logger.InfoCF("matrix", "Sent message to room", map[string]interface{}{
"chat_id": msg.ChatID,
})
}
return nil
}
// ─── Media upload helpers ─────────────────────────────────────────────────────
// sendMediaFile uploads a local file to the Matrix content repository and sends
// it as an appropriate Matrix event (m.image, m.audio, m.video, or m.file).
func (c *MatrixChannel) sendMediaFile(ctx context.Context, roomID id.RoomID, filePath string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read media file %q: %w", filePath, err)
}
mimeType := detectMIMEType(filePath, data)
fileName := filepath.Base(filePath)
logger.InfoCF("matrix", "Uploading media to content repo", map[string]interface{}{
"path": filePath,
"mime_type": mimeType,
"size": len(data),
})
resp, err := c.client.UploadMedia(ctx, mautrix.ReqUploadMedia{
ContentBytes: data,
ContentType: mimeType,
FileName: fileName,
})
if err != nil {
return fmt.Errorf("failed to upload media to Matrix: %w", err)
}
mxcURI := resp.ContentURI.CUString()
// Determine event type based on MIME category
msgType := mimeToMsgType(mimeType)
content := &event.MessageEventContent{
MsgType: msgType,
Body: fileName,
URL: mxcURI,
Info: &event.FileInfo{
MimeType: mimeType,
Size: len(data),
},
}
_, err = c.client.SendMessageEvent(ctx, roomID, event.EventMessage, content)
if err != nil {
return fmt.Errorf("failed to send media event: %w", err)
}
logger.InfoCF("matrix", "Media sent successfully", map[string]interface{}{
"room_id": roomID.String(),
"msg_type": msgType,
"mime_type": mimeType,
"mxc_uri": string(mxcURI),
})
return nil
}
// detectMIMEType guesses the MIME type using the file extension first,
// then falls back to sniffing the first 512 bytes.
func detectMIMEType(filePath string, data []byte) string {
// Try extension first (most reliable for known formats)
ext := strings.ToLower(filepath.Ext(filePath))
if ext != "" {
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
// Strip parameters (e.g. "text/plain; charset=utf-8" → "text/plain")
if idx := strings.Index(mimeType, ";"); idx > 0 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
return mimeType
}
}
// Fallback: sniff content
if len(data) > 0 {
sniff := data
if len(sniff) > 512 {
sniff = sniff[:512]
}
return http.DetectContentType(sniff)
}
return "application/octet-stream"
}
// mimeToMsgType maps a MIME type to the appropriate Matrix message type.
func mimeToMsgType(mimeType string) event.MessageType {
base := mimeType
if idx := strings.Index(mimeType, "/"); idx > 0 {
base = mimeType[:idx]
}
switch base {
case "image":
return event.MsgImage
case "audio":
return event.MsgAudio
case "video":
return event.MsgVideo
default:
return event.MsgFile
}
}
// ─── Room/user helpers ────────────────────────────────────────────────────────
func (c *MatrixChannel) getRoomName(ctx context.Context, roomID id.RoomID) string {
// Check cache first
if cached, ok := c.roomNames.Load(roomID.String()); ok {
return cached.(string)
}
// Fetch room name from state event
var nameEvt event.RoomNameEventContent
err := c.client.StateEvent(ctx, roomID, event.StateRoomName, "", &nameEvt)
if err == nil && nameEvt.Name != "" {
c.roomNames.Store(roomID.String(), nameEvt.Name)
return nameEvt.Name
}
// Fallback to room ID
roomName := roomID.String()
c.roomNames.Store(roomID.String(), roomName)
return roomName
}
func (c *MatrixChannel) getUserDisplayName(ctx context.Context, roomID id.RoomID, userID id.UserID) string {
resp, err := c.client.GetDisplayName(ctx, userID)
if err == nil && resp.DisplayName != "" {
return resp.DisplayName
}
return userID.String()
}
func (c *MatrixChannel) getRoomMemberCount(ctx context.Context, roomID id.RoomID) int {
resp, err := c.client.JoinedMembers(ctx, roomID)
if err != nil {
return 0
}
return len(resp.Joined)
}
func (c *MatrixChannel) getReplyToID(msgEvt *event.MessageEventContent) string {
if msgEvt.RelatesTo != nil && msgEvt.RelatesTo.InReplyTo != nil {
return msgEvt.RelatesTo.InReplyTo.EventID.String()
}
return ""
}
func (c *MatrixChannel) isBotMentioned(msgEvt *event.MessageEventContent, botUserID id.UserID) bool {
// Full Matrix ID mention (e.g. @bot:homeserver)
if strings.Contains(msgEvt.Body, botUserID.String()) {
return true
}
// Formatted (HTML) body mention
if msgEvt.Format == event.FormatHTML && strings.Contains(msgEvt.FormattedBody, botUserID.String()) {
return true
}
// Localpart mention (e.g. "wanda")
localpart := strings.TrimPrefix(botUserID.String(), "@")
localpart = strings.Split(localpart, ":")[0]
if strings.Contains(strings.ToLower(msgEvt.Body), strings.ToLower(localpart)) {
return true
}
return false
}
func (c *MatrixChannel) removeMention(text string, botUserID id.UserID) string {
// Remove full ID (@user:homeserver)
text = strings.ReplaceAll(text, botUserID.String(), "")
// Remove localpart with @ prefix
localpart := strings.TrimPrefix(botUserID.String(), "@")
localpart = strings.Split(localpart, ":")[0]
text = strings.ReplaceAll(text, "@"+localpart, "")
// Remove bare localpart at start/end of message
text = strings.TrimPrefix(text, localpart)
text = strings.TrimSuffix(text, localpart)
return strings.TrimSpace(text)
}
// ─── Inbound media download ───────────────────────────────────────────────────
func (c *MatrixChannel) downloadMedia(ctx context.Context, mxcURL id.ContentURIString, filename, ext string) string {
if mxcURL == "" {
return ""
}
contentURI := mxcURL.ParseOrIgnore()
if contentURI.IsEmpty() {
logger.ErrorCF("matrix", "Invalid media URL", map[string]interface{}{
"mxc_url": string(mxcURL),
})
return ""
}
logger.DebugCF("matrix", "Downloading media", map[string]interface{}{
"mxc_url": string(mxcURL),
"filename": filename,
})
data, err := c.client.DownloadBytes(ctx, contentURI)
if err != nil {
logger.ErrorCF("matrix", "Failed to download media", map[string]interface{}{
"error": err.Error(),
"mxc_url": string(mxcURL),
})
return ""
}
// Determine file extension
if ext == "" {
if strings.Contains(filename, ".") {
parts := strings.Split(filename, ".")
ext = "." + parts[len(parts)-1]
} else {
ext = ".bin"
}
}
// Write to temp file
tempFile, err := os.CreateTemp("", "matrix-media-*"+ext)
if err != nil {
logger.ErrorCF("matrix", "Failed to create temp file", map[string]interface{}{
"error": err.Error(),
})
return ""
}
defer tempFile.Close()
if _, err := tempFile.Write(data); err != nil {
logger.ErrorCF("matrix", "Failed to write media file", map[string]interface{}{
"error": err.Error(),
})
os.Remove(tempFile.Name())
return ""
}
logger.InfoCF("matrix", "Media downloaded successfully", map[string]interface{}{
"path": tempFile.Name(),
"size": len(data),
})
return tempFile.Name()
}
// ─── Markdown → Matrix HTML ───────────────────────────────────────────────────
// hasMarkdown returns true if the text contains common Markdown syntax.
func hasMarkdown(text string) bool {
return strings.ContainsAny(text, "*_`#[~")
}
// markdownToMatrixHTML converts a subset of Markdown to Matrix-compatible HTML.
// Matrix supports: <strong>, <em>, <code>, <pre>, <del>, <h1>-<h6>, <a>, <ul>, <li>, <blockquote>
func markdownToMatrixHTML(text string) string {
if text == "" {
return ""
}
// 1. Extract and protect code blocks (```...```) before other processing
type codeBlock struct{ lang, code string }
var codeBlocks []codeBlock
reCodeBlock := regexp.MustCompile("(?s)```([a-zA-Z0-9]*)\n?(.*?)```")
text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
match := reCodeBlock.FindStringSubmatch(m)
lang, code := "", ""
if len(match) >= 3 {
lang = match[1]
code = match[2]
}
placeholder := fmt.Sprintf("\x00CB%d\x00", len(codeBlocks))
codeBlocks = append(codeBlocks, codeBlock{lang, code})
return placeholder
})
// 2. Extract and protect inline code (`...`)
var inlineCodes []string
reInlineCode := regexp.MustCompile("`([^`]+)`")
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
match := reInlineCode.FindStringSubmatch(m)
code := ""
if len(match) >= 2 {
code = match[1]
}
placeholder := fmt.Sprintf("\x00IC%d\x00", len(inlineCodes))
inlineCodes = append(inlineCodes, code)
return placeholder
})
// 3. Escape HTML special characters in non-code content
text = matrixEscapeHTML(text)
// 4. Bold: **text** or __text__
reBold := regexp.MustCompile(`\*\*(.+?)\*\*`)
text = reBold.ReplaceAllString(text, "<strong>$1</strong>")
reBold2 := regexp.MustCompile(`__(.+?)__`)
text = reBold2.ReplaceAllString(text, "<strong>$1</strong>")
// 5. Italic: *text* or _text_ (single, not double)
reItalic := regexp.MustCompile(`\*([^*\n]+)\*`)
text = reItalic.ReplaceAllString(text, "<em>$1</em>")
reItalic2 := regexp.MustCompile(`_([^_\n]+)_`)
text = reItalic2.ReplaceAllString(text, "<em>$1</em>")
// 6. Strikethrough: ~~text~~
reStrike := regexp.MustCompile(`~~(.+?)~~`)
text = reStrike.ReplaceAllString(text, "<del>$1</del>")
// 7. Links: [label](url)
reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
// 8. Headings: # H1, ## H2, etc. (line by line)
reHeading := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
text = reHeading.ReplaceAllStringFunc(text, func(m string) string {
match := reHeading.FindStringSubmatch(m)
if len(match) < 3 {
return m
}
level := len(match[1])
return fmt.Sprintf("<h%d>%s</h%d>", level, match[2], level)
})
// 9. Blockquotes: > text
reQuote := regexp.MustCompile(`(?m)^>\s?(.*)$`)
text = reQuote.ReplaceAllString(text, "<blockquote>$1</blockquote>")
// 10. Unordered list items: - item or * item
reList := regexp.MustCompile(`(?m)^[-*]\s+(.+)$`)
text = reList.ReplaceAllString(text, "<li>$1</li>")
// 11. Newlines → <br> (preserve formatting)
text = strings.ReplaceAll(text, "\n", "<br>\n")
// 12. Restore inline code
for i, code := range inlineCodes {
escaped := matrixEscapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
}
// 13. Restore code blocks
for i, cb := range codeBlocks {
escaped := matrixEscapeHTML(cb.code)
if cb.lang != "" {
text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i),
fmt.Sprintf("<pre><code class=\"language-%s\">%s</code></pre>", cb.lang, escaped))
} else {
text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i),
fmt.Sprintf("<pre><code>%s</code></pre>", escaped))
}
}
return text
}
// matrixEscapeHTML escapes the three HTML special characters.
func matrixEscapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}

View file

@ -79,6 +79,18 @@ type ChannelsConfig struct {
Slack SlackConfig `json:"slack"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
Matrix MatrixConfig `json:"matrix"`
}
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" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
RequireMentionInGroup bool `json:"require_mention_in_group" env:"PICOCLAW_CHANNELS_MATRIX_REQUIRE_MENTION_IN_GROUP"`
}
type WhatsAppConfig struct {
@ -311,6 +323,16 @@ func DefaultConfig() *Config {
GroupTriggerPrefix: []string{},
AllowFrom: FlexibleStringSlice{},
},
Matrix: MatrixConfig{
Enabled: false,
Homeserver: "https://matrix.org",
UserID: "",
AccessToken: "",
DeviceID: "",
AllowFrom: FlexibleStringSlice{},
JoinOnInvite: true,
RequireMentionInGroup: true,
},
},
Providers: ProvidersConfig{
Anthropic: ProviderConfig{},