From 886d3088f258f78aae2cf059cd2b264ddec40867 Mon Sep 17 00:00:00 2001 From: Myka Date: Tue, 17 Feb 2026 11:13:44 +0300 Subject: [PATCH] feat(channels/matrix): add Matrix channel integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/picoclaw/main.go | 6 + config/config.example.json | 10 + docs/MATRIX_SETUP.md | 148 ++++++++ go.mod | 8 + go.sum | 26 ++ pkg/channels/manager.go | 13 + pkg/channels/matrix.go | 748 +++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 22 ++ 8 files changed, 981 insertions(+) create mode 100644 docs/MATRIX_SETUP.md create mode 100644 pkg/channels/matrix.go diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 60e7ad98e..c3c41cd20 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -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). diff --git a/config/config.example.json b/config/config.example.json index 2057008b8..4ba365272 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -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": { diff --git a/docs/MATRIX_SETUP.md b/docs/MATRIX_SETUP.md new file mode 100644 index 000000000..58f0afa6c --- /dev/null +++ b/docs/MATRIX_SETUP.md @@ -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 `` 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+ diff --git a/go.mod b/go.mod index 1f88639c8..5bb9d5bd2 100644 --- a/go.mod +++ b/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 ( diff --git a/go.sum b/go.sum index 0e95bf5cd..08630d81c 100644 --- a/go.sum +++ b/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= diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index ff523fcab..ab06e2f7f 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -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), }) diff --git a/pkg/channels/matrix.go b/pkg/channels/matrix.go new file mode 100644 index 000000000..8e249fb06 --- /dev/null +++ b/pkg/channels/matrix.go @@ -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: , , ,
, , 

-

, ,