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:
parent
b21ff3e6c9
commit
886d3088f2
8 changed files with 981 additions and 0 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
148
docs/MATRIX_SETUP.md
Normal 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
8
go.mod
|
|
@ -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
26
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
748
pkg/channels/matrix.go
Normal 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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
|
@ -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{},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue