├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yml │ └── enhancement.md └── workflows │ ├── go.yml │ └── stale.yml ├── .gitignore ├── .gitlab-ci.yml ├── .idea └── icon.svg ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.ci ├── LICENSE ├── LICENSE.exceptions ├── README.md ├── ROADMAP.md ├── app-manifest.yaml ├── build.sh ├── cmd └── mautrix-slack │ ├── legacymigrate.go │ ├── legacymigrate.sql │ ├── legacyprovision.go │ └── main.go ├── docker-run.sh ├── go.mod ├── go.sum └── pkg ├── connector ├── backfill.go ├── capabilities.go ├── chatinfo.go ├── client.go ├── config.go ├── connector.go ├── dbmeta.go ├── emoji.go ├── example-config.yaml ├── handlematrix.go ├── handleslack.go ├── id.go ├── login-app.go ├── login-cookie.go ├── slackdb │ ├── 00-latest-schema.sql │ ├── 02-emoji-alias-idx.sql │ ├── database.go │ └── emoji.go ├── slacklog.go └── startchat.go ├── emoji ├── emoji-generate.go ├── emoji.go └── emoji.json ├── msgconv ├── blocks.go ├── from-matrix.go ├── from-slack.go ├── matrixfmt │ └── blocks.go ├── mrkdwn │ ├── parser.go │ └── tag.go └── msgconv.go └── slackid ├── dbmeta.go ├── id.go └── id_test.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yaml,yml,sql}] 12 | indent_style = space 13 | 14 | [{.gitlab-ci.yml,.github/workflows/*.yml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: If something is definitely wrong in the bridge (rather than just a setup issue), 4 | file a bug report. Remember to include relevant logs. 5 | labels: bug 6 | 7 | --- 8 | 9 | 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Troubleshooting docs & FAQ 3 | url: https://docs.mau.fi/bridges/general/troubleshooting.html 4 | about: Check this first if you're having problems setting up the bridge. 5 | - name: Support room 6 | url: https://matrix.to/#/#slack:maunium.net 7 | about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Submit a feature request or other suggestion 4 | labels: enhancement 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | GOTOOLCHAIN: local 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | go-version: ["1.23", "1.24"] 15 | name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }} 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go-version }} 24 | cache: true 25 | 26 | - name: Install libolm 27 | run: sudo apt-get install libolm-dev libolm3 28 | 29 | - name: Install dependencies 30 | run: | 31 | go install golang.org/x/tools/cmd/goimports@latest 32 | go install honnef.co/go/tools/cmd/staticcheck@latest 33 | export PATH="$HOME/go/bin:$PATH" 34 | 35 | - name: Run pre-commit 36 | uses: pre-commit/action@v3.0.1 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Lock old issues' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 14 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | # pull-requests: write 11 | # discussions: write 12 | 13 | concurrency: 14 | group: lock-threads 15 | 16 | jobs: 17 | lock-stale: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: dessant/lock-threads@v5 21 | id: lock 22 | with: 23 | issue-inactive-days: 90 24 | process-only: issues 25 | - name: Log processed threads 26 | run: | 27 | if [ '${{ steps.lock.outputs.issues }}' ]; then 28 | echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"' 29 | fi 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /mautrix-slack 2 | logs/ 3 | *.db* 4 | *.yaml 5 | !example-config.yaml 6 | !.pre-commit-config.yaml 7 | /start 8 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'mautrix/ci' 3 | file: '/gov2-as-default.yml' 4 | -------------------------------------------------------------------------------- /.idea/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude_types: [markdown] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/tekwizely/pre-commit-golang 12 | rev: v1.0.0-rc.1 13 | hooks: 14 | - id: go-imports-repo 15 | args: 16 | - "-local" 17 | - "go.mau.fi/mautrix-slack" 18 | - "-w" 19 | - id: go-vet-repo-mod 20 | - id: go-staticcheck-repo-mod 21 | - id: go-mod-tidy 22 | 23 | - repo: https://github.com/beeper/pre-commit-go 24 | rev: v0.4.2 25 | hooks: 26 | - id: zerolog-ban-msgf 27 | - id: zerolog-use-stringer 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.2.1 (2025-04-16) 2 | 3 | * Fixed auto-linkification in outgoing messages of links whose top-level domain 4 | contains another shorter top-level domain (e.g. `.dev` which contains `.de`). 5 | 6 | # v0.2.0 (2025-03-16) 7 | 8 | * Bumped minimum Go version to 1.23. 9 | * Added support for signaling supported features to clients using the 10 | `com.beeper.room_features` state event. 11 | * Changed mention bridging to never bridge as matrix.to URLs. 12 | * Fixed edits being bridged multiple times if a single chat had multiple 13 | logged-in Matrix users. 14 | 15 | # v0.1.4 (2024-12-16) 16 | 17 | * Switched to new API for loading initial chats. 18 | * Updated Docker imager to Alpine 3.21. 19 | 20 | # v0.1.3 (2024-11-16) 21 | 22 | * Fixed bridged code blocks not being wrapped in a `` element. 23 | * Fixed login command not url-decoding cookies properly. 24 | 25 | # v0.1.2 (2024-10-16) 26 | 27 | * Fixed bridging newlines in plaintext messages from Matrix to Slack 28 | (thanks to [@redgoat650] in [#61]). 29 | * Fixed invalid auth not being detected immediately in some cases. 30 | 31 | [@redgoat650]: https://github.com/redgoat650 32 | [#61]: https://github.com/mautrix/slack/pull/61 33 | 34 | # v0.1.1 (2024-09-16) 35 | 36 | * Dropped support for unauthenticated media on Matrix. 37 | * Changed incoming file bridging to roundtrip via disk to avoid storing the 38 | entire file in memory. 39 | * Fixed sending media messages to Slack threads. 40 | 41 | # v0.1.0 (2024-08-16) 42 | 43 | Initial release. 44 | 45 | Note that when upgrading from an older version, the config file will have to be 46 | recreated. Migrating old configs is not supported. If encryption is used, the 47 | `pickle_key` config option must be set to `maunium.net/go/mautrix-whatsapp` to 48 | be able to read the old database. 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1-alpine3.21 AS builder 2 | 3 | RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev 4 | 5 | COPY . /build 6 | WORKDIR /build 7 | RUN ./build.sh 8 | 9 | FROM alpine:3.21 10 | 11 | ENV UID=1337 \ 12 | GID=1337 13 | 14 | RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq-go curl 15 | 16 | COPY --from=builder /build/mautrix-slack /usr/bin/mautrix-slack 17 | COPY --from=builder /build/docker-run.sh /docker-run.sh 18 | VOLUME /data 19 | 20 | CMD ["/docker-run.sh"] 21 | -------------------------------------------------------------------------------- /Dockerfile.ci: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | ENV UID=1337 \ 4 | GID=1337 5 | 6 | RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go 7 | 8 | ARG EXECUTABLE=./mautrix-slack 9 | COPY $EXECUTABLE /usr/bin/mautrix-slack 10 | COPY ./docker-run.sh /docker-run.sh 11 | ENV BRIDGEV2=1 12 | VOLUME /data 13 | WORKDIR /data 14 | 15 | CMD ["/docker-run.sh"] 16 | -------------------------------------------------------------------------------- /LICENSE.exceptions: -------------------------------------------------------------------------------- 1 | The mautrix-slack developers grant the following special exceptions: 2 | 3 | * to Beeper the right to embed the program in the Beeper clients and servers, 4 | and use and distribute the collective work without applying the license to 5 | the whole. 6 | * to Element the right to distribute compiled binaries of the program as a part 7 | of the Element Server Suite and other server bundles without applying the 8 | license. 9 | 10 | All exceptions are only valid under the condition that any modifications to 11 | the source code of mautrix-slack remain publicly available under the terms 12 | of the GNU AGPL version 3 or later. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mautrix-slack 2 | ![Languages](https://img.shields.io/github/languages/top/mautrix/slack.svg) 3 | [![License](https://img.shields.io/github/license/mautrix/slack.svg)](LICENSE) 4 | [![GitLab CI](https://mau.dev/mautrix/slack/badges/main/pipeline.svg)](https://mau.dev/mautrix/slack/container_registry) 5 | 6 | A Matrix-Slack puppeting bridge based on [slack-go](https://github.com/slack-go/slack). 7 | 8 | ## Documentation 9 | All setup and usage instructions are located on [docs.mau.fi]. Some quick links: 10 | 11 | [docs.mau.fi]: https://docs.mau.fi/bridges/go/slack/index.html 12 | 13 | * [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=slack) 14 | (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=slack)) 15 | * Basic usage: [Authentication](https://docs.mau.fi/bridges/go/slack/authentication.html) 16 | 17 | ### Features & Roadmap 18 | [ROADMAP.md](https://github.com/mautrix/slack/blob/master/ROADMAP.md) 19 | contains a general overview of what is supported by the bridge. 20 | 21 | ## Discussion 22 | Matrix room: [#slack:maunium.net](https://matrix.to/#/#slack:maunium.net) 23 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Features & roadmap 2 | 3 | * Matrix → Slack 4 | * [x] Message content 5 | * [x] Plain text 6 | * [x] Formatted text 7 | * [x] User pings 8 | * [x] Media and files 9 | * [x] Edits 10 | * [x] Threads 11 | * [x] Replies (as Slack threads) 12 | * [x] Reactions 13 | * [x] Typing status 14 | * [x] Message redaction 15 | * [x] Mark room as read 16 | * Slack → Matrix 17 | * [ ] Message content 18 | * [x] Plain text 19 | * [x] Formatted text 20 | * [x] User pings 21 | * [x] Media and files 22 | * [x] Edits 23 | * [x] Threads (as Matrix native threads with fallback Matrix reply) 24 | * [x] Custom Slack emoji 25 | * [x] Reactions 26 | * [x] Typing status 27 | * [x] Message deletion 28 | * [x] Reading pre-login message history 29 | * [x] Conversation types 30 | * [x] Channel (including Slack Connect) 31 | * [x] Group DM 32 | * [x] 1:1 DM 33 | * [x] Initial conversation metadata 34 | * [x] Name 35 | * [x] Topic 36 | * [x] Description 37 | * [x] Channel members 38 | * [ ] Conversation metadata changes 39 | * [ ] Name 40 | * [x] Topic 41 | * [x] Description 42 | * [x] Mark conversation as read 43 | * Misc 44 | * [x] Automatic portal creation 45 | * [x] On login (with token, not with password) 46 | * [x] When receiving message 47 | * [ ] When added to conversation 48 | * [ ] Creating DM by inviting user to Matrix room 49 | * [x] Using your own Matrix account for messages sent from your Slack client 50 | * [x] Shared channel portals between different Matrix users 51 | * [ ] Using relay bot to bridge to Slack 52 | -------------------------------------------------------------------------------- /app-manifest.yaml: -------------------------------------------------------------------------------- 1 | display_information: 2 | name: mautrix-slack 3 | features: 4 | bot_user: 5 | display_name: mautrix-slack 6 | always_online: false 7 | oauth_config: 8 | scopes: 9 | bot: 10 | - channels:history 11 | - channels:read 12 | - channels:write.invites 13 | - channels:write.topic 14 | - chat:write 15 | - chat:write.customize 16 | - conversations.connect:write 17 | - emoji:read 18 | - files:read 19 | - files:write 20 | - groups:history 21 | - groups:read 22 | - groups:write 23 | - groups:write.invites 24 | - groups:write.topic 25 | - im:history 26 | - im:read 27 | - im:write 28 | - im:write.topic 29 | - mpim:history 30 | - mpim:read 31 | - mpim:write 32 | - mpim:write.topic 33 | - pins:read 34 | - pins:write 35 | - reactions:read 36 | - reactions:write 37 | - team:read 38 | - users:read 39 | - users.profile:read 40 | - users:read.email 41 | settings: 42 | event_subscriptions: 43 | bot_events: 44 | - app_uninstalled 45 | - channel_archive 46 | - channel_created 47 | - channel_deleted 48 | - channel_history_changed 49 | - channel_id_changed 50 | - channel_left 51 | - channel_rename 52 | - channel_shared 53 | - channel_unarchive 54 | - channel_unshared 55 | - email_domain_changed 56 | - emoji_changed 57 | - group_archive 58 | - group_deleted 59 | - group_history_changed 60 | - group_left 61 | - group_rename 62 | - group_unarchive 63 | - im_history_changed 64 | - member_joined_channel 65 | - member_left_channel 66 | - message.channels 67 | - message.groups 68 | - message.im 69 | - message.mpim 70 | - pin_added 71 | - pin_removed 72 | - reaction_added 73 | - reaction_removed 74 | - team_domain_change 75 | - team_join 76 | - team_rename 77 | - user_change 78 | interactivity: 79 | is_enabled: false 80 | org_deploy_enabled: false 81 | socket_mode_enabled: true 82 | token_rotation_enabled: false 83 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }' | head -n1) 3 | GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" 4 | go build -ldflags="$GO_LDFLAGS" "$@" ./cmd/mautrix-slack 5 | -------------------------------------------------------------------------------- /cmd/mautrix-slack/legacymigrate.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | _ "embed" 21 | ) 22 | 23 | const legacyMigrateRenameTables = ` 24 | ALTER TABLE portal RENAME TO portal_old; 25 | ALTER TABLE puppet RENAME TO puppet_old; 26 | ALTER TABLE "user" RENAME TO user_old; 27 | ALTER TABLE user_team RENAME TO user_team_old; 28 | ALTER TABLE user_team_portal RENAME TO user_team_portal_old; 29 | ALTER TABLE message RENAME TO message_old; 30 | ALTER TABLE reaction RENAME TO reaction_old; 31 | ALTER TABLE attachment RENAME TO attachment_old; 32 | ALTER TABLE team_info RENAME TO team_info_old; 33 | ALTER TABLE backfill_state RENAME TO backfill_state_old; 34 | ALTER TABLE emoji RENAME TO emoji_old; 35 | ` 36 | 37 | //go:embed legacymigrate.sql 38 | var legacyMigrateCopyData string 39 | -------------------------------------------------------------------------------- /cmd/mautrix-slack/legacymigrate.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO portal ( 2 | bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_bridge_id, relay_login_id, 3 | name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set, in_space, room_type, 4 | metadata 5 | ) 6 | SELECT 7 | '', -- bridge_id 8 | team_id, -- id 9 | '', -- receiver 10 | space_room, 11 | NULL, -- parent_id 12 | '', -- parent_receiver 13 | NULL, -- relay_bridge_id 14 | NULL, -- relay_login_id 15 | team_name, 16 | '', -- topic 17 | COALESCE(avatar, ''), -- avatar_id, 18 | '', -- avatar_hash 19 | COALESCE(avatar_url, ''), -- avatar_mxc 20 | name_set, 21 | avatar_set, 22 | true, -- topic_set 23 | false, -- in_space 24 | 'space', -- room_type 25 | '{}' -- metadata 26 | FROM team_info_old; 27 | 28 | INSERT INTO portal ( 29 | bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_bridge_id, relay_login_id, other_user_id, 30 | name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set, name_is_custom, in_space, room_type, 31 | metadata 32 | ) 33 | SELECT 34 | '', -- bridge_id 35 | team_id || '-' || channel_id, -- id 36 | '', -- receiver 37 | mxid, 38 | team_id, -- parent_id 39 | '', -- parent_receiver 40 | NULL, -- relay_bridge_id 41 | NULL, -- relay_login_id 42 | CASE WHEN type=2 THEN LOWER(team_id || '-' || dm_user_id) END, -- other_user_id 43 | name, 44 | topic, 45 | avatar, -- avatar_id, 46 | '', -- avatar_hash 47 | COALESCE(avatar_url, ''), -- avatar_mxc 48 | name_set, 49 | avatar_set, 50 | topic_set, 51 | CASE WHEN type=2 THEN false ELSE true END, -- name_is_custom 52 | in_space, 53 | CASE 54 | WHEN type=2 THEN 'dm' 55 | WHEN type=3 THEN 'group_dm' 56 | ELSE '' 57 | END, -- room_type 58 | '{}' -- metadata 59 | FROM portal_old; 60 | 61 | INSERT INTO ghost ( 62 | bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, 63 | name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata 64 | ) 65 | SELECT 66 | '', -- bridge_id 67 | lower(team_id || '-' || user_id), -- id 68 | name, 69 | COALESCE(avatar, ''), -- avatar_id 70 | '', -- avatar_hash 71 | COALESCE(avatar_url, ''), -- avatar_mxc 72 | name_set, 73 | avatar_set, 74 | contact_info_set, -- contact_info_set 75 | is_bot, -- is_bot 76 | '[]', -- identifiers 77 | '{}' -- metadata 78 | FROM puppet_old 79 | WHERE user_id=UPPER(user_id); 80 | 81 | DELETE FROM message_old WHERE NOT EXISTS( 82 | SELECT 1 FROM puppet_old WHERE puppet_old.user_id=message_old.author_id AND puppet_old.team_id=message_old.team_id 83 | ); 84 | DELETE FROM reaction_old WHERE NOT EXISTS( 85 | SELECT 1 FROM puppet_old WHERE puppet_old.user_id=reaction_old.author_id AND puppet_old.team_id=reaction_old.team_id 86 | ); 87 | 88 | INSERT INTO message ( 89 | bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, 90 | sender_mxid, timestamp, edit_count, 91 | thread_root_id, reply_to_id, reply_to_part_id, metadata 92 | ) 93 | SELECT 94 | '', -- bridge_id 95 | team_id || '-' || channel_id || '-' || slack_message_id, -- id 96 | '', -- part_id 97 | matrix_message_id, -- mxid 98 | team_id || '-' || channel_id, -- room_id 99 | '', -- room_receiver 100 | lower(team_id || '-' || author_id), -- sender_id 101 | '', -- sender_mxid (not available) 102 | CAST(CAST(slack_message_id AS FLOAT) * 1000000000 AS BIGINT), -- timestamp 103 | 0, -- edit_count 104 | CASE WHEN slack_thread_id<>'' THEN 105 | team_id || '-' || channel_id || '-' || slack_thread_id 106 | END, -- thread_root_id 107 | NULL, -- reply_to_id 108 | NULL, -- reply_to_part_id 109 | '{}' -- metadata 110 | FROM message_old; 111 | 112 | -- Insert fake ghost because attachments don't have senders 113 | INSERT INTO ghost ( 114 | bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, 115 | name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata 116 | ) VALUES ( 117 | '', '', '', '', '', '', false, false, false, false, '[]', '{}' 118 | ); 119 | 120 | INSERT INTO message ( 121 | bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, 122 | sender_mxid, timestamp, edit_count, 123 | thread_root_id, reply_to_id, reply_to_part_id, metadata 124 | ) 125 | SELECT 126 | '', -- bridge_id 127 | team_id || '-' || channel_id || '-' || slack_message_id, -- id 128 | 'file-0-' || slack_file_id, -- part_id 129 | matrix_event_id, -- mxid 130 | team_id || '-' || channel_id, -- room_id 131 | '', -- room_receiver 132 | '', -- sender_id TODO find correct sender 133 | '', -- sender_mxid (not available) 134 | CAST(CAST(slack_message_id AS FLOAT) * 1000000000 AS BIGINT), -- timestamp 135 | 0, -- edit_count 136 | CASE WHEN slack_thread_id<>'' THEN 137 | team_id || '-' || channel_id || '-' || slack_thread_id 138 | END, -- thread_root_id 139 | NULL, -- reply_to_id 140 | NULL, -- reply_to_part_id 141 | '{}' -- metadata 142 | FROM attachment_old 143 | WHERE true 144 | -- hack to prevent exploding when there are multiple rows with the same file_id 145 | ON CONFLICT (bridge_id, room_receiver, id, part_id) DO NOTHING; 146 | 147 | UPDATE message 148 | SET part_id='' 149 | FROM (SELECT id, COUNT(*) AS count FROM message GROUP BY id HAVING COUNT(*) = 1) as pc 150 | WHERE pc.count = 1 AND message.id = pc.id; 151 | 152 | INSERT INTO reaction ( 153 | bridge_id, message_id, message_part_id, sender_id, emoji_id, emoji, 154 | room_id, room_receiver, mxid, timestamp, metadata 155 | ) 156 | SELECT 157 | '', -- bridge_id 158 | team_id || '-' || channel_id || '-' || slack_message_id, -- message_id 159 | '', -- message_part_id 160 | lower(team_id || '-' || author_id), -- sender_id 161 | slack_name, -- emoji_id 162 | matrix_name, -- emoji 163 | team_id || '-' || channel_id, -- room_id 164 | '', -- room_receiver 165 | matrix_event_id, -- mxid 166 | CAST(CAST(slack_message_id AS FLOAT) * 1000000000 AS BIGINT), -- timestamp 167 | '{}' -- metadata 168 | FROM reaction_old 169 | WHERE EXISTS(SELECT 1 170 | FROM message 171 | WHERE message.id = team_id || '-' || channel_id || '-' || slack_message_id 172 | AND message.bridge_id = '' 173 | AND message.part_id = '' 174 | AND message.room_receiver = ''); 175 | 176 | INSERT INTO "user" (bridge_id, mxid, management_room, access_token) 177 | SELECT '', mxid, management_room, NULL FROM user_old; 178 | 179 | INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, space_room, metadata) 180 | SELECT 181 | '', -- bridge_id 182 | mxid, -- user_mxid 183 | team_id || '-' || slack_id, -- id 184 | team_name || ' - ' || slack_email, -- remote_name 185 | NULL, -- space_room 186 | -- only: postgres 187 | jsonb_build_object 188 | -- only: sqlite (line commented) 189 | -- json_object 190 | ('token', token, 'cookie_token', cookie_token, 'email', slack_email) -- metadata 191 | FROM user_team_old; 192 | 193 | INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read) 194 | SELECT DISTINCT 195 | '', -- bridge_id 196 | matrix_user_id, -- user_mxid 197 | slack_team_id || '-' || slack_user_id, -- login_id 198 | slack_team_id || '-' || portal_channel_id, -- portal_id 199 | '', -- portal_receiver 200 | false, -- in_space 201 | false, -- preferred 202 | -- only: postgres 203 | CAST(NULL AS BIGINT) -- last_read 204 | -- only: sqlite (line commented) 205 | -- NULL -- last_read 206 | FROM user_team_portal_old; 207 | 208 | UPDATE portal 209 | SET receiver=ul.user_login_id 210 | FROM (SELECT team_id || '-' || slack_id AS user_login_id, team_id AS parent_id FROM user_team_old) ul 211 | WHERE room_type IN ('dm', 'group_dm') AND portal.parent_id=ul.parent_id; 212 | 213 | CREATE TABLE emoji ( 214 | team_id TEXT NOT NULL, 215 | emoji_id TEXT NOT NULL, 216 | value TEXT NOT NULL, 217 | alias TEXT, 218 | image_mxc TEXT, 219 | 220 | PRIMARY KEY (team_id, emoji_id) 221 | ); 222 | 223 | INSERT INTO emoji (team_id, emoji_id, value, alias, image_mxc) 224 | SELECT slack_team, slack_id, '', alias, image_url 225 | FROM emoji_old; 226 | 227 | CREATE TABLE slack_version (version INTEGER, compat INTEGER); 228 | INSERT INTO slack_version (version, compat) VALUES (1, 1); 229 | 230 | DROP TABLE reaction_old; 231 | DROP TABLE message_old; 232 | DROP TABLE attachment_old; 233 | DROP TABLE user_team_portal_old; 234 | DROP TABLE backfill_state_old; 235 | DROP TABLE portal_old; 236 | DROP TABLE team_info_old; 237 | DROP TABLE user_team_old; 238 | DROP TABLE puppet_old; 239 | DROP TABLE user_old; 240 | DROP TABLE emoji_old; 241 | -------------------------------------------------------------------------------- /cmd/mautrix-slack/legacyprovision.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "encoding/json" 21 | "net/http" 22 | "net/url" 23 | "strings" 24 | 25 | "github.com/rs/zerolog/hlog" 26 | "maunium.net/go/mautrix/bridgev2" 27 | "maunium.net/go/mautrix/bridgev2/networkid" 28 | 29 | "go.mau.fi/mautrix-slack/pkg/connector" 30 | "go.mau.fi/mautrix-slack/pkg/slackid" 31 | ) 32 | 33 | func jsonResponse(w http.ResponseWriter, status int, response any) { 34 | w.Header().Add("Content-Type", "application/json") 35 | w.WriteHeader(status) 36 | _ = json.NewEncoder(w).Encode(response) 37 | } 38 | 39 | type Error struct { 40 | Success bool `json:"success"` 41 | Error string `json:"error"` 42 | ErrCode string `json:"errcode"` 43 | } 44 | 45 | type Response struct { 46 | Success bool `json:"success"` 47 | Status string `json:"status"` 48 | } 49 | 50 | func legacyProvPing(w http.ResponseWriter, r *http.Request) { 51 | user := m.Matrix.Provisioning.GetUser(r) 52 | puppets := []any{} 53 | for _, login := range user.GetUserLogins() { 54 | teamID, userID := slackid.ParseUserLoginID(login.ID) 55 | client, _ := login.Client.(*connector.SlackClient) 56 | var teamName string 57 | if client != nil && client.BootResp != nil { 58 | teamName = client.BootResp.Team.Name 59 | } 60 | 61 | puppets = append(puppets, map[string]any{ 62 | "puppetId": string(login.ID), 63 | "puppetMxid": user.MXID, 64 | "userId": userID, 65 | "data": map[string]any{ 66 | "team": map[string]any{ 67 | "id": teamID, 68 | "name": teamName, 69 | }, 70 | "self": map[string]any{ 71 | "id": string(login.ID), 72 | "name": login.Metadata.(*slackid.UserLoginMetadata).Email, 73 | }, 74 | }, 75 | }) 76 | } 77 | 78 | resp := map[string]any{ 79 | "puppets": puppets, 80 | "management_room": user.ManagementRoom, 81 | "mxid": user.MXID, 82 | } 83 | jsonResponse(w, http.StatusOK, resp) 84 | } 85 | 86 | func legacyProvLogin(w http.ResponseWriter, r *http.Request) { 87 | user := m.Matrix.Provisioning.GetUser(r) 88 | var data struct { 89 | Token string 90 | Cookietoken string 91 | } 92 | 93 | err := json.NewDecoder(r.Body).Decode(&data) 94 | if err != nil { 95 | jsonResponse(w, http.StatusBadRequest, Error{ 96 | Error: "Invalid JSON", 97 | ErrCode: "Invalid JSON", 98 | }) 99 | return 100 | } 101 | 102 | if data.Token == "" { 103 | jsonResponse(w, http.StatusBadRequest, Error{ 104 | Error: "Missing field token", 105 | ErrCode: "Missing field token", 106 | }) 107 | return 108 | } 109 | 110 | if data.Cookietoken == "" { 111 | jsonResponse(w, http.StatusBadRequest, Error{ 112 | Error: "Missing field cookietoken", 113 | ErrCode: "Missing field cookietoken", 114 | }) 115 | return 116 | } 117 | 118 | login, err := m.Bridge.Network.CreateLogin(r.Context(), user, connector.LoginFlowIDAuthToken) 119 | if err != nil { 120 | hlog.FromRequest(r).Err(err).Msg("Failed to create login") 121 | jsonResponse(w, http.StatusInternalServerError, Error{ 122 | Error: "Failed to create login", 123 | ErrCode: "M_UNKNOWN", 124 | }) 125 | return 126 | } 127 | nextStep, err := login.Start(r.Context()) 128 | if err != nil { 129 | hlog.FromRequest(r).Err(err).Msg("Failed to start login") 130 | jsonResponse(w, http.StatusInternalServerError, Error{ 131 | Error: "Failed to start login", 132 | ErrCode: "M_UNKNOWN", 133 | }) 134 | return 135 | } else if nextStep.StepID != connector.LoginStepIDAuthToken { 136 | jsonResponse(w, http.StatusInternalServerError, Error{ 137 | Error: "Unexpected login step", 138 | ErrCode: "M_UNKNOWN", 139 | }) 140 | return 141 | } 142 | data.Cookietoken, _ = url.PathUnescape(data.Cookietoken) 143 | nextStep, err = login.(bridgev2.LoginProcessCookies).SubmitCookies(r.Context(), map[string]string{ 144 | "auth_token": data.Token, 145 | "cookie_token": data.Cookietoken, 146 | }) 147 | if err != nil { 148 | hlog.FromRequest(r).Err(err).Msg("Failed to submit cookies") 149 | jsonResponse(w, http.StatusInternalServerError, Error{ 150 | Error: "Failed to submit cookies", 151 | ErrCode: "M_UNKNOWN", 152 | }) 153 | return 154 | } else if nextStep.StepID != connector.LoginStepIDComplete { 155 | jsonResponse(w, http.StatusInternalServerError, Error{ 156 | Error: "Unexpected login step", 157 | ErrCode: "M_UNKNOWN", 158 | }) 159 | return 160 | } 161 | 162 | teamID, userID := slackid.ParseUserLoginID(nextStep.CompleteParams.UserLogin.ID) 163 | jsonResponse(w, http.StatusCreated, 164 | map[string]any{ 165 | "success": true, 166 | "teamid": teamID, 167 | "userid": userID, 168 | }) 169 | } 170 | 171 | func legacyProvLogout(w http.ResponseWriter, r *http.Request) { 172 | user := m.Matrix.Provisioning.GetUser(r) 173 | loginID := r.URL.Query().Get("slack_team_id") 174 | if !strings.ContainsRune(loginID, '-') { 175 | loginIDPrefix := loginID + "-" 176 | loginID = "" 177 | for _, login := range user.GetUserLoginIDs() { 178 | if strings.HasPrefix(string(login), loginIDPrefix) { 179 | loginID = string(login) 180 | break 181 | } 182 | } 183 | } 184 | if loginID == "" { 185 | jsonResponse(w, http.StatusNotFound, Error{ 186 | Error: "Not logged in", 187 | ErrCode: "Not logged in", 188 | }) 189 | return 190 | } 191 | login, err := m.Bridge.GetExistingUserLoginByID(r.Context(), networkid.UserLoginID(loginID)) 192 | if err != nil { 193 | jsonResponse(w, http.StatusInternalServerError, Error{ 194 | Error: "Failed to get login", 195 | ErrCode: "M_UNKNOWN", 196 | }) 197 | return 198 | } else if login == nil { 199 | jsonResponse(w, http.StatusNotFound, Error{ 200 | Error: "Not logged in", 201 | ErrCode: "Not logged in", 202 | }) 203 | return 204 | } 205 | login.Logout(r.Context()) 206 | jsonResponse(w, http.StatusOK, Response{true, "Logged out successfully."}) 207 | } 208 | -------------------------------------------------------------------------------- /cmd/mautrix-slack/main.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "net/http" 21 | 22 | "github.com/lib/pq" 23 | "maunium.net/go/mautrix/bridgev2/matrix/mxmain" 24 | 25 | "go.mau.fi/mautrix-slack/pkg/connector" 26 | "go.mau.fi/mautrix-slack/pkg/connector/slackdb" 27 | ) 28 | 29 | var ( 30 | Tag = "unknown" 31 | Commit = "unknown" 32 | BuildTime = "unknown" 33 | ) 34 | 35 | var c = &connector.SlackConnector{} 36 | var m = mxmain.BridgeMain{ 37 | Name: "mautrix-slack", 38 | Description: "A Matrix-Slack puppeting bridge", 39 | URL: "https://github.com/mautrix/slack", 40 | Version: "0.2.1", 41 | Connector: c, 42 | } 43 | 44 | func main() { 45 | slackdb.PostgresArrayWrapper = pq.Array 46 | m.PostInit = func() { 47 | m.CheckLegacyDB( 48 | 16, 49 | "c565641", 50 | "v0.1.0", 51 | m.LegacyMigrateSimple(legacyMigrateRenameTables, legacyMigrateCopyData, 14), 52 | true, 53 | ) 54 | } 55 | m.PostStart = func() { 56 | if m.Matrix.Provisioning != nil { 57 | m.Matrix.Provisioning.Router.HandleFunc("/v1/ping", legacyProvPing).Methods(http.MethodGet) 58 | m.Matrix.Provisioning.Router.HandleFunc("/v1/login", legacyProvLogin).Methods(http.MethodPost) 59 | m.Matrix.Provisioning.Router.HandleFunc("/v1/logout", legacyProvLogout).Methods(http.MethodPost) 60 | } 61 | } 62 | m.InitVersion(Tag, Commit, BuildTime) 63 | m.Run() 64 | } 65 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ -z "$GID" ]]; then 4 | GID="$UID" 5 | fi 6 | 7 | BINARY_NAME=/usr/bin/mautrix-slack 8 | 9 | function fixperms { 10 | chown -R $UID:$GID /data 11 | } 12 | 13 | if [[ ! -f /data/config.yaml ]]; then 14 | $BINARY_NAME -c /data/config.yaml -e 15 | echo "Didn't find a config file." 16 | echo "Copied default config file to /data/config.yaml" 17 | echo "Modify that config file to your liking." 18 | echo "Start the container again after that to generate the registration file." 19 | exit 20 | fi 21 | 22 | if [[ ! -f /data/registration.yaml ]]; then 23 | $BINARY_NAME -g -c /data/config.yaml -r /data/registration.yaml 24 | echo "Didn't find a registration file." 25 | echo "Generated one for you." 26 | echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." 27 | exit 28 | fi 29 | 30 | cd /data 31 | fixperms 32 | exec su-exec $UID:$GID $BINARY_NAME 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.mau.fi/mautrix-slack 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/lib/pq v1.10.9 9 | github.com/rs/zerolog v1.34.0 10 | github.com/slack-go/slack v0.16.0 11 | github.com/stretchr/testify v1.10.0 12 | github.com/yuin/goldmark v1.7.11 13 | go.mau.fi/util v0.8.7 14 | golang.org/x/net v0.40.0 15 | gopkg.in/yaml.v3 v3.0.1 16 | maunium.net/go/mautrix v0.24.1-0.20250527083757-8a745c0d03ec 17 | ) 18 | 19 | require ( 20 | filippo.io/edwards25519 v1.1.0 // indirect 21 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/gorilla/mux v1.8.0 // indirect 24 | github.com/gorilla/websocket v1.5.0 // indirect 25 | github.com/mattn/go-colorable v0.1.14 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/mattn/go-sqlite3 v1.14.28 // indirect 28 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect 29 | github.com/pmezard/go-difflib v1.0.0 // indirect 30 | github.com/rs/xid v1.6.0 // indirect 31 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect 32 | github.com/tidwall/gjson v1.18.0 // indirect 33 | github.com/tidwall/match v1.1.1 // indirect 34 | github.com/tidwall/pretty v1.2.1 // indirect 35 | github.com/tidwall/sjson v1.2.5 // indirect 36 | go.mau.fi/zeroconfig v0.1.3 // indirect 37 | golang.org/x/crypto v0.38.0 // indirect 38 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 39 | golang.org/x/sync v0.14.0 // indirect 40 | golang.org/x/sys v0.33.0 // indirect 41 | golang.org/x/text v0.25.0 // indirect 42 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 43 | maunium.net/go/mauflag v1.0.0 // indirect 44 | ) 45 | 46 | replace github.com/slack-go/slack => github.com/beeper/slackgo v0.0.0-20250309192538-8fa8f3a4b11c 47 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 4 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 5 | github.com/beeper/slackgo v0.0.0-20250309192538-8fa8f3a4b11c h1:/FydF/sSrRQty9+rODG1GMfx+hXPLyuNZNebo8z3d+Y= 6 | github.com/beeper/slackgo v0.0.0-20250309192538-8fa8f3a4b11c/go.mod h1:axoegr/0xf8uWt4I+coY6x+CVKPbWGs4YqpoYbCBRr8= 7 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 8 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 12 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 13 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 14 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 15 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 16 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 17 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 18 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 19 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 20 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 21 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 22 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 23 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 24 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 25 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 26 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 27 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 28 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 29 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= 30 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 31 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 35 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 36 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 37 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 38 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 39 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 40 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 41 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 43 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 44 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 45 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 46 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 47 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 48 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 49 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 50 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 51 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 52 | github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo= 53 | github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 54 | go.mau.fi/util v0.8.7 h1:ywKarPxouJQEEijTs4mPlxC7F4AWEKokEpWc+2TYy6c= 55 | go.mau.fi/util v0.8.7/go.mod h1:j6R3cENakc1f8HpQeFl0N15UiSTcNmIfDBNJUbL71RY= 56 | go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= 57 | go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= 58 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 59 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 60 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= 61 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 62 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 63 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 64 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 65 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 66 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 70 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 71 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 72 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 73 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 74 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 75 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 76 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 77 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 78 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 79 | maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= 80 | maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 81 | maunium.net/go/mautrix v0.24.1-0.20250527083757-8a745c0d03ec h1:kn/SHMTE4FiafMFS9WKXzHQc/Z9yN09Aa7TiFOgYfSY= 82 | maunium.net/go/mautrix v0.24.1-0.20250527083757-8a745c0d03ec/go.mod h1:HqA1HUutQYJkrYRPkK64itARDz79PCec1oWVEB72HVQ= 83 | -------------------------------------------------------------------------------- /pkg/connector/backfill.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "slices" 23 | 24 | "github.com/rs/zerolog" 25 | "github.com/slack-go/slack" 26 | "maunium.net/go/mautrix/bridgev2" 27 | "maunium.net/go/mautrix/bridgev2/database" 28 | "maunium.net/go/mautrix/bridgev2/networkid" 29 | 30 | "go.mau.fi/mautrix-slack/pkg/slackid" 31 | ) 32 | 33 | var ( 34 | _ bridgev2.BackfillingNetworkAPI = (*SlackClient)(nil) 35 | _ bridgev2.BackfillingNetworkAPIWithLimits = (*SlackClient)(nil) 36 | ) 37 | 38 | func (s *SlackClient) GetBackfillMaxBatchCount(ctx context.Context, portal *bridgev2.Portal, task *database.BackfillTask) int { 39 | switch portal.RoomType { 40 | case database.RoomTypeSpace: 41 | return 0 42 | case database.RoomTypeDefault: 43 | return s.Main.br.Config.Backfill.Queue.GetOverride("channel") 44 | case database.RoomTypeGroupDM: 45 | return s.Main.br.Config.Backfill.Queue.GetOverride("group_dm") 46 | case database.RoomTypeDM: 47 | return s.Main.br.Config.Backfill.Queue.GetOverride("dm") 48 | default: 49 | return s.Main.br.Config.Backfill.Queue.MaxBatches 50 | } 51 | } 52 | 53 | func (s *SlackClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { 54 | if s.Client == nil { 55 | return nil, bridgev2.ErrNotLoggedIn 56 | } 57 | _, channelID := slackid.ParsePortalID(params.Portal.ID) 58 | if channelID == "" { 59 | return nil, fmt.Errorf("invalid channel ID") 60 | } 61 | var anchorMessageID string 62 | if params.AnchorMessage != nil { 63 | _, _, anchorMessageID, _ = slackid.ParseMessageID(params.AnchorMessage.ID) 64 | } 65 | slackParams := &slack.GetConversationHistoryParameters{ 66 | ChannelID: channelID, 67 | Cursor: string(params.Cursor), 68 | Latest: anchorMessageID, 69 | Limit: min(params.Count, 999), 70 | Inclusive: false, 71 | IncludeAllMetadata: false, 72 | } 73 | if params.Forward { 74 | slackParams.Oldest = slackParams.Latest 75 | slackParams.Latest = "" 76 | } 77 | var chunk *slack.GetConversationHistoryResponse 78 | var err error 79 | var threadTS string 80 | if params.ThreadRoot != "" { 81 | var ok bool 82 | _, _, threadTS, ok = slackid.ParseMessageID(params.ThreadRoot) 83 | if !ok { 84 | return nil, fmt.Errorf("invalid thread root ID") 85 | } 86 | chunk, err = s.Client.GetConversationRepliesContext(ctx, &slack.GetConversationRepliesParameters{ 87 | GetConversationHistoryParameters: *slackParams, 88 | Timestamp: threadTS, 89 | }) 90 | } else { 91 | chunk, err = s.Client.GetConversationHistoryContext(ctx, slackParams) 92 | } 93 | if err != nil { 94 | return nil, err 95 | } 96 | convertedMessages := make([]*bridgev2.BackfillMessage, 0, len(chunk.Messages)) 97 | var maxMsgID string 98 | for _, msg := range chunk.Messages { 99 | if threadTS != "" && msg.Timestamp == threadTS { 100 | continue 101 | } else if threadTS == "" && msg.ThreadTimestamp != "" && msg.ThreadTimestamp != msg.Timestamp { 102 | continue 103 | } 104 | convertedMessages = append(convertedMessages, s.wrapBackfillMessage(ctx, params.Portal, &msg.Msg, threadTS != "")) 105 | if maxMsgID < msg.Timestamp { 106 | maxMsgID = msg.Timestamp 107 | } 108 | } 109 | slices.Reverse(convertedMessages) 110 | lastRead := s.getLastReadCache(channelID) 111 | return &bridgev2.FetchMessagesResponse{ 112 | Messages: convertedMessages, 113 | Cursor: networkid.PaginationCursor(chunk.ResponseMetadata.Cursor), 114 | HasMore: chunk.HasMore, 115 | Forward: params.Forward, 116 | MarkRead: lastRead != "" && maxMsgID != "" && lastRead >= maxMsgID, 117 | }, nil 118 | } 119 | 120 | func (s *SlackClient) wrapBackfillMessage(ctx context.Context, portal *bridgev2.Portal, msg *slack.Msg, inThread bool) *bridgev2.BackfillMessage { 121 | senderID := msg.User 122 | if senderID == "" { 123 | senderID = msg.BotID 124 | } 125 | sender := s.makeEventSender(senderID) 126 | ghost, err := s.Main.br.GetGhostByID(ctx, sender.Sender) 127 | if err != nil { 128 | zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost") 129 | } 130 | var intent bridgev2.MatrixAPI 131 | if ghost == nil { 132 | intent = portal.Bridge.Bot 133 | } else { 134 | intent = ghost.Intent 135 | } 136 | _, channelID := slackid.ParsePortalID(portal.ID) 137 | out := &bridgev2.BackfillMessage{ 138 | ConvertedMessage: s.Main.MsgConv.ToMatrix(ctx, portal, intent, s.UserLogin, msg), 139 | Sender: sender, 140 | ID: slackid.MakeMessageID(s.TeamID, channelID, msg.Timestamp), 141 | Timestamp: slackid.ParseSlackTimestamp(msg.Timestamp), 142 | Reactions: make([]*bridgev2.BackfillReaction, 0, len(msg.Reactions)), 143 | } 144 | if msg.ReplyCount > 0 && !inThread { 145 | out.ShouldBackfillThread = true 146 | out.LastThreadMessage = slackid.MakeMessageID(s.TeamID, channelID, msg.LatestReply) 147 | } 148 | for _, reaction := range msg.Reactions { 149 | emoji, extraContent := s.getReactionInfo(ctx, reaction.Name) 150 | for _, user := range reaction.Users { 151 | out.Reactions = append(out.Reactions, &bridgev2.BackfillReaction{ 152 | Sender: s.makeEventSender(user), 153 | EmojiID: networkid.EmojiID(reaction.Name), 154 | Emoji: emoji, 155 | ExtraContent: extraContent, 156 | }) 157 | } 158 | } 159 | return out 160 | } 161 | -------------------------------------------------------------------------------- /pkg/connector/capabilities.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "strconv" 22 | "time" 23 | 24 | "go.mau.fi/util/ffmpeg" 25 | "go.mau.fi/util/jsontime" 26 | "go.mau.fi/util/ptr" 27 | "maunium.net/go/mautrix/bridgev2" 28 | "maunium.net/go/mautrix/event" 29 | 30 | "go.mau.fi/mautrix-slack/pkg/slackid" 31 | ) 32 | 33 | func (s *SlackConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { 34 | return &bridgev2.NetworkGeneralCapabilities{ 35 | // GetUserInfo has an internal rate limit of 1 fetch per 24 hours, 36 | // so we're fine to tell the bridge to fetch user info all the time. 37 | AggressiveUpdateInfo: true, 38 | } 39 | } 40 | 41 | func (s *SlackConnector) GetBridgeInfoVersion() (info, caps int) { 42 | return 1, 1 43 | } 44 | 45 | func supportedIfFFmpeg() event.CapabilitySupportLevel { 46 | if ffmpeg.Supported() { 47 | return event.CapLevelPartialSupport 48 | } 49 | return event.CapLevelRejected 50 | } 51 | 52 | func capID() string { 53 | base := "fi.mau.slack.capabilities.2025_01_10" 54 | if ffmpeg.Supported() { 55 | return base + "+ffmpeg" 56 | } 57 | return base 58 | } 59 | 60 | const MaxFileSize = 1 * 1000 * 1000 * 1000 61 | const MaxTextLength = 40000 62 | 63 | var roomCaps = &event.RoomFeatures{ 64 | ID: capID(), 65 | Formatting: event.FormattingFeatureMap{ 66 | event.FmtBold: event.CapLevelFullySupported, 67 | event.FmtItalic: event.CapLevelFullySupported, 68 | event.FmtStrikethrough: event.CapLevelFullySupported, 69 | event.FmtInlineCode: event.CapLevelFullySupported, 70 | event.FmtCodeBlock: event.CapLevelFullySupported, 71 | event.FmtSyntaxHighlighting: event.CapLevelDropped, 72 | event.FmtBlockquote: event.CapLevelFullySupported, 73 | event.FmtInlineLink: event.CapLevelFullySupported, 74 | event.FmtUserLink: event.CapLevelFullySupported, 75 | event.FmtRoomLink: event.CapLevelFullySupported, 76 | event.FmtEventLink: event.CapLevelUnsupported, 77 | event.FmtAtRoomMention: event.CapLevelFullySupported, 78 | event.FmtUnorderedList: event.CapLevelFullySupported, 79 | event.FmtOrderedList: event.CapLevelFullySupported, 80 | event.FmtListStart: event.CapLevelFullySupported, 81 | event.FmtListJumpValue: event.CapLevelDropped, 82 | event.FmtCustomEmoji: event.CapLevelFullySupported, 83 | }, 84 | File: event.FileFeatureMap{ 85 | event.MsgImage: { 86 | MimeTypes: map[string]event.CapabilitySupportLevel{ 87 | "image/jpeg": event.CapLevelFullySupported, 88 | "image/png": event.CapLevelFullySupported, 89 | "image/gif": event.CapLevelFullySupported, 90 | "image/webp": event.CapLevelFullySupported, 91 | }, 92 | Caption: event.CapLevelFullySupported, 93 | MaxCaptionLength: MaxTextLength, 94 | MaxSize: MaxFileSize, 95 | }, 96 | event.MsgVideo: { 97 | MimeTypes: map[string]event.CapabilitySupportLevel{ 98 | "video/mp4": event.CapLevelFullySupported, 99 | "video/webm": event.CapLevelFullySupported, 100 | }, 101 | Caption: event.CapLevelFullySupported, 102 | MaxCaptionLength: MaxTextLength, 103 | MaxSize: MaxFileSize, 104 | }, 105 | event.MsgAudio: { 106 | MimeTypes: map[string]event.CapabilitySupportLevel{ 107 | "audio/mpeg": event.CapLevelFullySupported, 108 | "audio/webm": event.CapLevelFullySupported, 109 | "audio/wav": event.CapLevelFullySupported, 110 | }, 111 | Caption: event.CapLevelFullySupported, 112 | MaxCaptionLength: MaxTextLength, 113 | MaxSize: MaxFileSize, 114 | }, 115 | event.MsgFile: { 116 | MimeTypes: map[string]event.CapabilitySupportLevel{ 117 | // TODO Slack Connect rejects some types 118 | // https://slack.com/intl/en-gb/help/articles/1500002249342-Restricted-file-types-in-Slack-Connect 119 | "*/*": event.CapLevelFullySupported, 120 | }, 121 | Caption: event.CapLevelFullySupported, 122 | MaxCaptionLength: MaxTextLength, 123 | MaxSize: MaxFileSize, 124 | }, 125 | event.CapMsgGIF: { 126 | MimeTypes: map[string]event.CapabilitySupportLevel{ 127 | "image/gif": event.CapLevelFullySupported, 128 | }, 129 | Caption: event.CapLevelFullySupported, 130 | MaxCaptionLength: MaxTextLength, 131 | MaxSize: MaxFileSize, 132 | }, 133 | event.CapMsgVoice: { 134 | MimeTypes: map[string]event.CapabilitySupportLevel{ 135 | "audio/ogg": supportedIfFFmpeg(), 136 | "audio/webm; codecs=opus": event.CapLevelFullySupported, 137 | }, 138 | Caption: event.CapLevelFullySupported, 139 | MaxCaptionLength: MaxTextLength, 140 | MaxSize: MaxFileSize, 141 | MaxDuration: ptr.Ptr(jsontime.S(5 * time.Minute)), 142 | }, 143 | }, 144 | LocationMessage: event.CapLevelRejected, 145 | MaxTextLength: MaxTextLength, 146 | Thread: event.CapLevelFullySupported, 147 | Edit: event.CapLevelFullySupported, 148 | EditMaxAge: nil, 149 | Delete: event.CapLevelFullySupported, 150 | Reaction: event.CapLevelFullySupported, 151 | } 152 | 153 | func (s *SlackClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { 154 | meta := &slackid.PortalMetadata{} 155 | topLevel := portal.GetTopLevelParent() 156 | if topLevel != nil { 157 | meta = topLevel.Metadata.(*slackid.PortalMetadata) 158 | } 159 | caps := roomCaps 160 | if meta.EditMaxAge != nil && *meta.EditMaxAge >= 0 { 161 | caps = ptr.Clone(roomCaps) 162 | caps.ID += "+edit_max_age=" + strconv.Itoa(*meta.EditMaxAge) 163 | caps.EditMaxAge = ptr.Ptr(jsontime.S(time.Duration(*meta.EditMaxAge) * time.Minute)) 164 | if *meta.EditMaxAge == 0 { 165 | caps.Edit = event.CapLevelRejected 166 | } 167 | } 168 | if meta.AllowDelete != nil && !*meta.AllowDelete { 169 | if caps == roomCaps { 170 | caps = ptr.Clone(roomCaps) 171 | } 172 | caps.ID += "+disallow_delete" 173 | caps.Delete = event.CapLevelRejected 174 | } 175 | return caps 176 | } 177 | -------------------------------------------------------------------------------- /pkg/connector/chatinfo.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "net/url" 23 | "slices" 24 | "strings" 25 | "sync" 26 | "time" 27 | "unicode" 28 | "unicode/utf8" 29 | 30 | "github.com/rs/zerolog" 31 | "github.com/slack-go/slack" 32 | "go.mau.fi/util/jsontime" 33 | "go.mau.fi/util/ptr" 34 | "maunium.net/go/mautrix/bridgev2" 35 | "maunium.net/go/mautrix/bridgev2/database" 36 | "maunium.net/go/mautrix/bridgev2/networkid" 37 | "maunium.net/go/mautrix/event" 38 | 39 | "go.mau.fi/mautrix-slack/pkg/slackid" 40 | ) 41 | 42 | const ChatInfoCacheExpiry = 1 * time.Hour 43 | 44 | func (s *SlackClient) fetchChatInfoWithCache(ctx context.Context, channelID string) (*slack.Channel, error) { 45 | s.chatInfoCacheLock.Lock() 46 | defer s.chatInfoCacheLock.Unlock() 47 | if cached, ok := s.chatInfoCache[channelID]; ok && time.Since(cached.ts) < ChatInfoCacheExpiry { 48 | return cached.data, nil 49 | } 50 | info, err := s.Client.GetConversationInfoContext(ctx, &slack.GetConversationInfoInput{ 51 | ChannelID: channelID, 52 | IncludeLocale: true, 53 | IncludeNumMembers: true, 54 | }) 55 | if err != nil { 56 | return nil, err 57 | } 58 | s.chatInfoCache[channelID] = chatInfoCacheEntry{ 59 | ts: time.Now(), 60 | data: info, 61 | } 62 | return info, nil 63 | } 64 | 65 | func (s *SlackClient) fetchChannelMembers(ctx context.Context, channelID string, limit int) (output map[networkid.UserID]bridgev2.ChatMember) { 66 | var cursor string 67 | output = make(map[networkid.UserID]bridgev2.ChatMember) 68 | for limit > 0 { 69 | chunkLimit := limit 70 | if chunkLimit > 200 { 71 | chunkLimit = 100 72 | } 73 | membersChunk, nextCursor, err := s.Client.GetUsersInConversation(&slack.GetUsersInConversationParameters{ 74 | ChannelID: channelID, 75 | Limit: limit, 76 | Cursor: cursor, 77 | }) 78 | if err != nil { 79 | zerolog.Ctx(ctx).Err(err).Msg("Failed to get channel members") 80 | break 81 | } 82 | for _, member := range membersChunk { 83 | evtSender := s.makeEventSender(member) 84 | output[evtSender.Sender] = bridgev2.ChatMember{EventSender: evtSender} 85 | } 86 | cursor = nextCursor 87 | limit -= len(membersChunk) 88 | if nextCursor == "" || len(membersChunk) < chunkLimit { 89 | break 90 | } 91 | } 92 | return 93 | } 94 | 95 | func compareStringFold(a, b string) int { 96 | for { 97 | if a == "" { 98 | if b == "" { 99 | return 0 100 | } 101 | return -1 102 | } else if b == "" { 103 | return 1 104 | } 105 | aRune, aSize := utf8.DecodeRuneInString(a) 106 | bRune, bSize := utf8.DecodeRuneInString(b) 107 | 108 | aLower := unicode.ToLower(aRune) 109 | bLower := unicode.ToLower(bRune) 110 | if aLower < bLower { 111 | return -1 112 | } else if bLower > aLower { 113 | return 1 114 | } 115 | a = a[aSize:] 116 | b = b[bSize:] 117 | } 118 | } 119 | 120 | func (s *SlackClient) generateGroupDMName(ctx context.Context, members []string) (string, error) { 121 | ghostNames := make([]string, 0, len(members)) 122 | for _, member := range members { 123 | if member == s.UserID { 124 | continue 125 | } 126 | ghost, err := s.UserLogin.Bridge.GetGhostByID(ctx, slackid.MakeUserID(s.TeamID, member)) 127 | if err != nil { 128 | return "", err 129 | } 130 | ghost.UpdateInfoIfNecessary(ctx, s.UserLogin, bridgev2.RemoteEventUnknown) 131 | if ghost.Name != "" { 132 | ghostNames = append(ghostNames, ghost.Name) 133 | } 134 | } 135 | slices.SortFunc(ghostNames, compareStringFold) 136 | return strings.Join(ghostNames, ", "), nil 137 | } 138 | 139 | func (s *SlackClient) generateMemberList(ctx context.Context, info *slack.Channel, fetchList bool) (members bridgev2.ChatMemberList) { 140 | selfUserID := slackid.MakeUserID(s.TeamID, s.UserID) 141 | if !fetchList { 142 | return bridgev2.ChatMemberList{ 143 | IsFull: false, 144 | TotalMemberCount: info.NumMembers, 145 | MemberMap: map[networkid.UserID]bridgev2.ChatMember{ 146 | selfUserID: {EventSender: s.makeEventSender(s.UserID)}, 147 | }, 148 | } 149 | } 150 | members.MemberMap = s.fetchChannelMembers(ctx, info.ID, s.Main.Config.ParticipantSyncCount) 151 | if _, hasSelf := members.MemberMap[selfUserID]; !hasSelf && info.IsMember { 152 | members.MemberMap[selfUserID] = bridgev2.ChatMember{EventSender: s.makeEventSender(s.UserID)} 153 | } 154 | members.IsFull = info.NumMembers > 0 && len(members.MemberMap) >= info.NumMembers 155 | return 156 | } 157 | 158 | func (s *SlackClient) wrapChatInfo(ctx context.Context, info *slack.Channel, isNew bool) (*bridgev2.ChatInfo, error) { 159 | var members bridgev2.ChatMemberList 160 | var avatar *bridgev2.Avatar 161 | var roomType database.RoomType 162 | var err error 163 | var extraUpdates func(ctx context.Context, portal *bridgev2.Portal) bool 164 | var userLocal *bridgev2.UserLocalPortalInfo 165 | switch { 166 | case info.IsMpIM: 167 | roomType = database.RoomTypeGroupDM 168 | members.IsFull = true 169 | members.MemberMap = make(map[networkid.UserID]bridgev2.ChatMember, len(info.Members)) 170 | for _, member := range info.Members { 171 | evtSender := s.makeEventSender(member) 172 | members.MemberMap[evtSender.Sender] = bridgev2.ChatMember{EventSender: evtSender} 173 | } 174 | info.Name, err = s.generateGroupDMName(ctx, info.Members) 175 | if err != nil { 176 | return nil, err 177 | } 178 | case info.IsIM: 179 | roomType = database.RoomTypeDM 180 | members.IsFull = true 181 | selfMember := bridgev2.ChatMember{EventSender: s.makeEventSender(s.UserID)} 182 | otherMember := bridgev2.ChatMember{EventSender: s.makeEventSender(info.User)} 183 | members.OtherUserID = otherMember.Sender 184 | members.MemberMap = map[networkid.UserID]bridgev2.ChatMember{ 185 | selfMember.Sender: selfMember, 186 | otherMember.Sender: otherMember, 187 | } 188 | ghost, err := s.UserLogin.Bridge.GetGhostByID(ctx, slackid.MakeUserID(s.TeamID, info.User)) 189 | if err != nil { 190 | return nil, err 191 | } 192 | ghost.UpdateInfoIfNecessary(ctx, s.UserLogin, bridgev2.RemoteEventUnknown) 193 | info.Name = ghost.Name 194 | case info.Name != "": 195 | members = s.generateMemberList(ctx, info, !s.Main.Config.ParticipantSyncOnlyOnCreate || isNew) 196 | if isNew && s.Main.Config.MuteChannelsByDefault { 197 | userLocal = &bridgev2.UserLocalPortalInfo{ 198 | MutedUntil: &event.MutedForever, 199 | } 200 | } 201 | default: 202 | return nil, fmt.Errorf("unrecognized channel type") 203 | } 204 | if s.Main.Config.WorkspaceAvatarInRooms && (roomType == database.RoomTypeDefault || roomType == database.RoomTypeGroupDM) { 205 | avatar = &bridgev2.Avatar{ 206 | ID: s.TeamPortal.AvatarID, 207 | Remove: s.TeamPortal.AvatarID == "", 208 | MXC: s.TeamPortal.AvatarMXC, 209 | Hash: s.TeamPortal.AvatarHash, 210 | } 211 | } 212 | members.TotalMemberCount = info.NumMembers 213 | var name *string 214 | if roomType != database.RoomTypeDM || len(members.MemberMap) == 1 { 215 | name = ptr.Ptr(s.Main.Config.FormatChannelName(&ChannelNameParams{ 216 | Channel: info, 217 | Team: &s.BootResp.Team.TeamInfo, 218 | IsNoteToSelf: info.IsIM && info.User == s.UserID, 219 | })) 220 | } 221 | return &bridgev2.ChatInfo{ 222 | Name: name, 223 | Topic: ptr.Ptr(info.Topic.Value), 224 | Avatar: avatar, 225 | Members: &members, 226 | Type: &roomType, 227 | ParentID: ptr.Ptr(slackid.MakeTeamPortalID(s.TeamID)), 228 | ExtraUpdates: extraUpdates, 229 | UserLocal: userLocal, 230 | CanBackfill: true, 231 | }, nil 232 | } 233 | 234 | func (s *SlackClient) fetchChatInfo(ctx context.Context, channelID string, isNew bool) (*bridgev2.ChatInfo, error) { 235 | info, err := s.fetchChatInfoWithCache(ctx, channelID) 236 | if err != nil { 237 | return nil, err 238 | } else if isNew && info.IsChannel && !info.IsMember { 239 | return nil, fmt.Errorf("request cancelled due to user not being in channel") 240 | } 241 | return s.wrapChatInfo(ctx, info, isNew) 242 | } 243 | 244 | func (s *SlackClient) getTeamInfo() *bridgev2.ChatInfo { 245 | name := s.Main.Config.FormatTeamName(&s.BootResp.Team.TeamInfo) 246 | avatarURL, _ := s.BootResp.Team.Icon["image_230"].(string) 247 | if s.BootResp.Team.Icon["image_default"] == true { 248 | avatarURL = "" 249 | } 250 | selfEvtSender := s.makeEventSender(s.UserID) 251 | return &bridgev2.ChatInfo{ 252 | Name: &name, 253 | Topic: nil, 254 | Avatar: makeAvatar(avatarURL, ""), 255 | Members: &bridgev2.ChatMemberList{ 256 | IsFull: false, 257 | TotalMemberCount: 0, 258 | MemberMap: map[networkid.UserID]bridgev2.ChatMember{selfEvtSender.Sender: {EventSender: selfEvtSender}}, 259 | PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)}, 260 | }, 261 | Type: ptr.Ptr(database.RoomTypeSpace), 262 | ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) { 263 | meta := portal.Metadata.(*slackid.PortalMetadata) 264 | if meta.TeamDomain != s.BootResp.Team.Domain { 265 | meta.TeamDomain = s.BootResp.Team.Domain 266 | changed = true 267 | } 268 | prefs := s.BootResp.Team.Prefs 269 | if prefs.MsgEditWindowMins != nil && (meta.EditMaxAge == nil || *meta.EditMaxAge != *prefs.MsgEditWindowMins) { 270 | meta.EditMaxAge = prefs.MsgEditWindowMins 271 | changed = true 272 | } 273 | if prefs.AllowMessageDeletion != nil && (meta.AllowDelete == nil || *meta.AllowDelete != *prefs.AllowMessageDeletion) { 274 | meta.AllowDelete = prefs.AllowMessageDeletion 275 | changed = true 276 | } 277 | return 278 | }, 279 | } 280 | } 281 | 282 | func (s *SlackClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { 283 | teamID, channelID := slackid.ParsePortalID(portal.ID) 284 | if teamID == "" { 285 | return nil, fmt.Errorf("invalid portal ID %q", portal.ID) 286 | } else if channelID == "" { 287 | return s.getTeamInfo(), nil 288 | } else { 289 | return s.fetchChatInfo(ctx, channelID, portal.MXID == "") 290 | } 291 | } 292 | 293 | func makeAvatar(avatarURL, slackAvatarHash string) *bridgev2.Avatar { 294 | avatarID := networkid.AvatarID(slackAvatarHash) 295 | if avatarID == "" { 296 | avatarID = networkid.AvatarID(avatarURL) 297 | } 298 | return &bridgev2.Avatar{ 299 | ID: avatarID, 300 | Get: func(ctx context.Context) ([]byte, error) { 301 | return downloadPlainFile(ctx, avatarURL, "avatar") 302 | }, 303 | Remove: avatarURL == "", 304 | } 305 | } 306 | 307 | func (s *SlackClient) wrapUserInfo(userID string, info *slack.User, botInfo *slack.Bot, ghost *bridgev2.Ghost) *bridgev2.UserInfo { 308 | var name *string 309 | var avatar *bridgev2.Avatar 310 | var extraUpdateAvatarID networkid.AvatarID 311 | isBot := userID == "USLACKBOT" 312 | if info != nil { 313 | name = ptr.Ptr(s.Main.Config.FormatDisplayname(&DisplaynameParams{ 314 | User: info, 315 | Team: &s.BootResp.Team.TeamInfo, 316 | })) 317 | avatarURL := info.Profile.ImageOriginal 318 | if avatarURL == "" && info.Profile.Image512 != "" { 319 | avatarURL = info.Profile.Image512 320 | } 321 | if avatarURL == "" && info.Profile.AvatarHash != "" { 322 | avatarURL = (&url.URL{ 323 | Scheme: "https", 324 | Host: "ca.slack-edge.com", 325 | Path: fmt.Sprintf("/%s-%s-%s-512", s.TeamID, info.ID, info.Profile.AvatarHash), 326 | }).String() 327 | } 328 | avatar = makeAvatar(avatarURL, info.Profile.AvatarHash) 329 | // Optimization to avoid updating legacy avatars 330 | oldAvatarID := string(ghost.AvatarID) 331 | if strings.HasPrefix(oldAvatarID, "https://") && (oldAvatarID == avatarURL || strings.Contains(oldAvatarID, info.Profile.AvatarHash)) { 332 | extraUpdateAvatarID = avatar.ID 333 | avatar = nil 334 | } 335 | isBot = isBot || info.IsBot || info.IsAppUser 336 | } else if botInfo != nil { 337 | name = ptr.Ptr(s.Main.Config.FormatBotDisplayname(botInfo, &s.BootResp.Team.TeamInfo)) 338 | avatar = makeAvatar(botInfo.Icons.Image72, botInfo.Icons.Image72) 339 | isBot = true 340 | } 341 | return &bridgev2.UserInfo{ 342 | Identifiers: []string{fmt.Sprintf("slack-internal:%s", userID)}, 343 | Name: name, 344 | Avatar: avatar, 345 | IsBot: &isBot, 346 | ExtraUpdates: func(ctx context.Context, ghost *bridgev2.Ghost) bool { 347 | meta := ghost.Metadata.(*slackid.GhostMetadata) 348 | meta.LastSync = jsontime.UnixNow() 349 | if info != nil { 350 | meta.SlackUpdatedTS = int64(info.Updated) 351 | } else if botInfo != nil { 352 | meta.SlackUpdatedTS = int64(botInfo.Updated) 353 | } 354 | if extraUpdateAvatarID != "" { 355 | ghost.AvatarID = extraUpdateAvatarID 356 | } 357 | return true 358 | }, 359 | } 360 | } 361 | 362 | func (s *SlackClient) syncManyUsers(ctx context.Context, ghosts map[string]*bridgev2.Ghost) { 363 | params := slack.GetCachedUsersParameters{ 364 | CheckInteraction: true, 365 | IncludeProfileOnlyUsers: true, 366 | UpdatedIDs: make(map[string]int64, len(ghosts)), 367 | } 368 | for _, ghost := range ghosts { 369 | meta := ghost.Metadata.(*slackid.GhostMetadata) 370 | _, userID := slackid.ParseUserID(ghost.ID) 371 | params.UpdatedIDs[userID] = meta.SlackUpdatedTS 372 | } 373 | zerolog.Ctx(ctx).Debug().Any("request_map", params.UpdatedIDs).Msg("Requesting user info") 374 | infos, err := s.Client.GetUsersCacheContext(ctx, s.TeamID, params) 375 | if err != nil { 376 | zerolog.Ctx(ctx).Err(err).Msg("Failed to get user info") 377 | return 378 | } 379 | zerolog.Ctx(ctx).Debug().Int("updated_user_count", len(infos)).Msg("Got user info") 380 | var wg sync.WaitGroup 381 | wg.Add(len(infos)) 382 | for userID, info := range infos { 383 | ghost, ok := ghosts[userID] 384 | if !ok { 385 | wg.Done() 386 | zerolog.Ctx(ctx).Warn().Str("user_id", userID).Msg("Got unexpected user info") 387 | continue 388 | } 389 | go func() { 390 | defer wg.Done() 391 | ghost.UpdateInfo(ctx, s.wrapUserInfo(userID, info, nil, ghost)) 392 | }() 393 | } 394 | wg.Wait() 395 | zerolog.Ctx(ctx).Debug().Msg("Finished syncing users") 396 | } 397 | 398 | func (s *SlackClient) fetchUserInfo(ctx context.Context, userID string, lastUpdated int64, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { 399 | if len(userID) == 0 { 400 | return nil, fmt.Errorf("empty user ID") 401 | } 402 | var info *slack.User 403 | var botInfo *slack.Bot 404 | var err error 405 | if userID[0] == 'B' { 406 | botInfo, err = s.Client.GetBotInfoContext(ctx, slack.GetBotInfoParameters{ 407 | Bot: userID, 408 | }) 409 | } else if s.IsRealUser { 410 | var infos map[string]*slack.User 411 | infos, err = s.Client.GetUsersCacheContext(ctx, s.TeamID, slack.GetCachedUsersParameters{ 412 | CheckInteraction: true, 413 | IncludeProfileOnlyUsers: true, 414 | UpdatedIDs: map[string]int64{ 415 | userID: lastUpdated, 416 | }, 417 | }) 418 | if infos != nil { 419 | var ok bool 420 | info, ok = infos[userID] 421 | if !ok { 422 | return nil, nil 423 | } 424 | } 425 | } else { 426 | info, err = s.Client.GetUserInfoContext(ctx, userID) 427 | } 428 | if err != nil { 429 | return nil, fmt.Errorf("failed to get user info for %q: %w", userID, err) 430 | } 431 | return s.wrapUserInfo(userID, info, botInfo, ghost), nil 432 | } 433 | 434 | const MinGhostSyncInterval = 4 * time.Hour 435 | 436 | func (s *SlackClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { 437 | if ghost.ID == "" { 438 | return nil, nil 439 | } 440 | meta := ghost.Metadata.(*slackid.GhostMetadata) 441 | if time.Since(meta.LastSync.Time) < MinGhostSyncInterval { 442 | return nil, nil 443 | } 444 | if s.IsRealUser && (ghost.Name != "" || time.Since(s.initialConnect) < 1*time.Minute) { 445 | s.userResyncQueue <- ghost 446 | return nil, nil 447 | } 448 | _, userID := slackid.ParseUserID(ghost.ID) 449 | return s.fetchUserInfo(ctx, userID, meta.SlackUpdatedTS, ghost) 450 | } 451 | -------------------------------------------------------------------------------- /pkg/connector/client.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "cmp" 21 | "context" 22 | "fmt" 23 | "slices" 24 | "strings" 25 | "sync" 26 | "sync/atomic" 27 | "time" 28 | 29 | "github.com/rs/zerolog" 30 | "github.com/slack-go/slack" 31 | "github.com/slack-go/slack/socketmode" 32 | "maunium.net/go/mautrix/bridgev2" 33 | "maunium.net/go/mautrix/bridgev2/networkid" 34 | "maunium.net/go/mautrix/bridgev2/status" 35 | 36 | "go.mau.fi/mautrix-slack/pkg/msgconv" 37 | "go.mau.fi/mautrix-slack/pkg/slackid" 38 | ) 39 | 40 | func init() { 41 | status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{ 42 | "slack-not-logged-in": "Please log in again", 43 | "slack-invalid-auth": "Invalid credentials, please log in again", 44 | "slack-user-removed-from-team": "You were removed from the Slack workspace", 45 | "slack-id-mismatch": "Unexpected internal error: got different user ID", 46 | }) 47 | } 48 | 49 | func makeSlackClient(log *zerolog.Logger, token, cookieToken, appToken string) *slack.Client { 50 | options := []slack.Option{ 51 | slack.OptionLog(slackgoZerolog{Logger: log.With().Str("component", "slackgo").Logger()}), 52 | slack.OptionDebug(log.GetLevel() == zerolog.TraceLevel), 53 | } 54 | if cookieToken != "" { 55 | options = append(options, slack.OptionCookie("d", cookieToken)) 56 | } else if appToken != "" { 57 | options = append(options, slack.OptionAppLevelToken(appToken)) 58 | } 59 | return slack.New(token, options...) 60 | } 61 | 62 | func (s *SlackConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { 63 | teamID, userID := slackid.ParseUserLoginID(login.ID) 64 | meta := login.Metadata.(*slackid.UserLoginMetadata) 65 | var sc *SlackClient 66 | if meta.Token == "" { 67 | sc = &SlackClient{Main: s, UserLogin: login, UserID: userID, TeamID: teamID} 68 | } else { 69 | client := makeSlackClient(&login.Log, meta.Token, meta.CookieToken, meta.AppToken) 70 | sc = &SlackClient{ 71 | Main: s, 72 | UserLogin: login, 73 | Client: client, 74 | UserID: userID, 75 | TeamID: teamID, 76 | IsRealUser: strings.HasPrefix(meta.Token, "xoxs-") || strings.HasPrefix(meta.Token, "xoxc-"), 77 | 78 | chatInfoCache: make(map[string]chatInfoCacheEntry), 79 | lastReadCache: make(map[string]string), 80 | userResyncQueue: make(chan *bridgev2.Ghost, 16), 81 | } 82 | if sc.IsRealUser { 83 | sc.RTM = client.NewRTM() 84 | } else { 85 | log := login.Log.With().Str("component", "slackgo socketmode").Logger() 86 | sc.SocketMode = socketmode.New( 87 | sc.Client, 88 | socketmode.OptionLog(slackgoZerolog{Logger: log}), 89 | socketmode.OptionDebug(log.GetLevel() == zerolog.TraceLevel), 90 | ) 91 | } 92 | } 93 | teamPortalKey := sc.makeTeamPortalKey(teamID) 94 | var err error 95 | sc.TeamPortal, err = s.br.UnlockedGetPortalByKey(ctx, teamPortalKey, false) 96 | if err != nil { 97 | return fmt.Errorf("failed to get team portal: %w", err) 98 | } 99 | login.Client = sc 100 | return nil 101 | } 102 | 103 | type chatInfoCacheEntry struct { 104 | ts time.Time 105 | data *slack.Channel 106 | } 107 | 108 | type SlackClient struct { 109 | Main *SlackConnector 110 | UserLogin *bridgev2.UserLogin 111 | Client *slack.Client 112 | RTM *slack.RTM 113 | SocketMode *socketmode.Client 114 | UserID string 115 | TeamID string 116 | BootResp *slack.ClientUserBootResponse 117 | TeamPortal *bridgev2.Portal 118 | IsRealUser bool 119 | Ghost *bridgev2.Ghost 120 | 121 | stopSocketMode context.CancelFunc 122 | stopResyncQueue atomic.Pointer[context.CancelFunc] 123 | userResyncQueue chan *bridgev2.Ghost 124 | initialConnect time.Time 125 | 126 | chatInfoCache map[string]chatInfoCacheEntry 127 | chatInfoCacheLock sync.Mutex 128 | lastReadCache map[string]string 129 | lastReadCacheLock sync.Mutex 130 | } 131 | 132 | var ( 133 | _ bridgev2.NetworkAPI = (*SlackClient)(nil) 134 | _ msgconv.SlackClientProvider = (*SlackClient)(nil) 135 | _ status.BridgeStateFiller = (*SlackClient)(nil) 136 | ) 137 | 138 | func (s *SlackClient) GetClient() *slack.Client { 139 | return s.Client 140 | } 141 | 142 | func (s *SlackClient) handleBootError(ctx context.Context, err error) { 143 | if err.Error() == "user_removed_from_team" || err.Error() == "invalid_auth" { 144 | s.invalidateSession(ctx, status.BridgeState{ 145 | StateEvent: status.StateBadCredentials, 146 | Error: status.BridgeStateErrorCode(fmt.Sprintf("slack-%s", strings.ReplaceAll(err.Error(), "_", "-"))), 147 | }) 148 | } else { 149 | s.UserLogin.BridgeState.Send(status.BridgeState{ 150 | StateEvent: status.StateUnknownError, 151 | Error: "slack-unknown-fetch-error", 152 | Message: fmt.Sprintf("Unknown error from Slack: %s", err.Error()), 153 | }) 154 | } 155 | } 156 | 157 | func (s *SlackClient) Connect(ctx context.Context) { 158 | if s.Client == nil { 159 | s.UserLogin.BridgeState.Send(status.BridgeState{ 160 | StateEvent: status.StateBadCredentials, 161 | Error: "slack-not-logged-in", 162 | }) 163 | return 164 | } 165 | var bootResp *slack.ClientUserBootResponse 166 | if s.IsRealUser { 167 | err := s.Client.FetchVersionData(ctx) 168 | if err != nil { 169 | zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to fetch version data") 170 | } 171 | // TODO do actual warm boots by saving last received ts somewhere 172 | bootResp, err = s.Client.ClientUserBootContext(ctx, time.Time{}) 173 | if err != nil { 174 | s.handleBootError(ctx, err) 175 | return 176 | } 177 | } else { 178 | teamResp, err := s.Client.GetTeamInfoContext(ctx) 179 | if err != nil { 180 | zerolog.Ctx(ctx).Err(err).Msg("Failed to fetch team info") 181 | s.handleBootError(ctx, err) 182 | return 183 | } 184 | userResp, err := s.Client.GetUserInfoContext(ctx, s.UserID) 185 | if err != nil { 186 | zerolog.Ctx(ctx).Err(err).Msg("Failed to fetch user info") 187 | s.handleBootError(ctx, err) 188 | return 189 | } 190 | bootResp = &slack.ClientUserBootResponse{ 191 | Self: *userResp, 192 | Team: slack.BootTeam{ 193 | TeamInfo: *teamResp, 194 | }, 195 | } 196 | } 197 | err := s.connect(ctx, bootResp) 198 | if err != nil { 199 | zerolog.Ctx(ctx).Err(err).Msg("Failed to connect") 200 | s.UserLogin.BridgeState.Send(status.BridgeState{ 201 | StateEvent: status.StateUnknownError, 202 | Error: "slack-unknown-connect-error", 203 | Message: fmt.Sprintf("Unknown error from Slack: %s", err.Error()), 204 | }) 205 | } 206 | } 207 | 208 | func (s *SlackClient) connect(ctx context.Context, bootResp *slack.ClientUserBootResponse) error { 209 | s.initialConnect = time.Now() 210 | s.BootResp = bootResp 211 | err := s.syncTeamPortal(ctx) 212 | if err != nil { 213 | return err 214 | } 215 | ghost, err := s.Main.br.GetGhostByID(ctx, slackid.MakeUserID(s.TeamID, s.UserID)) 216 | if err != nil { 217 | return fmt.Errorf("failed to get own ghost: %w", err) 218 | } 219 | ghost.UpdateInfo(ctx, s.wrapUserInfo(s.UserID, &s.BootResp.Self, nil, ghost)) 220 | s.UserLogin.RemoteProfile = status.RemoteProfile{ 221 | Phone: s.BootResp.Self.Profile.Phone, 222 | Email: s.BootResp.Self.Profile.Email, 223 | Name: ghost.Name, 224 | Avatar: ghost.AvatarMXC, 225 | } 226 | s.Ghost = ghost 227 | if s.IsRealUser { 228 | go s.consumeRTMEvents() 229 | go s.RTM.ManageConnection() 230 | go s.resyncUsers() 231 | } else { 232 | go s.consumeSocketModeEvents() 233 | go s.runSocketMode(ctx) 234 | } 235 | go s.SyncEmojis(ctx) 236 | go s.SyncChannels(ctx) 237 | return nil 238 | } 239 | 240 | func (s *SlackClient) consumeRTMEvents() { 241 | for evt := range s.RTM.IncomingEvents { 242 | s.HandleSlackEvent(evt.Data) 243 | } 244 | } 245 | 246 | func (s *SlackClient) consumeSocketModeEvents() { 247 | for evt := range s.SocketMode.Events { 248 | s.HandleSocketModeEvent(evt) 249 | } 250 | } 251 | 252 | func (s *SlackClient) resyncUsers() { 253 | ctx, cancel := context.WithCancel(context.Background()) 254 | defer cancel() 255 | ctx = s.UserLogin.Log.With().Str("component", "user resync loop").Logger().WithContext(ctx) 256 | if cancelOld := s.stopResyncQueue.Swap(&cancel); cancelOld != nil { 257 | (*cancelOld)() 258 | } 259 | const resyncWait = 30 * time.Second 260 | const shortResyncWait = 1 * time.Second 261 | forceShortWait := false 262 | for entry := range s.userResyncQueue { 263 | _, userID := slackid.ParseUserID(entry.ID) 264 | entries := map[string]*bridgev2.Ghost{userID: entry} 265 | var timer *time.Timer 266 | if entry.Name == "" || forceShortWait { 267 | forceShortWait = true 268 | timer = time.NewTimer(shortResyncWait) 269 | } else { 270 | timer = time.NewTimer(resyncWait) 271 | } 272 | CollectLoop: 273 | for { 274 | select { 275 | case entry = <-s.userResyncQueue: 276 | _, userID = slackid.ParseUserID(entry.ID) 277 | entries[userID] = entry 278 | if entry.Name == "" || forceShortWait { 279 | forceShortWait = true 280 | timer.Reset(shortResyncWait) 281 | } else { 282 | timer.Reset(resyncWait) 283 | } 284 | case <-timer.C: 285 | break CollectLoop 286 | } 287 | } 288 | go s.syncManyUsers(ctx, entries) 289 | forceShortWait = false 290 | } 291 | } 292 | 293 | func (s *SlackClient) runSocketMode(ctx context.Context) { 294 | var cancel context.CancelFunc 295 | ctx, cancel = context.WithCancel(ctx) 296 | defer cancel() 297 | s.stopSocketMode = cancel 298 | log := zerolog.Ctx(ctx) 299 | for ctx.Err() == nil { 300 | err := s.SocketMode.RunContext(ctx) 301 | if err != nil { 302 | log.Err(err).Msg("Error in socket mode connection") 303 | s.UserLogin.BridgeState.Send(status.BridgeState{ 304 | StateEvent: status.StateTransientDisconnect, 305 | Error: "slack-socketmode-error", 306 | Message: err.Error(), 307 | }) 308 | time.Sleep(10 * time.Second) 309 | } else { 310 | log.Info().Msg("Socket disconnected without error") 311 | return 312 | } 313 | } 314 | } 315 | 316 | func (s *SlackClient) syncTeamPortal(ctx context.Context) error { 317 | info := s.getTeamInfo() 318 | if s.TeamPortal.MXID == "" { 319 | err := s.TeamPortal.CreateMatrixRoom(ctx, s.UserLogin, info) 320 | if err != nil { 321 | return err 322 | } 323 | } else { 324 | s.TeamPortal.UpdateInfo(ctx, info, s.UserLogin, nil, time.Time{}) 325 | } 326 | return nil 327 | } 328 | 329 | func (s *SlackClient) setLastReadCache(channelID, ts string) { 330 | s.lastReadCacheLock.Lock() 331 | s.lastReadCache[channelID] = ts 332 | s.lastReadCacheLock.Unlock() 333 | } 334 | 335 | func (s *SlackClient) getLastReadCache(channelID string) string { 336 | s.lastReadCacheLock.Lock() 337 | defer s.lastReadCacheLock.Unlock() 338 | return s.lastReadCache[channelID] 339 | } 340 | 341 | func (s *SlackClient) getLatestMessageIDs(ctx context.Context) map[string]string { 342 | if !s.IsRealUser { 343 | return nil 344 | } 345 | log := zerolog.Ctx(ctx) 346 | clientCounts, err := s.Client.ClientCountsContext(ctx, &slack.ClientCountsParams{ 347 | ThreadCountsByChannel: true, 348 | OrgWideAware: true, 349 | IncludeFileChannels: true, 350 | }) 351 | if err != nil { 352 | log.Err(err).Msg("Failed to fetch client counts") 353 | return nil 354 | } 355 | latestMessageIDs := make(map[string]string, len(clientCounts.Channels)+len(clientCounts.MpIMs)+len(clientCounts.IMs)) 356 | lastReadCache := make(map[string]string, len(clientCounts.Channels)+len(clientCounts.MpIMs)+len(clientCounts.IMs)) 357 | for _, ch := range clientCounts.Channels { 358 | latestMessageIDs[ch.ID] = ch.Latest 359 | lastReadCache[ch.ID] = ch.LastRead 360 | } 361 | for _, ch := range clientCounts.MpIMs { 362 | latestMessageIDs[ch.ID] = ch.Latest 363 | lastReadCache[ch.ID] = ch.LastRead 364 | } 365 | for _, ch := range clientCounts.IMs { 366 | latestMessageIDs[ch.ID] = ch.Latest 367 | lastReadCache[ch.ID] = ch.LastRead 368 | } 369 | s.lastReadCacheLock.Lock() 370 | s.lastReadCache = lastReadCache 371 | s.lastReadCacheLock.Unlock() 372 | return latestMessageIDs 373 | } 374 | 375 | func (s *SlackClient) SyncChannels(ctx context.Context) { 376 | log := zerolog.Ctx(ctx) 377 | latestMessageIDs := s.getLatestMessageIDs(ctx) 378 | userPortals, err := s.UserLogin.Bridge.DB.UserPortal.GetAllForLogin(ctx, s.UserLogin.UserLogin) 379 | if err != nil { 380 | log.Err(err).Msg("Failed to fetch user portals") 381 | return 382 | } 383 | existingPortals := make(map[networkid.PortalKey]struct{}, len(userPortals)) 384 | for _, up := range userPortals { 385 | existingPortals[up.Portal] = struct{}{} 386 | } 387 | var channels []*slack.Channel 388 | token := s.UserLogin.Metadata.(*slackid.UserLoginMetadata).Token 389 | if s.IsRealUser && (strings.HasPrefix(token, "xoxs-") || s.Main.Config.Backfill.ConversationCount == -1) { 390 | for _, ch := range s.BootResp.Channels { 391 | ch.IsMember = true 392 | channels = append(channels, &ch.Channel) 393 | } 394 | for _, ch := range s.BootResp.IMs { 395 | ch.IsMember = true 396 | channels = append(channels, &ch.Channel) 397 | } 398 | log.Debug().Int("channel_count", len(channels)).Msg("Using channels from boot response for sync") 399 | } else { 400 | totalLimit := s.Main.Config.Backfill.ConversationCount 401 | if totalLimit < 0 { 402 | totalLimit = 50 403 | } 404 | var cursor string 405 | log.Debug().Int("total_limit", totalLimit).Msg("Fetching conversation list for sync") 406 | for totalLimit > 0 { 407 | reqLimit := totalLimit 408 | if totalLimit > 200 { 409 | reqLimit = 100 410 | } 411 | channelsChunk, nextCursor, err := s.Client.GetConversationsForUserContext(ctx, &slack.GetConversationsForUserParameters{ 412 | Types: []string{"public_channel", "private_channel", "mpim", "im"}, 413 | Limit: reqLimit, 414 | Cursor: cursor, 415 | }) 416 | if err != nil { 417 | log.Err(err).Msg("Failed to fetch conversations for sync") 418 | return 419 | } 420 | log.Debug().Int("chunk_size", len(channelsChunk)).Msg("Fetched chunk of conversations") 421 | for _, channel := range channelsChunk { 422 | channels = append(channels, &channel) 423 | } 424 | if nextCursor == "" || len(channelsChunk) == 0 { 425 | break 426 | } 427 | totalLimit -= len(channelsChunk) 428 | cursor = nextCursor 429 | } 430 | } 431 | if latestMessageIDs != nil { 432 | slices.SortFunc(channels, func(a, b *slack.Channel) int { 433 | return cmp.Compare(latestMessageIDs[a.ID], latestMessageIDs[b.ID]) 434 | }) 435 | } 436 | for _, ch := range channels { 437 | portalKey := s.makePortalKey(ch) 438 | delete(existingPortals, portalKey) 439 | var latestMessageID string 440 | var hasCounts bool 441 | if !s.IsRealUser { 442 | ch, err = s.Client.GetConversationInfoContext(ctx, &slack.GetConversationInfoInput{ 443 | ChannelID: ch.ID, 444 | IncludeLocale: true, 445 | IncludeNumMembers: true, 446 | }) 447 | if err != nil { 448 | log.Err(err).Str("channel_id", ch.ID).Msg("Failed to fetch channel info") 449 | continue 450 | } 451 | hasCounts = ch.Latest != nil 452 | if hasCounts { 453 | latestMessageID = ch.Latest.Timestamp 454 | } 455 | } else { 456 | latestMessageID, hasCounts = latestMessageIDs[ch.ID] 457 | } 458 | // TODO fetch latest message from channel info when using bot account? 459 | s.Main.br.QueueRemoteEvent(s.UserLogin, &SlackChatResync{ 460 | SlackEventMeta: &SlackEventMeta{ 461 | Type: bridgev2.RemoteEventChatResync, 462 | PortalKey: portalKey, 463 | CreatePortal: hasCounts || (!ch.IsIM && !ch.IsMpIM), 464 | LogContext: func(c zerolog.Context) zerolog.Context { 465 | return c. 466 | Object("portal_key", portalKey). 467 | Str("slack_latest_message_id", latestMessageID) 468 | }, 469 | }, 470 | Client: s, 471 | LatestMessage: latestMessageID, 472 | PreFetchedInfo: ch, 473 | }) 474 | } 475 | for portalKey := range existingPortals { 476 | _, channelID := slackid.ParsePortalID(portalKey.ID) 477 | if channelID == "" { 478 | continue 479 | } 480 | latestMessageID, ok := latestMessageIDs[channelID] 481 | if !ok { 482 | // TODO delete portal if it's actually gone? 483 | continue 484 | } 485 | s.Main.br.QueueRemoteEvent(s.UserLogin, &SlackChatResync{ 486 | SlackEventMeta: &SlackEventMeta{ 487 | Type: bridgev2.RemoteEventChatResync, 488 | PortalKey: portalKey, 489 | }, 490 | Client: s, 491 | LatestMessage: latestMessageID, 492 | }) 493 | } 494 | } 495 | 496 | func (s *SlackClient) Disconnect() { 497 | s.disconnect() 498 | s.Client = nil 499 | } 500 | 501 | func (s *SlackClient) disconnect() { 502 | if rtm := s.RTM; rtm != nil { 503 | err := rtm.Disconnect() 504 | if err != nil { 505 | s.UserLogin.Log.Debug().Err(err).Msg("Failed to disconnect RTM") 506 | } 507 | // TODO stop consumeEvents? 508 | s.RTM = nil 509 | } 510 | if stop := s.stopSocketMode; stop != nil { 511 | stop() 512 | s.SocketMode = nil 513 | } 514 | if cancel := s.stopResyncQueue.Swap(nil); cancel != nil { 515 | (*cancel)() 516 | } 517 | } 518 | 519 | func (s *SlackClient) IsLoggedIn() bool { 520 | return s.Client != nil 521 | } 522 | 523 | func (s *SlackClient) LogoutRemote(ctx context.Context) { 524 | s.disconnect() 525 | if s.IsRealUser { 526 | if cli := s.Client; cli != nil { 527 | _, err := cli.SendAuthSignoutContext(ctx) 528 | if err != nil { 529 | s.UserLogin.Log.Err(err).Msg("Failed to send sign out request to Slack") 530 | } 531 | } 532 | } 533 | s.Client = nil 534 | meta := s.UserLogin.Metadata.(*slackid.UserLoginMetadata) 535 | meta.Token = "" 536 | meta.CookieToken = "" 537 | meta.AppToken = "" 538 | } 539 | 540 | func (s *SlackClient) invalidateSession(ctx context.Context, state status.BridgeState) { 541 | meta := s.UserLogin.Metadata.(*slackid.UserLoginMetadata) 542 | meta.Token = "" 543 | meta.CookieToken = "" 544 | err := s.UserLogin.Save(ctx) 545 | if err != nil { 546 | zerolog.Ctx(ctx).Err(err).Msg("Failed to save user login after invalidating session") 547 | } 548 | s.Disconnect() 549 | s.UserLogin.BridgeState.Send(state) 550 | } 551 | 552 | func (s *SlackClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool { 553 | return slackid.UserIDToUserLoginID(userID) == s.UserLogin.ID 554 | } 555 | 556 | func (s *SlackClient) FillBridgeState(state status.BridgeState) status.BridgeState { 557 | state.RemoteID = s.TeamID 558 | if state.Info == nil { 559 | state.Info = make(map[string]any) 560 | } 561 | state.Info["slack_user_id"] = s.UserID 562 | state.Info["real_login_id"] = s.UserLogin.ID 563 | return state 564 | } 565 | -------------------------------------------------------------------------------- /pkg/connector/config.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | _ "embed" 21 | "strings" 22 | "text/template" 23 | 24 | "github.com/slack-go/slack" 25 | up "go.mau.fi/util/configupgrade" 26 | "gopkg.in/yaml.v3" 27 | ) 28 | 29 | //go:embed example-config.yaml 30 | var ExampleConfig string 31 | 32 | type Config struct { 33 | DisplaynameTemplate string `yaml:"displayname_template"` 34 | ChannelNameTemplate string `yaml:"channel_name_template"` 35 | TeamNameTemplate string `yaml:"team_name_template"` 36 | 37 | CustomEmojiReactions bool `yaml:"custom_emoji_reactions"` 38 | WorkspaceAvatarInRooms bool `yaml:"workspace_avatar_in_rooms"` 39 | ParticipantSyncCount int `yaml:"participant_sync_count"` 40 | ParticipantSyncOnlyOnCreate bool `yaml:"participant_sync_only_on_create"` 41 | MuteChannelsByDefault bool `yaml:"mute_channels_by_default"` 42 | 43 | Backfill BackfillConfig `yaml:"backfill"` 44 | 45 | displaynameTemplate *template.Template `yaml:"-"` 46 | channelNameTemplate *template.Template `yaml:"-"` 47 | teamNameTemplate *template.Template `yaml:"-"` 48 | } 49 | 50 | type BackfillConfig struct { 51 | ConversationCount int `yaml:"conversation_count"` 52 | Enabled bool `yaml:"enabled"` 53 | } 54 | 55 | type umConfig Config 56 | 57 | func (c *Config) UnmarshalYAML(node *yaml.Node) error { 58 | err := node.Decode((*umConfig)(c)) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate) 64 | if err != nil { 65 | return err 66 | } 67 | c.channelNameTemplate, err = template.New("channel_name").Parse(c.ChannelNameTemplate) 68 | if err != nil { 69 | return err 70 | } 71 | c.teamNameTemplate, err = template.New("team_name").Parse(c.TeamNameTemplate) 72 | if err != nil { 73 | return err 74 | } 75 | return nil 76 | } 77 | 78 | func executeTemplate(tpl *template.Template, data any) string { 79 | var buffer strings.Builder 80 | _ = tpl.Execute(&buffer, data) 81 | return strings.TrimSpace(buffer.String()) 82 | } 83 | 84 | type DisplaynameParams struct { 85 | *slack.User 86 | Team *slack.TeamInfo 87 | } 88 | 89 | func (c *Config) FormatDisplayname(user *DisplaynameParams) string { 90 | return executeTemplate(c.displaynameTemplate, user) 91 | } 92 | 93 | func (c *Config) FormatBotDisplayname(bot *slack.Bot, team *slack.TeamInfo) string { 94 | return c.FormatDisplayname(&DisplaynameParams{ 95 | User: &slack.User{ 96 | ID: bot.ID, 97 | Name: bot.Name, 98 | IsBot: true, 99 | Deleted: bot.Deleted, 100 | Updated: bot.Updated, 101 | Profile: slack.UserProfile{ 102 | DisplayName: bot.Name, 103 | }, 104 | }, 105 | Team: team, 106 | }) 107 | } 108 | 109 | type ChannelNameParams struct { 110 | *slack.Channel 111 | Team *slack.TeamInfo 112 | IsNoteToSelf bool 113 | } 114 | 115 | func (c *Config) FormatChannelName(params *ChannelNameParams) string { 116 | return executeTemplate(c.channelNameTemplate, params) 117 | } 118 | 119 | func (c *Config) FormatTeamName(params *slack.TeamInfo) string { 120 | return executeTemplate(c.teamNameTemplate, params) 121 | } 122 | 123 | func (s *SlackConnector) GetConfig() (example string, data any, upgrader up.Upgrader) { 124 | return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig) 125 | } 126 | 127 | func upgradeConfig(helper up.Helper) { 128 | helper.Copy(up.Str, "displayname_template") 129 | helper.Copy(up.Str, "channel_name_template") 130 | helper.Copy(up.Str, "team_name_template") 131 | helper.Copy(up.Bool, "custom_emoji_reactions") 132 | helper.Copy(up.Bool, "workspace_avatar_in_rooms") 133 | helper.Copy(up.Int, "participant_sync_count") 134 | helper.Copy(up.Bool, "participant_sync_only_on_create") 135 | helper.Copy(up.Bool, "mute_channels_by_default") 136 | helper.Copy(up.Int, "backfill", "conversation_count") 137 | } 138 | -------------------------------------------------------------------------------- /pkg/connector/connector.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | 22 | "maunium.net/go/mautrix/bridgev2" 23 | 24 | "go.mau.fi/mautrix-slack/pkg/connector/slackdb" 25 | "go.mau.fi/mautrix-slack/pkg/msgconv" 26 | ) 27 | 28 | type SlackConnector struct { 29 | br *bridgev2.Bridge 30 | Config Config 31 | DB *slackdb.SlackDB 32 | MsgConv *msgconv.MessageConverter 33 | } 34 | 35 | var ( 36 | _ bridgev2.NetworkConnector = (*SlackConnector)(nil) 37 | _ bridgev2.MaxFileSizeingNetwork = (*SlackConnector)(nil) 38 | ) 39 | 40 | func (s *SlackConnector) Init(bridge *bridgev2.Bridge) { 41 | s.br = bridge 42 | s.DB = slackdb.New(bridge.DB.Database, bridge.Log.With().Str("db_section", "slack").Logger()) 43 | s.MsgConv = msgconv.New(bridge, s.DB) 44 | bridge.Config.PersonalFilteringSpaces = false 45 | } 46 | 47 | func (s *SlackConnector) SetMaxFileSize(maxSize int64) { 48 | s.MsgConv.MaxFileSize = int(maxSize) 49 | } 50 | 51 | func (s *SlackConnector) Start(ctx context.Context) error { 52 | return s.DB.Upgrade(ctx) 53 | } 54 | 55 | func (s *SlackConnector) GetName() bridgev2.BridgeName { 56 | return bridgev2.BridgeName{ 57 | DisplayName: "Slack", 58 | NetworkURL: "https://slack.com", 59 | NetworkIcon: "mxc://maunium.net/pVtzLmChZejGxLqmXtQjFxem", 60 | NetworkID: "slack", 61 | BeeperBridgeType: "slackgo", 62 | DefaultPort: 29335, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/connector/dbmeta.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "maunium.net/go/mautrix/bridgev2/database" 21 | 22 | "go.mau.fi/mautrix-slack/pkg/slackid" 23 | ) 24 | 25 | func (s *SlackConnector) GetDBMetaTypes() database.MetaTypes { 26 | return database.MetaTypes{ 27 | Portal: func() any { 28 | return &slackid.PortalMetadata{} 29 | }, 30 | Ghost: func() any { 31 | return &slackid.GhostMetadata{} 32 | }, 33 | Message: func() any { 34 | return &slackid.MessageMetadata{} 35 | }, 36 | Reaction: nil, 37 | UserLogin: func() any { 38 | return &slackid.UserLoginMetadata{} 39 | }, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pkg/connector/emoji.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io" 23 | "net/http" 24 | "strings" 25 | 26 | "github.com/rs/zerolog" 27 | "github.com/slack-go/slack" 28 | "maunium.net/go/mautrix/bridgev2" 29 | "maunium.net/go/mautrix/id" 30 | 31 | "go.mau.fi/mautrix-slack/pkg/connector/slackdb" 32 | "go.mau.fi/mautrix-slack/pkg/emoji" 33 | ) 34 | 35 | func (s *SlackClient) handleEmojiChange(ctx context.Context, evt *slack.EmojiChangedEvent) { 36 | defer s.Main.DB.Emoji.WithLock(s.TeamID)() 37 | log := zerolog.Ctx(ctx) 38 | log.UpdateContext(func(c zerolog.Context) zerolog.Context { 39 | return c.Str("subtype", evt.SubType) 40 | }) 41 | switch evt.SubType { 42 | case "add": 43 | s.addEmoji(ctx, evt.Name, evt.Value) 44 | log.Debug().Str("emoji_id", evt.Name).Msg("Handled emoji addition") 45 | case "remove": 46 | err := s.Main.DB.Emoji.DeleteMany(ctx, s.TeamID, evt.Names...) 47 | if err != nil { 48 | log.Err(err).Strs("emoji_ids", evt.Names).Msg("Failed to delete emojis from database") 49 | } else { 50 | log.Debug().Strs("emoji_ids", evt.Names).Msg("Handled emoji deletion") 51 | } 52 | case "rename": 53 | dbEmoji, err := s.Main.DB.Emoji.GetBySlackID(ctx, s.TeamID, evt.OldName) 54 | if err != nil { 55 | log.Err(err).Msg("Failed to get emoji from database for renaming") 56 | } else if dbEmoji == nil || dbEmoji.Value != evt.Value { 57 | log.Warn().Msg("Old emoji not found for renaming, adding new one") 58 | s.addEmoji(ctx, evt.NewName, evt.Value) 59 | } else if err = s.Main.DB.Emoji.Rename(ctx, dbEmoji, evt.NewName); err != nil { 60 | log.Err(err).Msg("Failed to rename emoji in database") 61 | } else { 62 | log.Debug().Str("old_id", evt.OldName).Str("new_name", evt.NewName).Msg("Handled emoji rename") 63 | } 64 | default: 65 | log.Warn().Msg("Unknown emoji change subtype, resyncing emojis") 66 | err := s.syncEmojis(ctx, false) 67 | if err != nil { 68 | log.Err(err).Msg("Failed to resync emojis") 69 | } 70 | } 71 | } 72 | 73 | func (s *SlackClient) addEmoji(ctx context.Context, emojiName, emojiValue string) *slackdb.Emoji { 74 | log := zerolog.Ctx(ctx) 75 | dbEmoji, err := s.Main.DB.Emoji.GetBySlackID(ctx, s.TeamID, emojiName) 76 | if err != nil { 77 | log.Err(err). 78 | Str("emoji_name", emojiName). 79 | Str("emoji_value", emojiValue). 80 | Msg("Failed to check if emoji already exists") 81 | return nil 82 | } 83 | var newAlias string 84 | var newImageMXC id.ContentURIString 85 | if strings.HasPrefix(emojiValue, "alias:") { 86 | newAlias = strings.TrimPrefix(emojiValue, "alias:") 87 | aliasTarget, isImage, _ := s.tryGetEmoji(ctx, newAlias, false, false) 88 | if isImage { 89 | newImageMXC = id.ContentURIString(aliasTarget) 90 | } 91 | if dbEmoji != nil && dbEmoji.Value == emojiValue && dbEmoji.Alias == newAlias && dbEmoji.ImageMXC == newImageMXC { 92 | return dbEmoji 93 | } 94 | } else if dbEmoji != nil && dbEmoji.Value == emojiValue { 95 | return dbEmoji 96 | } 97 | if dbEmoji == nil { 98 | dbEmoji = &slackdb.Emoji{ 99 | TeamID: s.TeamID, 100 | EmojiID: emojiName, 101 | } 102 | } 103 | if newImageMXC != "" || (dbEmoji.Value != "" && dbEmoji.Value != emojiValue) { 104 | dbEmoji.ImageMXC = newImageMXC 105 | } 106 | dbEmoji.Value = emojiValue 107 | dbEmoji.Alias = newAlias 108 | err = s.Main.DB.Emoji.Put(ctx, dbEmoji) 109 | if err != nil { 110 | log.Err(err). 111 | Str("emoji_name", emojiName). 112 | Str("emoji_value", emojiValue). 113 | Msg("Failed to save custom emoji to database") 114 | } 115 | return dbEmoji 116 | } 117 | 118 | func downloadPlainFile(ctx context.Context, url, thing string) ([]byte, error) { 119 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 120 | if err != nil { 121 | return nil, fmt.Errorf("failed to prepare request: %w", err) 122 | } 123 | 124 | getResp, err := http.DefaultClient.Do(req) 125 | if err != nil { 126 | return nil, fmt.Errorf("failed to download %s: %w", thing, err) 127 | } 128 | 129 | data, err := io.ReadAll(getResp.Body) 130 | _ = getResp.Body.Close() 131 | if err != nil { 132 | return nil, fmt.Errorf("failed to read %s data: %w", thing, err) 133 | } 134 | return data, nil 135 | } 136 | 137 | func reuploadEmoji(ctx context.Context, intent bridgev2.MatrixAPI, url string) (id.ContentURIString, error) { 138 | data, err := downloadPlainFile(ctx, url, "emoji") 139 | if err != nil { 140 | return "", err 141 | } 142 | 143 | mime := http.DetectContentType(data) 144 | resp, _, err := intent.UploadMedia(ctx, "", data, "", mime) 145 | if err != nil { 146 | return "", fmt.Errorf("failed to upload avatar to Matrix: %w", err) 147 | } 148 | 149 | return resp, nil 150 | } 151 | 152 | func (s *SlackClient) ResyncEmojisDueToNotFound(ctx context.Context) bool { 153 | lock := s.Main.DB.Emoji.GetLock(s.TeamID) 154 | if !lock.TryLock() { 155 | return false 156 | } 157 | defer lock.Unlock() 158 | err := s.syncEmojis(ctx, false) 159 | if err != nil { 160 | zerolog.Ctx(ctx).Err(err).Msg("Failed to sync emojis after emoji wasn't found") 161 | return false 162 | } 163 | return true 164 | } 165 | 166 | func (s *SlackClient) SyncEmojis(ctx context.Context) { 167 | defer s.Main.DB.Emoji.WithLock(s.TeamID)() 168 | err := s.syncEmojis(ctx, true) 169 | if err != nil { 170 | zerolog.Ctx(ctx).Err(err).Msg("Failed to sync emojis") 171 | } 172 | } 173 | 174 | func (s *SlackClient) syncEmojis(ctx context.Context, onlyIfCountMismatch bool) error { 175 | log := zerolog.Ctx(ctx).With().Str("action", "sync emojis").Logger() 176 | resp, err := s.Client.GetEmojiContext(ctx) 177 | if err != nil { 178 | log.Err(err).Msg("Failed to fetch emoji list") 179 | return err 180 | } 181 | if onlyIfCountMismatch { 182 | emojiCount, err := s.Main.DB.Emoji.GetEmojiCount(ctx, s.TeamID) 183 | if err != nil { 184 | log.Err(err).Msg("Failed to get emoji count from database") 185 | return nil 186 | } else if emojiCount == len(resp) { 187 | log.Debug().Int("emoji_count", len(resp)).Msg("Not syncing emojis: count is already correct") 188 | return nil 189 | } 190 | log.Debug(). 191 | Int("emoji_count", len(resp)). 192 | Int("cached_emoji_count", emojiCount). 193 | Msg("Syncing team emojis as server has different number than cache") 194 | } else { 195 | log.Debug().Int("emoji_count", len(resp)).Msg("Syncing team emojis (didn't check cache)") 196 | } 197 | 198 | deferredAliases := make(map[string]string) 199 | created := make(map[string]*slackdb.Emoji, len(resp)) 200 | existingIDs := make([]string, 0, len(resp)) 201 | 202 | for key, url := range resp { 203 | existingIDs = append(existingIDs, key) 204 | if strings.HasPrefix(url, "alias:") { 205 | deferredAliases[key] = strings.TrimPrefix(url, "alias:") 206 | } else { 207 | addedEmoji := s.addEmoji(ctx, key, url) 208 | if addedEmoji != nil { 209 | created[key] = addedEmoji 210 | } 211 | } 212 | } 213 | 214 | for key, alias := range deferredAliases { 215 | dbEmoji := &slackdb.Emoji{ 216 | TeamID: s.TeamID, 217 | EmojiID: key, 218 | Value: fmt.Sprintf("alias:%s", alias), 219 | Alias: alias, 220 | } 221 | if otherEmoji, ok := created[alias]; ok { 222 | dbEmoji.ImageMXC = otherEmoji.ImageMXC 223 | } 224 | err = s.Main.DB.Emoji.Put(ctx, dbEmoji) 225 | if err != nil { 226 | log.Err(err). 227 | Str("emoji_id", dbEmoji.EmojiID). 228 | Str("alias", dbEmoji.Alias). 229 | Str("image_mxc", string(dbEmoji.ImageMXC)). 230 | Msg("Failed to save deferred emoji alias to database") 231 | } 232 | } 233 | 234 | emojiCount, err := s.Main.DB.Emoji.GetEmojiCount(ctx, s.TeamID) 235 | if err != nil { 236 | log.Err(err).Msg("Failed to get emoji count from database to check if emojis need to be pruned") 237 | } else if emojiCount > len(resp) { 238 | err = s.Main.DB.Emoji.Prune(ctx, s.TeamID, existingIDs...) 239 | if err != nil { 240 | log.Err(err).Msg("Failed to prune removed emojis from database") 241 | } 242 | } 243 | 244 | return nil 245 | } 246 | 247 | func (s *SlackClient) tryGetEmoji(ctx context.Context, shortcode string, ensureUploaded, allowRecurse bool) (val string, isImage, found bool) { 248 | if unicode := emoji.GetUnicode(shortcode); unicode != "" { 249 | return unicode, false, true 250 | } 251 | 252 | dbEmoji, err := s.Main.DB.Emoji.GetBySlackID(ctx, s.TeamID, shortcode) 253 | if err != nil { 254 | zerolog.Ctx(ctx).Err(err).Str("shortcode", shortcode).Msg("Failed to get emoji from database") 255 | return 256 | } else if dbEmoji == nil { 257 | return 258 | } 259 | found = true 260 | if dbEmoji.ImageMXC != "" { 261 | val = string(dbEmoji.ImageMXC) 262 | isImage = true 263 | } else if strings.HasPrefix(dbEmoji.Value, "alias:") { 264 | if !allowRecurse { 265 | zerolog.Ctx(ctx).Warn().Str("shortcode", shortcode).Msg("Not recursing into emoji alias") 266 | return "", false, true 267 | } 268 | val, isImage, _ = s.tryGetEmoji(ctx, strings.TrimPrefix(dbEmoji.Value, "alias:"), ensureUploaded, false) 269 | } else if ensureUploaded { 270 | defer s.Main.DB.Emoji.WithLock(s.TeamID)() 271 | dbEmoji.ImageMXC, err = reuploadEmoji(ctx, s.Main.br.Bot, dbEmoji.Value) 272 | if err != nil { 273 | zerolog.Ctx(ctx).Err(err). 274 | Str("shortcode", shortcode). 275 | Str("url", dbEmoji.Value). 276 | Msg("Failed to reupload emoji") 277 | return 278 | } 279 | err = s.Main.DB.Emoji.SaveMXC(ctx, dbEmoji) 280 | if err != nil { 281 | zerolog.Ctx(ctx).Err(err). 282 | Str("shortcode", shortcode). 283 | Str("url", dbEmoji.Value). 284 | Str("mxc", string(dbEmoji.ImageMXC)). 285 | Msg("Failed to save reuploaded emoji") 286 | } 287 | val = string(dbEmoji.ImageMXC) 288 | isImage = true 289 | } 290 | return 291 | } 292 | 293 | func (s *SlackClient) GetEmoji(ctx context.Context, shortcode string) (string, bool) { 294 | emojiVal, isImage, found := s.tryGetEmoji(ctx, shortcode, true, true) 295 | if !found && s.ResyncEmojisDueToNotFound(ctx) { 296 | emojiVal, isImage, _ = s.tryGetEmoji(ctx, shortcode, true, true) 297 | } 298 | if emojiVal == "" { 299 | emojiVal = fmt.Sprintf(":%s:", shortcode) 300 | isImage = false 301 | } 302 | return emojiVal, isImage 303 | } 304 | -------------------------------------------------------------------------------- /pkg/connector/example-config.yaml: -------------------------------------------------------------------------------- 1 | # Displayname template for Slack users. Available variables: 2 | # .Name - The username of the user 3 | # .Team.Name - The name of the team the channel is in 4 | # .Team.Domain - The Slack subdomain of the team the channel is in 5 | # .ID - The internal ID of the user 6 | # .IsBot - Whether the user is a bot 7 | # .Profile.DisplayName - The username or real name of the user (depending on settings) 8 | # Variables only available for users (not bots): 9 | # .TeamID - The internal ID of the workspace the user is in 10 | # .TZ - The timezone region of the user (e.g. Europe/London) 11 | # .TZLabel - The label of the timezone of the user (e.g. Greenwich Mean Time) 12 | # .TZOffset - The UTC offset of the timezone of the user (e.g. 0) 13 | # .Profile.RealName - The real name of the user 14 | # .Profile.FirstName - The first name of the user 15 | # .Profile.LastName - The last name of the user 16 | # .Profile.Title - The job title of the user 17 | # .Profile.Pronouns - The pronouns of the user 18 | # .Profile.Email - The email address of the user 19 | # .Profile.Phone - The formatted phone number of the user 20 | displayname_template: '{{or .Profile.DisplayName .Profile.RealName .Name}}{{if .IsBot}} (bot){{end}}' 21 | # Channel name template for Slack channels (all types). Available variables: 22 | # .Name - The name of the channel 23 | # .Team.Name - The name of the team the channel is in 24 | # .Team.Domain - The Slack subdomain of the team the channel is in 25 | # .ID - The internal ID of the channel 26 | # .IsNoteToSelf - Whether the channel is a DM with yourself 27 | # .IsGeneral - Whether the channel is the #general channel 28 | # .IsChannel - Whether the channel is a channel (rather than a DM) 29 | # .IsPrivate - Whether the channel is private 30 | # .IsIM - Whether the channel is a one-to-one DM 31 | # .IsMpIM - Whether the channel is a group DM 32 | # .IsShared - Whether the channel is shared with another workspace. 33 | # .IsExtShared - Whether the channel is shared with an external organization. 34 | # .IsOrgShared - Whether the channel is shared with an organization in the same enterprise grid. 35 | channel_name_template: '{{if and .IsChannel (not .IsPrivate)}}#{{end}}{{.Name}}{{if .IsNoteToSelf}} (you){{end}}' 36 | # Displayname template for Slack workspaces. Available variables: 37 | # .Name - The name of the team 38 | # .Domain - The Slack subdomain of the team 39 | # .ID - The internal ID of the team 40 | team_name_template: "{{ .Name }}" 41 | 42 | # Should incoming custom emoji reactions be bridged as mxc:// URIs? 43 | # If set to false, custom emoji reactions will be bridged as the shortcode instead, and the image won't be available. 44 | custom_emoji_reactions: true 45 | # Should channels and group DMs have the workspace icon as the Matrix room avatar? 46 | workspace_avatar_in_rooms: false 47 | # Number of participants to sync in channels (doesn't affect group DMs) 48 | participant_sync_count: 5 49 | # Should channel participants only be synced when creating the room? 50 | # If you want participants to always be accurately synced, set participant_sync_count to a high value and this to false. 51 | participant_sync_only_on_create: true 52 | # Should channel portals be muted by default? 53 | mute_channels_by_default: false 54 | 55 | # Options for backfilling messages from Slack. 56 | backfill: 57 | # Number of conversations to fetch from Slack when syncing workspace. 58 | # This option applies even if message backfill is disabled below. 59 | # If set to -1, all chats in the client.boot response will be bridged, and nothing will be fetched separately. 60 | conversation_count: -1 61 | -------------------------------------------------------------------------------- /pkg/connector/handlematrix.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "strings" 24 | 25 | "github.com/rs/zerolog" 26 | "github.com/slack-go/slack" 27 | "maunium.net/go/mautrix/bridgev2" 28 | "maunium.net/go/mautrix/bridgev2/database" 29 | "maunium.net/go/mautrix/bridgev2/networkid" 30 | 31 | "go.mau.fi/mautrix-slack/pkg/connector/slackdb" 32 | "go.mau.fi/mautrix-slack/pkg/emoji" 33 | "go.mau.fi/mautrix-slack/pkg/msgconv" 34 | "go.mau.fi/mautrix-slack/pkg/slackid" 35 | ) 36 | 37 | var ( 38 | _ bridgev2.EditHandlingNetworkAPI = (*SlackClient)(nil) 39 | _ bridgev2.RedactionHandlingNetworkAPI = (*SlackClient)(nil) 40 | _ bridgev2.ReactionHandlingNetworkAPI = (*SlackClient)(nil) 41 | _ bridgev2.ReadReceiptHandlingNetworkAPI = (*SlackClient)(nil) 42 | _ bridgev2.TypingHandlingNetworkAPI = (*SlackClient)(nil) 43 | _ bridgev2.RoomNameHandlingNetworkAPI = (*SlackClient)(nil) 44 | _ bridgev2.RoomTopicHandlingNetworkAPI = (*SlackClient)(nil) 45 | ) 46 | 47 | func (s *SlackClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { 48 | if s.Client == nil { 49 | return nil, bridgev2.ErrNotLoggedIn 50 | } 51 | _, channelID := slackid.ParsePortalID(msg.Portal.ID) 52 | if channelID == "" { 53 | return nil, errors.New("invalid channel ID") 54 | } 55 | conv, err := s.Main.MsgConv.ToSlack(ctx, s.Client, msg.Portal, msg.Content, msg.Event, msg.ThreadRoot, nil, msg.OrigSender, s.IsRealUser) 56 | if err != nil { 57 | return nil, err 58 | } 59 | timestamp, err := s.sendToSlack(ctx, channelID, conv, msg) 60 | if err != nil { 61 | return nil, err 62 | } 63 | if timestamp == "" { 64 | return &bridgev2.MatrixMessageResponse{Pending: true}, nil 65 | } 66 | return &bridgev2.MatrixMessageResponse{ 67 | DB: &database.Message{ 68 | ID: slackid.MakeMessageID(s.TeamID, channelID, timestamp), 69 | SenderID: slackid.MakeUserID(s.TeamID, s.UserID), 70 | Timestamp: slackid.ParseSlackTimestamp(timestamp), 71 | }, 72 | }, nil 73 | } 74 | 75 | func (s *SlackClient) sendToSlack( 76 | ctx context.Context, 77 | channelID string, 78 | conv *msgconv.ConvertedSlackMessage, 79 | msg *bridgev2.MatrixMessage, 80 | ) (string, error) { 81 | log := zerolog.Ctx(ctx) 82 | if conv.SendReq != nil { 83 | log.Debug().Msg("Sending message to Slack") 84 | _, timestamp, err := s.Client.PostMessageContext(ctx, channelID, conv.SendReq) 85 | return timestamp, err 86 | } else if conv.FileUpload != nil { 87 | log.Debug().Msg("Uploading attachment to Slack") 88 | file, err := s.Client.UploadFileV2Context(ctx, *conv.FileUpload) 89 | if err != nil { 90 | log.Err(err).Msg("Failed to upload attachment to Slack") 91 | return "", err 92 | } 93 | var shareInfo slack.ShareFileInfo 94 | // Slack puts the channel message info after uploading a file in either file.shares.private or file.shares.public 95 | if info, found := file.Shares.Private[channelID]; found && len(info) > 0 { 96 | shareInfo = info[0] 97 | } else if info, found = file.Shares.Public[channelID]; found && len(info) > 0 { 98 | shareInfo = info[0] 99 | } 100 | if shareInfo.Ts != "" { 101 | return shareInfo.Ts, nil 102 | } 103 | if msg != nil { 104 | msg.AddPendingToSave(nil, networkid.TransactionID(fmt.Sprintf("%s:%s", s.UserID, file.ID)), nil) 105 | } 106 | return "", nil 107 | } else if conv.FileShare != nil { 108 | log.Debug().Msg("Sharing already uploaded attachment to Slack") 109 | resp, err := s.Client.ShareFile(ctx, *conv.FileShare) 110 | if err != nil { 111 | log.Err(err).Msg("Failed to share attachment to Slack") 112 | return "", err 113 | } 114 | return resp.FileMsgTS, nil 115 | } else { 116 | return "", errors.New("no message or attachment to send") 117 | } 118 | } 119 | 120 | func (s *SlackClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { 121 | if s.Client == nil { 122 | return bridgev2.ErrNotLoggedIn 123 | } 124 | _, channelID := slackid.ParsePortalID(msg.Portal.ID) 125 | if channelID == "" { 126 | return errors.New("invalid channel ID") 127 | } 128 | conv, err := s.Main.MsgConv.ToSlack(ctx, s.Client, msg.Portal, msg.Content, msg.Event, nil, msg.EditTarget, msg.OrigSender, s.IsRealUser) 129 | if err != nil { 130 | return err 131 | } 132 | msg.EditTarget.Metadata.(*slackid.MessageMetadata).LastEditTS, err = s.sendToSlack(ctx, channelID, conv, nil) 133 | return err 134 | } 135 | 136 | func (s *SlackClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { 137 | if s.Client == nil { 138 | return bridgev2.ErrNotLoggedIn 139 | } 140 | _, channelID, messageID, ok := slackid.ParseMessageID(msg.TargetMessage.ID) 141 | if !ok { 142 | return errors.New("invalid message ID") 143 | } 144 | _, _, err := s.Client.DeleteMessageContext(ctx, channelID, messageID) 145 | return err 146 | } 147 | 148 | func (s *SlackClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (resp bridgev2.MatrixReactionPreResponse, err error) { 149 | key := msg.Content.RelatesTo.Key 150 | var emojiID string 151 | if strings.ContainsRune(key, ':') { 152 | var dbEmoji *slackdb.Emoji 153 | dbEmoji, err = s.Main.DB.Emoji.GetByMXC(ctx, key) 154 | if err != nil { 155 | err = fmt.Errorf("failed to get emoji from db: %w", err) 156 | } 157 | emojiID = dbEmoji.EmojiID 158 | } else { 159 | emojiID = emoji.GetShortcode(key) 160 | if emojiID == "" { 161 | err = fmt.Errorf("unknown emoji %q", key) 162 | } 163 | } 164 | if err != nil { 165 | return 166 | } 167 | return bridgev2.MatrixReactionPreResponse{ 168 | SenderID: slackid.MakeUserID(s.TeamID, s.UserID), 169 | EmojiID: networkid.EmojiID(emojiID), 170 | }, nil 171 | } 172 | 173 | func (s *SlackClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) { 174 | if s.Client == nil { 175 | return nil, bridgev2.ErrNotLoggedIn 176 | } 177 | _, channelID, messageID, ok := slackid.ParseMessageID(msg.TargetMessage.ID) 178 | if !ok { 179 | return nil, errors.New("invalid message ID") 180 | } 181 | err = s.Client.AddReactionContext(ctx, string(msg.PreHandleResp.EmojiID), slack.ItemRef{ 182 | Channel: channelID, 183 | Timestamp: messageID, 184 | }) 185 | return 186 | } 187 | 188 | func (s *SlackClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { 189 | if s.Client == nil { 190 | return bridgev2.ErrNotLoggedIn 191 | } 192 | _, channelID, messageID, ok := slackid.ParseMessageID(msg.TargetReaction.MessageID) 193 | if !ok { 194 | return errors.New("invalid message ID") 195 | } 196 | err := s.Client.RemoveReactionContext(ctx, string(msg.TargetReaction.EmojiID), slack.ItemRef{ 197 | Channel: channelID, 198 | Timestamp: messageID, 199 | }) 200 | if err != nil && err.Error() != "reaction" { 201 | return err 202 | } 203 | return nil 204 | } 205 | 206 | func (s *SlackClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { 207 | if s.Client == nil { 208 | return bridgev2.ErrNotLoggedIn 209 | } else if !s.IsRealUser { 210 | return nil 211 | } 212 | if msg.ExactMessage != nil { 213 | _, channelID, messageTS, ok := slackid.ParseMessageID(msg.ExactMessage.ID) 214 | if !ok { 215 | return errors.New("invalid message ID") 216 | } 217 | return s.Client.MarkConversationContext(ctx, channelID, messageTS) 218 | } 219 | lastMessage, err := s.UserLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo) 220 | if err != nil { 221 | return err 222 | } else if lastMessage != nil { 223 | _, channelID, messageTS, ok := slackid.ParseMessageID(lastMessage.ID) 224 | if !ok { 225 | return errors.New("invalid message ID") 226 | } 227 | return s.Client.MarkConversationContext(ctx, channelID, messageTS) 228 | } 229 | return nil 230 | } 231 | 232 | func (s *SlackClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { 233 | if s.Client == nil { 234 | return bridgev2.ErrNotLoggedIn 235 | } else if !s.IsRealUser { 236 | return nil 237 | } 238 | _, channelID := slackid.ParsePortalID(msg.Portal.ID) 239 | if channelID == "" { 240 | return nil 241 | } 242 | s.RTM.SendMessage(s.RTM.NewTypingMessage(channelID)) 243 | return nil 244 | } 245 | 246 | func (s *SlackClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) { 247 | _, channelID := slackid.ParsePortalID(msg.Portal.ID) 248 | if channelID == "" { 249 | return false, errors.New("invalid channel ID") 250 | } 251 | resp, err := s.Client.RenameConversationContext(ctx, channelID, msg.Content.Name) 252 | zerolog.Ctx(ctx).Trace().Any("resp_data", resp).Msg("Renamed conversation") 253 | return err == nil, err 254 | } 255 | 256 | func (s *SlackClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridgev2.MatrixRoomTopic) (bool, error) { 257 | _, channelID := slackid.ParsePortalID(msg.Portal.ID) 258 | if channelID == "" { 259 | return false, errors.New("invalid channel ID") 260 | } 261 | resp, err := s.Client.SetTopicOfConversationContext(ctx, channelID, msg.Content.Topic) 262 | zerolog.Ctx(ctx).Trace().Any("resp_data", resp).Msg("Changed conversation topic") 263 | return err == nil, err 264 | } 265 | -------------------------------------------------------------------------------- /pkg/connector/id.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "github.com/slack-go/slack" 21 | "maunium.net/go/mautrix/bridgev2" 22 | "maunium.net/go/mautrix/bridgev2/networkid" 23 | 24 | "go.mau.fi/mautrix-slack/pkg/slackid" 25 | ) 26 | 27 | func (s *SlackClient) makePortalKey(ch *slack.Channel) networkid.PortalKey { 28 | return slackid.MakePortalKey(s.TeamID, ch.ID, s.UserLogin.ID, s.Main.br.Config.SplitPortals || ch.IsIM || ch.IsMpIM) 29 | } 30 | 31 | func (s *SlackClient) makeEventSender(userID string) bridgev2.EventSender { 32 | return bridgev2.EventSender{ 33 | IsFromMe: userID == s.UserID, 34 | SenderLogin: slackid.MakeUserLoginID(s.TeamID, userID), 35 | Sender: slackid.MakeUserID(s.TeamID, userID), 36 | } 37 | } 38 | 39 | func (s *SlackClient) makeTeamPortalKey(teamID string) networkid.PortalKey { 40 | key := networkid.PortalKey{ 41 | ID: slackid.MakeTeamPortalID(teamID), 42 | } 43 | if s.Main.br.Config.SplitPortals { 44 | key.Receiver = s.UserLogin.ID 45 | } 46 | return key 47 | } 48 | -------------------------------------------------------------------------------- /pkg/connector/login-app.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "maunium.net/go/mautrix/bridgev2" 24 | "maunium.net/go/mautrix/bridgev2/database" 25 | 26 | "go.mau.fi/mautrix-slack/pkg/slackid" 27 | ) 28 | 29 | const LoginFlowIDApp = "app" 30 | const LoginStepIDAppToken = "fi.mau.slack.login.enter_app_tokens" 31 | 32 | type SlackAppLogin struct { 33 | User *bridgev2.User 34 | } 35 | 36 | var _ bridgev2.LoginProcessUserInput = (*SlackAppLogin)(nil) 37 | 38 | func (s *SlackAppLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { 39 | return &bridgev2.LoginStep{ 40 | Type: bridgev2.LoginStepTypeUserInput, 41 | StepID: LoginStepIDAppToken, 42 | UserInputParams: &bridgev2.LoginUserInputParams{ 43 | Fields: []bridgev2.LoginInputDataField{{ 44 | Type: bridgev2.LoginInputFieldTypeToken, 45 | ID: "bot_token", 46 | Name: "Bot token", 47 | Description: "Slack bot token for the workspace (starts with `xoxb-`)", 48 | Pattern: "^xoxb-.+$", 49 | }, { 50 | Type: bridgev2.LoginInputFieldTypeToken, 51 | ID: "app_token", 52 | Name: "App token", 53 | Description: "Slack app-level token (starts with `xapp-`)", 54 | Pattern: "^xapp-.+$", 55 | }}, 56 | }, 57 | }, nil 58 | } 59 | 60 | func (s *SlackAppLogin) Cancel() {} 61 | 62 | func (s *SlackAppLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { 63 | token, appToken := input["bot_token"], input["app_token"] 64 | client := makeSlackClient(&s.User.Log, token, "", appToken) 65 | info, err := client.AuthTestContext(ctx) 66 | if err != nil { 67 | return nil, fmt.Errorf("auth.test failed: %w", err) 68 | } 69 | ul, err := s.User.NewLogin(ctx, &database.UserLogin{ 70 | ID: slackid.MakeUserLoginID(info.TeamID, info.UserID), 71 | RemoteName: fmt.Sprintf("%s - %s", info.Team, info.User), 72 | Metadata: &slackid.UserLoginMetadata{ 73 | Token: token, 74 | AppToken: appToken, 75 | }, 76 | }, &bridgev2.NewLoginParams{ 77 | DeleteOnConflict: true, 78 | DontReuseExisting: false, 79 | }) 80 | if err != nil { 81 | return nil, err 82 | } 83 | sc := ul.Client.(*SlackClient) 84 | go sc.Connect(ul.Log.WithContext(context.Background())) 85 | return &bridgev2.LoginStep{ 86 | Type: bridgev2.LoginStepTypeComplete, 87 | StepID: LoginStepIDComplete, 88 | Instructions: fmt.Sprintf("Successfully logged into %s as %s", info.Team, info.User), 89 | CompleteParams: &bridgev2.LoginCompleteParams{ 90 | UserLoginID: ul.ID, 91 | UserLogin: ul, 92 | }, 93 | }, nil 94 | } 95 | -------------------------------------------------------------------------------- /pkg/connector/login-cookie.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "time" 23 | 24 | "github.com/rs/zerolog" 25 | "maunium.net/go/mautrix/bridgev2" 26 | "maunium.net/go/mautrix/bridgev2/database" 27 | 28 | "go.mau.fi/mautrix-slack/pkg/slackid" 29 | ) 30 | 31 | const LoginFlowIDAuthToken = "token" 32 | const LoginStepIDAuthToken = "fi.mau.slack.login.enter_auth_token" 33 | const LoginStepIDComplete = "fi.mau.slack.login.complete" 34 | 35 | func (s *SlackConnector) GetLoginFlows() []bridgev2.LoginFlow { 36 | return []bridgev2.LoginFlow{{ 37 | Name: "Auth token & cookie", 38 | Description: "Log in with an auth token (and a cookie, if the token is from a browser)", 39 | ID: LoginFlowIDAuthToken, 40 | }, { 41 | Name: "Slack app", 42 | Description: "Log in with a Slack app", 43 | ID: LoginFlowIDApp, 44 | }} 45 | } 46 | 47 | func (s *SlackConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { 48 | switch flowID { 49 | case LoginFlowIDAuthToken: 50 | return &SlackTokenLogin{ 51 | User: user, 52 | }, nil 53 | case LoginFlowIDApp: 54 | return &SlackAppLogin{ 55 | User: user, 56 | }, nil 57 | default: 58 | return nil, fmt.Errorf("unknown login flow %s", flowID) 59 | } 60 | } 61 | 62 | type SlackTokenLogin struct { 63 | User *bridgev2.User 64 | } 65 | 66 | var _ bridgev2.LoginProcessCookies = (*SlackTokenLogin)(nil) 67 | 68 | const ExtractSlackTokenJS = ` 69 | new Promise(resolve => { 70 | let mautrixSlackTokenCheckInterval 71 | let useSlackInBrowserClicked = false 72 | function mautrixFindSlackToken() { 73 | // Automatically click the "Use Slack in Browser" button 74 | if (/\.slack\.com$/.test(window.location.host)) { 75 | const link = document?.querySelector?.(".p-ssb_redirect__body")?.querySelector?.(".c-link") 76 | if (link && !useSlackInBrowserClicked) { 77 | location.href = link.getAttribute("href") 78 | useSlackInBrowserClicked = true 79 | } 80 | } 81 | if (!localStorage.localConfig_v2?.includes("xoxc-")) { 82 | return 83 | } 84 | const auth_token = Object.values(JSON.parse(localStorage.localConfig_v2).teams)[0].token 85 | window.clearInterval(mautrixSlackTokenCheckInterval) 86 | resolve({ auth_token }) 87 | } 88 | mautrixSlackTokenCheckInterval = window.setInterval(mautrixFindSlackToken, 1000) 89 | }) 90 | ` 91 | 92 | func (s *SlackTokenLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { 93 | return &bridgev2.LoginStep{ 94 | Type: bridgev2.LoginStepTypeCookies, 95 | StepID: LoginStepIDAuthToken, 96 | Instructions: "Enter a JSON object with your auth token and cookie token, or a cURL command copied from browser devtools.\n\nFor example: `{\"auth_token\":\"xoxc-...\",\"cookie_token\":\"xoxd-...\"}`", 97 | CookiesParams: &bridgev2.LoginCookiesParams{ 98 | URL: "https://slack.com/signin", 99 | UserAgent: "", 100 | Fields: []bridgev2.LoginCookieField{{ 101 | ID: "auth_token", 102 | Required: true, 103 | Sources: []bridgev2.LoginCookieFieldSource{{ 104 | Type: bridgev2.LoginCookieTypeSpecial, 105 | Name: "fi.mau.slack.auth_token", 106 | }, { 107 | Type: bridgev2.LoginCookieTypeRequestBody, 108 | Name: "token", 109 | RequestURLRegex: `^https://.+?\.slack\.com/api/(client|experiments|api|users|teams|conversations)\..+$`, 110 | }}, 111 | Pattern: `^xoxc-.+$`, 112 | }, { 113 | ID: "cookie_token", 114 | Required: true, 115 | Sources: []bridgev2.LoginCookieFieldSource{{ 116 | Type: bridgev2.LoginCookieTypeCookie, 117 | Name: "d", 118 | CookieDomain: "slack.com", 119 | }}, 120 | Pattern: `^xoxd-[a-zA-Z0-9/+=]+$`, 121 | }}, 122 | ExtractJS: ExtractSlackTokenJS, 123 | }, 124 | }, nil 125 | } 126 | 127 | func (s *SlackTokenLogin) Cancel() {} 128 | 129 | func (s *SlackTokenLogin) SubmitCookies(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { 130 | token, cookieToken := input["auth_token"], input["cookie_token"] 131 | client := makeSlackClient(&s.User.Log, token, cookieToken, "") 132 | err := client.FetchVersionData(ctx) 133 | if err != nil { 134 | zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to fetch version data") 135 | return nil, err 136 | } 137 | info, err := client.ClientUserBootContext(ctx, time.Time{}) 138 | if err != nil { 139 | return nil, fmt.Errorf("client.boot failed: %w", err) 140 | } 141 | ul, err := s.User.NewLogin(ctx, &database.UserLogin{ 142 | ID: slackid.MakeUserLoginID(info.Team.ID, info.Self.ID), 143 | RemoteName: fmt.Sprintf("%s - %s", info.Team.Name, info.Self.Profile.Email), 144 | Metadata: &slackid.UserLoginMetadata{ 145 | Email: info.Self.Profile.Email, 146 | Token: token, 147 | CookieToken: cookieToken, 148 | }, 149 | }, &bridgev2.NewLoginParams{ 150 | DeleteOnConflict: true, 151 | DontReuseExisting: false, 152 | }) 153 | if err != nil { 154 | return nil, err 155 | } 156 | sc := ul.Client.(*SlackClient) 157 | err = sc.connect(ul.Log.WithContext(context.Background()), info) 158 | if err != nil { 159 | return nil, fmt.Errorf("failed to connect after login: %w", err) 160 | } 161 | return &bridgev2.LoginStep{ 162 | Type: bridgev2.LoginStepTypeComplete, 163 | StepID: LoginStepIDComplete, 164 | Instructions: fmt.Sprintf("Successfully logged into %s as %s", info.Team.Name, info.Self.Profile.Email), 165 | CompleteParams: &bridgev2.LoginCompleteParams{ 166 | UserLoginID: ul.ID, 167 | UserLogin: ul, 168 | }, 169 | }, nil 170 | } 171 | -------------------------------------------------------------------------------- /pkg/connector/slackdb/00-latest-schema.sql: -------------------------------------------------------------------------------- 1 | -- v0 -> v2 (compatible with v1+): Latest schema 2 | CREATE TABLE emoji ( 3 | team_id TEXT NOT NULL, 4 | emoji_id TEXT NOT NULL, 5 | value TEXT NOT NULL, 6 | alias TEXT, 7 | image_mxc TEXT, 8 | 9 | PRIMARY KEY (team_id, emoji_id) 10 | ); 11 | 12 | CREATE INDEX emoji_alias_idx ON emoji (team_id, alias); 13 | -------------------------------------------------------------------------------- /pkg/connector/slackdb/02-emoji-alias-idx.sql: -------------------------------------------------------------------------------- 1 | -- v2 (compatible with v1+): Add index for emoji aliases 2 | CREATE INDEX emoji_alias_idx ON emoji (team_id, alias); 3 | -------------------------------------------------------------------------------- /pkg/connector/slackdb/database.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package slackdb 18 | 19 | import ( 20 | "embed" 21 | "sync" 22 | 23 | "github.com/rs/zerolog" 24 | "go.mau.fi/util/dbutil" 25 | ) 26 | 27 | type SlackDB struct { 28 | *dbutil.Database 29 | Emoji *EmojiQuery 30 | } 31 | 32 | var table dbutil.UpgradeTable 33 | 34 | //go:embed *.sql 35 | var upgrades embed.FS 36 | 37 | func init() { 38 | table.RegisterFS(upgrades) 39 | } 40 | 41 | func New(db *dbutil.Database, log zerolog.Logger) *SlackDB { 42 | db = db.Child("slack_version", table, dbutil.ZeroLogger(log)) 43 | return &SlackDB{ 44 | Database: db, 45 | Emoji: &EmojiQuery{ 46 | QueryHelper: dbutil.MakeQueryHelper(db, newEmoji), 47 | locks: make(map[string]*sync.Mutex), 48 | }, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/connector/slackdb/emoji.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package slackdb 18 | 19 | import ( 20 | "context" 21 | "database/sql" 22 | "database/sql/driver" 23 | "fmt" 24 | "strings" 25 | "sync" 26 | 27 | "go.mau.fi/util/dbutil" 28 | "maunium.net/go/mautrix/id" 29 | ) 30 | 31 | var PostgresArrayWrapper func(any) interface { 32 | driver.Valuer 33 | sql.Scanner 34 | } 35 | 36 | type EmojiQuery struct { 37 | *dbutil.QueryHelper[*Emoji] 38 | locks map[string]*sync.Mutex 39 | locksLock sync.Mutex 40 | } 41 | 42 | func newEmoji(_ *dbutil.QueryHelper[*Emoji]) *Emoji { 43 | return &Emoji{} 44 | } 45 | 46 | const ( 47 | getEmojiBySlackIDQuery = ` 48 | SELECT team_id, emoji_id, value, alias, image_mxc FROM emoji WHERE team_id=$1 AND emoji_id=$2 49 | ` 50 | getEmojiByMXCQuery = ` 51 | SELECT team_id, emoji_id, value, alias, image_mxc FROM emoji WHERE image_mxc=$1 ORDER BY alias NULLS FIRST 52 | ` 53 | getEmojiCountInTeamQuery = ` 54 | SELECT COUNT(*) FROM emoji WHERE team_id=$1 55 | ` 56 | upsertEmojiQuery = ` 57 | INSERT INTO emoji (team_id, emoji_id, value, alias, image_mxc) 58 | VALUES ($1, $2, $3, $4, $5) 59 | ON CONFLICT (team_id, emoji_id) DO UPDATE 60 | SET value = excluded.value, alias = excluded.alias, image_mxc = excluded.image_mxc 61 | ` 62 | renameEmojiQuery = `UPDATE emoji SET emoji_id=$3 WHERE team_id=$1 AND emoji_id=$2` 63 | saveEmojiMXCQuery = `UPDATE emoji SET image_mxc=$3 WHERE team_id=$1 AND (emoji_id=$2 OR alias=$2)` 64 | deleteEmojiQueryPostgres = `DELETE FROM emoji WHERE team_id=$1 AND emoji_id=ANY($2)` 65 | deleteEmojiQuerySQLite = `DELETE FROM emoji WHERE team_id=? AND emoji_id IN (?)` 66 | pruneEmojiQueryPostgres = `DELETE FROM emoji WHERE team_id=$1 AND emoji_id<>ALL($2)` 67 | pruneEmojiQuerySQLite = `DELETE FROM emoji WHERE team_id=? AND emoji_id NOT IN (?)` 68 | ) 69 | 70 | func (eq *EmojiQuery) WithLock(teamID string) func() { 71 | lock := eq.GetLock(teamID) 72 | lock.Lock() 73 | return lock.Unlock 74 | } 75 | 76 | func (eq *EmojiQuery) GetLock(teamID string) *sync.Mutex { 77 | eq.locksLock.Lock() 78 | lock, ok := eq.locks[teamID] 79 | if !ok { 80 | lock = &sync.Mutex{} 81 | eq.locks[teamID] = lock 82 | } 83 | eq.locksLock.Unlock() 84 | return lock 85 | } 86 | 87 | func (eq *EmojiQuery) GetEmojiCount(ctx context.Context, teamID string) (count int, err error) { 88 | err = eq.GetDB().QueryRow(ctx, getEmojiCountInTeamQuery, teamID).Scan(&count) 89 | return 90 | } 91 | 92 | func (eq *EmojiQuery) GetBySlackID(ctx context.Context, teamID, emojiID string) (*Emoji, error) { 93 | return eq.QueryOne(ctx, getEmojiBySlackIDQuery, teamID, emojiID) 94 | } 95 | 96 | func (eq *EmojiQuery) GetByMXC(ctx context.Context, mxc string) (*Emoji, error) { 97 | return eq.QueryOne(ctx, getEmojiByMXCQuery, &mxc) 98 | } 99 | 100 | func buildSQLiteEmojiDeleteQuery(baseQuery string, teamID string, emojiIDs ...string) (string, []any) { 101 | args := make([]any, 1+len(emojiIDs)) 102 | args[0] = teamID 103 | for i, emojiID := range emojiIDs { 104 | args[i+1] = emojiID 105 | } 106 | placeholderRepeat := strings.Repeat("?,", len(emojiIDs)) 107 | inClause := fmt.Sprintf("IN (%s)", placeholderRepeat[:len(placeholderRepeat)-1]) 108 | query := strings.Replace(baseQuery, "IN (?)", inClause, 1) 109 | return query, args 110 | } 111 | 112 | func (eq *EmojiQuery) DeleteMany(ctx context.Context, teamID string, emojiIDs ...string) error { 113 | switch eq.GetDB().Dialect { 114 | case dbutil.Postgres: 115 | return eq.Exec(ctx, deleteEmojiQueryPostgres, teamID, emojiIDs) 116 | default: 117 | query, args := buildSQLiteEmojiDeleteQuery(deleteEmojiQuerySQLite, teamID, emojiIDs...) 118 | return eq.Exec(ctx, query, args...) 119 | } 120 | } 121 | 122 | func (eq *EmojiQuery) Prune(ctx context.Context, teamID string, emojiIDs ...string) error { 123 | switch eq.GetDB().Dialect { 124 | case dbutil.Postgres: 125 | return eq.Exec(ctx, pruneEmojiQueryPostgres, teamID, PostgresArrayWrapper(emojiIDs)) 126 | default: 127 | query, args := buildSQLiteEmojiDeleteQuery(pruneEmojiQuerySQLite, teamID, emojiIDs...) 128 | return eq.Exec(ctx, query, args...) 129 | } 130 | } 131 | 132 | func (eq *EmojiQuery) Put(ctx context.Context, emoji *Emoji) error { 133 | return eq.Exec(ctx, upsertEmojiQuery, emoji.sqlVariables()...) 134 | } 135 | 136 | func (eq *EmojiQuery) Rename(ctx context.Context, emoji *Emoji, newID string) error { 137 | return eq.Exec(ctx, renameEmojiQuery, emoji.TeamID, emoji.EmojiID, newID) 138 | } 139 | 140 | func (eq *EmojiQuery) SaveMXC(ctx context.Context, emoji *Emoji) error { 141 | return eq.Exec(ctx, saveEmojiMXCQuery, emoji.TeamID, emoji.EmojiID, emoji.ImageMXC) 142 | } 143 | 144 | type Emoji struct { 145 | TeamID string 146 | EmojiID string 147 | Value string 148 | Alias string 149 | ImageMXC id.ContentURIString 150 | } 151 | 152 | func (e *Emoji) Scan(row dbutil.Scannable) (*Emoji, error) { 153 | var alias sql.NullString 154 | var imageURL sql.NullString 155 | err := row.Scan(&e.TeamID, &e.EmojiID, &e.Value, &alias, &imageURL) 156 | if err != nil { 157 | return nil, err 158 | } 159 | e.ImageMXC = id.ContentURIString(imageURL.String) 160 | e.Alias = alias.String 161 | return e, nil 162 | } 163 | 164 | func (e *Emoji) sqlVariables() []any { 165 | return []any{e.TeamID, e.EmojiID, e.Value, dbutil.StrPtr(e.Alias), dbutil.StrPtr(e.ImageMXC)} 166 | } 167 | -------------------------------------------------------------------------------- /pkg/connector/slacklog.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "strings" 21 | 22 | "github.com/rs/zerolog" 23 | ) 24 | 25 | type slackgoZerolog struct { 26 | zerolog.Logger 27 | } 28 | 29 | func (l slackgoZerolog) Output(i int, s string) error { 30 | level := zerolog.DebugLevel 31 | if strings.HasPrefix(s, "Sending PING ") || strings.HasPrefix(s, "Updated reconnect URL") { 32 | level = zerolog.TraceLevel 33 | } 34 | l.WithLevel(level).Msg(strings.TrimSpace(s)) 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/connector/startchat.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "strings" 23 | 24 | "github.com/slack-go/slack" 25 | "maunium.net/go/mautrix/bridgev2" 26 | "maunium.net/go/mautrix/bridgev2/networkid" 27 | 28 | "go.mau.fi/mautrix-slack/pkg/slackid" 29 | ) 30 | 31 | var ( 32 | _ bridgev2.IdentifierResolvingNetworkAPI = (*SlackClient)(nil) 33 | _ bridgev2.UserSearchingNetworkAPI = (*SlackClient)(nil) 34 | _ bridgev2.GroupCreatingNetworkAPI = (*SlackClient)(nil) 35 | _ bridgev2.IdentifierValidatingNetwork = (*SlackConnector)(nil) 36 | ) 37 | 38 | func (s *SlackConnector) ValidateUserID(id networkid.UserID) bool { 39 | teamID, userID := slackid.ParseUserID(id) 40 | return teamID != "" && userID != "" 41 | } 42 | 43 | func (s *SlackClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error) { 44 | if s.Client == nil { 45 | return nil, bridgev2.ErrNotLoggedIn 46 | } 47 | var userInfo *slack.User 48 | var err error 49 | if strings.ContainsRune(identifier, '@') { 50 | userInfo, err = s.Client.GetUserByEmailContext(ctx, identifier) 51 | // TODO return err try next for not found users? 52 | } else { 53 | if strings.ContainsRune(identifier, '-') { 54 | var teamID string 55 | teamID, identifier = slackid.ParseUserID(networkid.UserID(identifier)) 56 | if teamID != s.TeamID { 57 | return nil, fmt.Errorf("%w: identifier does not match team", bridgev2.ErrResolveIdentifierTryNext) 58 | } 59 | } else { 60 | identifier = strings.ToUpper(identifier) 61 | } 62 | userInfo, err = s.Client.GetUserInfoContext(ctx, identifier) 63 | } 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to get user info: %w", err) 66 | } 67 | userID := slackid.MakeUserID(s.TeamID, userInfo.ID) 68 | ghost, err := s.Main.br.GetGhostByID(ctx, userID) 69 | if err != nil { 70 | return nil, fmt.Errorf("failed to get ghost: %w", err) 71 | } 72 | var chatResp *bridgev2.CreateChatResponse 73 | if createChat { 74 | resp, _, _, err := s.Client.OpenConversationContext(ctx, &slack.OpenConversationParameters{ 75 | ReturnIM: true, 76 | Users: []string{userInfo.ID}, 77 | }) 78 | if err != nil { 79 | return nil, fmt.Errorf("failed to open conversation: %w", err) 80 | } 81 | chatInfo, err := s.wrapChatInfo(ctx, resp, true) 82 | if err != nil { 83 | return nil, fmt.Errorf("failed to wrap chat info: %w", err) 84 | } 85 | chatResp = &bridgev2.CreateChatResponse{ 86 | PortalKey: s.makePortalKey(resp), 87 | PortalInfo: chatInfo, 88 | } 89 | } 90 | return &bridgev2.ResolveIdentifierResponse{ 91 | Ghost: ghost, 92 | UserID: userID, 93 | Chat: chatResp, 94 | }, nil 95 | } 96 | 97 | func (s *SlackClient) CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*bridgev2.CreateChatResponse, error) { 98 | if s.Client == nil { 99 | return nil, bridgev2.ErrNotLoggedIn 100 | } 101 | plainUsers := make([]string, len(users)) 102 | for i, user := range users { 103 | var teamID string 104 | teamID, plainUsers[i] = slackid.ParseUserID(user) 105 | if teamID != s.TeamID || plainUsers[i] == "" { 106 | return nil, fmt.Errorf("invalid user ID %q", user) 107 | } 108 | } 109 | var resp *slack.Channel 110 | var err error 111 | if name != "" { 112 | resp, err = s.Client.CreateConversationContext(ctx, slack.CreateConversationParams{ 113 | ChannelName: name, 114 | IsPrivate: true, 115 | TeamID: s.TeamID, 116 | }) 117 | if err != nil { 118 | return nil, fmt.Errorf("failed to create channel: %w", err) 119 | } 120 | resp, err = s.Client.InviteUsersToConversationContext(ctx, resp.ID, plainUsers...) 121 | if err != nil { 122 | return nil, fmt.Errorf("failed to invite users: %w", err) 123 | } 124 | } else { 125 | resp, _, _, err = s.Client.OpenConversationContext(ctx, &slack.OpenConversationParameters{ 126 | ReturnIM: true, 127 | Users: plainUsers, 128 | }) 129 | if err != nil { 130 | return nil, fmt.Errorf("failed to open conversation: %w", err) 131 | } 132 | } 133 | chatInfo, err := s.wrapChatInfo(ctx, resp, true) 134 | if err != nil { 135 | return nil, fmt.Errorf("failed to wrap chat info: %w", err) 136 | } 137 | return &bridgev2.CreateChatResponse{ 138 | PortalKey: s.makePortalKey(resp), 139 | PortalInfo: chatInfo, 140 | }, nil 141 | } 142 | 143 | func (s *SlackClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { 144 | if s.Client == nil { 145 | return nil, bridgev2.ErrNotLoggedIn 146 | } 147 | resp, err := s.Client.SearchUsersCacheContext(ctx, s.TeamID, query) 148 | if err != nil { 149 | return nil, err 150 | } 151 | results := make([]*bridgev2.ResolveIdentifierResponse, len(resp.Results)) 152 | for i, user := range resp.Results { 153 | userID := slackid.MakeUserID(s.TeamID, user.ID) 154 | ghost, err := s.Main.br.GetGhostByID(ctx, userID) 155 | if err != nil { 156 | return nil, fmt.Errorf("failed to get ghost: %w", err) 157 | } 158 | results[i] = &bridgev2.ResolveIdentifierResponse{ 159 | Ghost: ghost, 160 | UserID: userID, 161 | UserInfo: s.wrapUserInfo(user.ID, user, nil, ghost), 162 | } 163 | } 164 | return results, nil 165 | } 166 | -------------------------------------------------------------------------------- /pkg/emoji/emoji-generate.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | //go:build ignore 18 | 19 | package main 20 | 21 | import ( 22 | "bufio" 23 | "encoding/json" 24 | "errors" 25 | "fmt" 26 | "io" 27 | "net/http" 28 | "os" 29 | "strconv" 30 | "strings" 31 | 32 | "go.mau.fi/util/exerrors" 33 | ) 34 | 35 | type SkinVariation struct { 36 | Unified string `json:"unified"` 37 | NonQualified *string `json:"non_qualified"` 38 | Image string `json:"image"` 39 | SheetX int `json:"sheet_x"` 40 | SheetY int `json:"sheet_y"` 41 | AddedIn string `json:"added_in"` 42 | HasImgApple bool `json:"has_img_apple"` 43 | HasImgGoogle bool `json:"has_img_google"` 44 | HasImgTwitter bool `json:"has_img_twitter"` 45 | HasImgFacebook bool `json:"has_img_facebook"` 46 | Obsoletes string `json:"obsoletes,omitempty"` 47 | ObsoletedBy string `json:"obsoleted_by,omitempty"` 48 | } 49 | 50 | type Emoji struct { 51 | Name string `json:"name"` 52 | Unified string `json:"unified"` 53 | NonQualified *string `json:"non_qualified"` 54 | Docomo *string `json:"docomo"` 55 | Au *string `json:"au"` 56 | Softbank *string `json:"softbank"` 57 | Google *string `json:"google"` 58 | Image string `json:"image"` 59 | SheetX int `json:"sheet_x"` 60 | SheetY int `json:"sheet_y"` 61 | ShortName string `json:"short_name"` 62 | ShortNames []string `json:"short_names"` 63 | Text *string `json:"text"` 64 | Texts []string `json:"texts"` 65 | Category string `json:"category"` 66 | Subcategory string `json:"subcategory"` 67 | SortOrder int `json:"sort_order"` 68 | AddedIn string `json:"added_in"` 69 | HasImgApple bool `json:"has_img_apple"` 70 | HasImgGoogle bool `json:"has_img_google"` 71 | HasImgTwitter bool `json:"has_img_twitter"` 72 | HasImgFacebook bool `json:"has_img_facebook"` 73 | SkinVariations map[string]*SkinVariation `json:"skin_variations,omitempty"` 74 | Obsoletes string `json:"obsoletes,omitempty"` 75 | ObsoletedBy string `json:"obsoleted_by,omitempty"` 76 | } 77 | 78 | func unifiedToUnicode(input string) string { 79 | parts := strings.Split(input, "-") 80 | output := make([]rune, len(parts)) 81 | for i, part := range parts { 82 | output[i] = rune(exerrors.Must(strconv.ParseInt(part, 16, 32))) 83 | } 84 | return string(output) 85 | } 86 | 87 | var skinToneIDs = map[string]string{ 88 | "1F3FB": "2", 89 | "1F3FC": "3", 90 | "1F3FD": "4", 91 | "1F3FE": "5", 92 | "1F3FF": "6", 93 | } 94 | 95 | func unifiedToSkinToneID(input string) string { 96 | parts := strings.Split(input, "-") 97 | var ok bool 98 | for i, part := range parts { 99 | parts[i], ok = skinToneIDs[part] 100 | if !ok { 101 | panic("unknown skin tone " + input) 102 | } 103 | } 104 | return "skin-tone-" + strings.Join(parts, "-") 105 | } 106 | 107 | func getVariationSequences() (output map[string]struct{}) { 108 | variationSequences := exerrors.Must(http.Get("https://www.unicode.org/Public/15.1.0/ucd/emoji/emoji-variation-sequences.txt")) 109 | buf := bufio.NewReader(variationSequences.Body) 110 | output = make(map[string]struct{}) 111 | for { 112 | line, err := buf.ReadString('\n') 113 | if errors.Is(err, io.EOF) { 114 | break 115 | } else if err != nil { 116 | panic(err) 117 | } 118 | parts := strings.Split(line, "; ") 119 | if len(parts) < 2 || parts[1] != "emoji style" { 120 | continue 121 | } 122 | unifiedParts := strings.Split(parts[0], " ") 123 | output[unifiedParts[0]] = struct{}{} 124 | } 125 | return 126 | } 127 | 128 | func main() { 129 | var emojis []Emoji 130 | resp := exerrors.Must(http.Get("https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json")) 131 | exerrors.PanicIfNotNil(json.NewDecoder(resp.Body).Decode(&emojis)) 132 | vs := getVariationSequences() 133 | 134 | shortcodeToEmoji := make(map[string]string) 135 | for _, emoji := range emojis { 136 | shortcodeToEmoji[emoji.ShortName] = unifiedToUnicode(emoji.Unified) 137 | if _, needsVariation := vs[emoji.Unified]; needsVariation { 138 | shortcodeToEmoji[emoji.ShortName] += "\ufe0f" 139 | } 140 | for skinToneKey, stEmoji := range emoji.SkinVariations { 141 | shortcodeToEmoji[fmt.Sprintf("%s::%s", emoji.ShortName, unifiedToSkinToneID(skinToneKey))] = unifiedToUnicode(stEmoji.Unified) 142 | } 143 | } 144 | file := exerrors.Must(os.OpenFile("emoji.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)) 145 | enc := json.NewEncoder(file) 146 | enc.SetIndent("", " ") 147 | exerrors.PanicIfNotNil(enc.Encode(shortcodeToEmoji)) 148 | exerrors.PanicIfNotNil(file.Close()) 149 | } 150 | -------------------------------------------------------------------------------- /pkg/emoji/emoji.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package emoji 18 | 19 | import ( 20 | "embed" 21 | "encoding/json" 22 | "regexp" 23 | "strings" 24 | "sync" 25 | 26 | "go.mau.fi/util/exerrors" 27 | "go.mau.fi/util/variationselector" 28 | ) 29 | 30 | //go:generate go run ./emoji-generate.go 31 | //go:embed emoji.json 32 | var emojiFileData embed.FS 33 | 34 | var shortcodeToUnicodeMap map[string]string 35 | var unicodeToShortcodeMap map[string]string 36 | var shortcodeRegex *regexp.Regexp 37 | var initOnce sync.Once 38 | 39 | func doInit() { 40 | file := exerrors.Must(emojiFileData.Open("emoji.json")) 41 | exerrors.PanicIfNotNil(json.NewDecoder(file).Decode(&shortcodeToUnicodeMap)) 42 | exerrors.PanicIfNotNil(file.Close()) 43 | unicodeToShortcodeMap = make(map[string]string, len(shortcodeToUnicodeMap)) 44 | for shortcode, emoji := range shortcodeToUnicodeMap { 45 | unicodeToShortcodeMap[variationselector.Remove(emoji)] = shortcode 46 | } 47 | shortcodeRegex = regexp.MustCompile(`:[^:\s]*:`) 48 | } 49 | 50 | func GetShortcode(unicode string) string { 51 | initOnce.Do(doInit) 52 | return unicodeToShortcodeMap[variationselector.Remove(unicode)] 53 | } 54 | 55 | func GetUnicode(shortcode string) string { 56 | initOnce.Do(doInit) 57 | return shortcodeToUnicodeMap[strings.Trim(shortcode, ":")] 58 | } 59 | 60 | func replaceShortcode(code string) string { 61 | emoji := GetUnicode(code) 62 | if emoji == "" { 63 | return code 64 | } 65 | return emoji 66 | } 67 | 68 | func ReplaceShortcodesWithUnicode(text string) string { 69 | initOnce.Do(doInit) 70 | return shortcodeRegex.ReplaceAllStringFunc(text, replaceShortcode) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/msgconv/from-matrix.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package msgconv 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "errors" 23 | "fmt" 24 | "image" 25 | "strings" 26 | 27 | "github.com/rs/zerolog" 28 | "github.com/slack-go/slack" 29 | "go.mau.fi/util/ffmpeg" 30 | "maunium.net/go/mautrix/bridgev2" 31 | "maunium.net/go/mautrix/bridgev2/database" 32 | "maunium.net/go/mautrix/event" 33 | 34 | "go.mau.fi/mautrix-slack/pkg/slackid" 35 | ) 36 | 37 | var ( 38 | ErrUnknownMsgType = errors.New("unknown msgtype") 39 | ErrMediaDownloadFailed = errors.New("failed to download media") 40 | ErrMediaUploadFailed = errors.New("failed to reupload media") 41 | ErrMediaConvertFailed = errors.New("failed to re-encode media") 42 | ErrMediaOnlyEditCaption = errors.New("only media message caption can be edited") 43 | ) 44 | 45 | func isMediaMsgtype(msgType event.MessageType) bool { 46 | return msgType == event.MsgImage || msgType == event.MsgAudio || msgType == event.MsgVideo || msgType == event.MsgFile 47 | } 48 | 49 | type ConvertedSlackMessage struct { 50 | SendReq slack.MsgOption 51 | FileUpload *slack.UploadFileV2Parameters 52 | FileShare *slack.ShareFileParams 53 | } 54 | 55 | func (mc *MessageConverter) ToSlack( 56 | ctx context.Context, 57 | client *slack.Client, 58 | portal *bridgev2.Portal, 59 | content *event.MessageEventContent, 60 | evt *event.Event, 61 | threadRoot *database.Message, 62 | editTarget *database.Message, 63 | origSender *bridgev2.OrigSender, 64 | isRealUser bool, 65 | ) (conv *ConvertedSlackMessage, err error) { 66 | log := zerolog.Ctx(ctx) 67 | 68 | if evt.Type == event.EventSticker { 69 | // Slack doesn't have stickers, just bridge stickers as images 70 | content.MsgType = event.MsgImage 71 | } 72 | 73 | var editTargetID, threadRootID string 74 | if editTarget != nil { 75 | if isMediaMsgtype(content.MsgType) { 76 | content.MsgType = event.MsgText 77 | if content.FileName == "" || content.FileName == content.Body { 78 | return nil, ErrMediaOnlyEditCaption 79 | } 80 | } 81 | var ok bool 82 | _, _, editTargetID, ok = slackid.ParseMessageID(editTarget.ID) 83 | if !ok { 84 | return nil, fmt.Errorf("failed to parse edit target ID") 85 | } 86 | } 87 | if threadRoot != nil { 88 | threadRootMessageID := threadRoot.ID 89 | if threadRoot.ThreadRoot != "" { 90 | threadRootMessageID = threadRoot.ThreadRoot 91 | } 92 | var ok bool 93 | _, _, threadRootID, ok = slackid.ParseMessageID(threadRootMessageID) 94 | if !ok { 95 | return nil, fmt.Errorf("failed to parse thread root ID") 96 | } 97 | } 98 | 99 | switch content.MsgType { 100 | case event.MsgText, event.MsgEmote, event.MsgNotice: 101 | options := make([]slack.MsgOption, 0, 4) 102 | var block slack.Block 103 | if content.Format == event.FormatHTML { 104 | block = mc.MatrixHTMLParser.Parse(ctx, content.FormattedBody, content.Mentions, portal) 105 | } else { 106 | block = mc.MatrixHTMLParser.ParseText(ctx, content.Body, content.Mentions, portal) 107 | } 108 | options = append(options, slack.MsgOptionBlocks(block)) 109 | if editTargetID != "" { 110 | options = append(options, slack.MsgOptionUpdate(editTargetID)) 111 | } else if threadRootID != "" { 112 | options = append(options, slack.MsgOptionTS(threadRootID)) 113 | } 114 | if content.MsgType == event.MsgEmote { 115 | options = append(options, slack.MsgOptionMeMessage()) 116 | } 117 | if content.BeeperLinkPreviews != nil && len(content.BeeperLinkPreviews) == 0 { 118 | options = append(options, slack.MsgOptionDisableLinkUnfurl(), slack.MsgOptionDisableMediaUnfurl()) 119 | } 120 | if origSender != nil { 121 | options = append(options, slack.MsgOptionUsername(origSender.FormattedName)) 122 | urlProvider, ok := mc.Bridge.Matrix.(bridgev2.MatrixConnectorWithPublicMedia) 123 | if ok && origSender.AvatarURL != "" { 124 | publicAvatarURL := urlProvider.GetPublicMediaAddress(origSender.AvatarURL) 125 | if publicAvatarURL != "" { 126 | options = append(options, slack.MsgOptionIconURL(publicAvatarURL)) 127 | } 128 | } 129 | } 130 | return &ConvertedSlackMessage{SendReq: slack.MsgOptionCompose(options...)}, nil 131 | case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: 132 | data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) 133 | if err != nil { 134 | log.Err(err).Msg("Failed to download Matrix attachment") 135 | return nil, ErrMediaDownloadFailed 136 | } 137 | 138 | var filename, caption, captionHTML, subtype string 139 | if content.FileName == "" || content.FileName == content.Body { 140 | filename = content.Body 141 | } else { 142 | filename = content.FileName 143 | caption = content.Body 144 | captionHTML = content.FormattedBody 145 | } 146 | if content.MSC3245Voice != nil && content.Info.MimeType != "audio/webm; codecs=opus" && ffmpeg.Supported() { 147 | data, err = ffmpeg.ConvertBytes(ctx, data, ".webm", []string{}, []string{"-c:a", "copy"}, content.Info.MimeType) 148 | if err != nil { 149 | log.Err(err).Msg("Failed to convert voice message") 150 | return nil, ErrMediaConvertFailed 151 | } 152 | filename += ".webm" 153 | content.Info.MimeType = "audio/webm; codecs=opus" 154 | subtype = "slack_audio" 155 | } else if content.MSC3245Voice != nil && content.Info.MimeType == "audio/webm; codecs=opus" { 156 | subtype = "slack_audio" 157 | } 158 | _, channelID := slackid.ParsePortalID(portal.ID) 159 | if !isRealUser { 160 | fileUpload := &slack.UploadFileV2Parameters{ 161 | Filename: filename, 162 | Reader: bytes.NewReader(data), 163 | FileSize: len(data), 164 | Channel: channelID, 165 | ThreadTimestamp: threadRootID, 166 | } 167 | if caption != "" { 168 | fileUpload.InitialComment = caption 169 | } 170 | return &ConvertedSlackMessage{FileUpload: fileUpload}, nil 171 | } else { 172 | resp, err := client.GetFileUploadURL(ctx, slack.GetFileUploadURLParameters{ 173 | Filename: filename, 174 | Length: len(data), 175 | SubType: subtype, 176 | }) 177 | if err != nil { 178 | log.Err(err).Msg("Failed to get file upload URL") 179 | return nil, ErrMediaUploadFailed 180 | } 181 | err = client.UploadToURL(ctx, resp, content.Info.MimeType, data) 182 | if err != nil { 183 | log.Err(err).Msg("Failed to upload file") 184 | return nil, ErrMediaUploadFailed 185 | } 186 | err = client.CompleteFileUpload(ctx, resp) 187 | if err != nil { 188 | log.Err(err).Msg("Failed to complete file upload") 189 | return nil, ErrMediaUploadFailed 190 | } 191 | var block slack.Block 192 | if captionHTML != "" { 193 | block = mc.MatrixHTMLParser.Parse(ctx, content.FormattedBody, content.Mentions, portal) 194 | } else if caption != "" { 195 | block = slack.NewRichTextBlock("", slack.NewRichTextSection(slack.NewRichTextSectionTextElement(caption, nil))) 196 | } 197 | fileShare := &slack.ShareFileParams{ 198 | Files: []string{resp.File}, 199 | Channel: channelID, 200 | ThreadTS: threadRootID, 201 | } 202 | if block != nil { 203 | fileShare.Blocks = []slack.Block{block} 204 | } 205 | return &ConvertedSlackMessage{FileShare: fileShare}, nil 206 | } 207 | default: 208 | return nil, ErrUnknownMsgType 209 | } 210 | } 211 | 212 | func (mc *MessageConverter) uploadMedia(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data []byte, content *event.MessageEventContent) error { 213 | content.Info.Size = len(data) 214 | if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") { 215 | cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) 216 | content.Info.Width, content.Info.Height = cfg.Width, cfg.Height 217 | } 218 | 219 | mxc, file, err := intent.UploadMedia(ctx, portal.MXID, data, "", content.Info.MimeType) 220 | if err != nil { 221 | return err 222 | } 223 | if file != nil { 224 | content.File = file 225 | } else { 226 | content.URL = mxc 227 | } 228 | return nil 229 | } 230 | -------------------------------------------------------------------------------- /pkg/msgconv/from-slack.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package msgconv 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "errors" 23 | "fmt" 24 | "image" 25 | "io" 26 | "net/http" 27 | "os" 28 | "slices" 29 | "strconv" 30 | "strings" 31 | "time" 32 | 33 | "github.com/rs/zerolog" 34 | "github.com/slack-go/slack" 35 | "go.mau.fi/util/exmime" 36 | "go.mau.fi/util/ffmpeg" 37 | "go.mau.fi/util/ptr" 38 | "maunium.net/go/mautrix" 39 | "maunium.net/go/mautrix/bridgev2" 40 | "maunium.net/go/mautrix/bridgev2/database" 41 | "maunium.net/go/mautrix/bridgev2/networkid" 42 | "maunium.net/go/mautrix/event" 43 | "maunium.net/go/mautrix/format" 44 | 45 | "go.mau.fi/mautrix-slack/pkg/slackid" 46 | ) 47 | 48 | func (mc *MessageConverter) ToMatrix( 49 | ctx context.Context, 50 | portal *bridgev2.Portal, 51 | intent bridgev2.MatrixAPI, 52 | source *bridgev2.UserLogin, 53 | msg *slack.Msg, 54 | ) *bridgev2.ConvertedMessage { 55 | ctx = context.WithValue(ctx, contextKeyPortal, portal) 56 | ctx = context.WithValue(ctx, contextKeySource, source) 57 | client := source.Client.(SlackClientProvider).GetClient() 58 | output := &bridgev2.ConvertedMessage{} 59 | if msg.ThreadTimestamp != "" && msg.ThreadTimestamp != msg.Timestamp { 60 | teamID, channelID := slackid.ParsePortalID(portal.ID) 61 | output.ThreadRoot = ptr.Ptr(slackid.MakeMessageID(teamID, channelID, msg.ThreadTimestamp)) 62 | } 63 | textPart := mc.makeTextPart(ctx, msg, portal, intent) 64 | if textPart != nil { 65 | output.Parts = append(output.Parts, textPart) 66 | } 67 | for i, file := range msg.Files { 68 | // mode=tombstone seems to mean the file was deleted 69 | if file.Mode == "tombstone" { 70 | continue 71 | } 72 | partID := slackid.MakePartID(slackid.PartTypeFile, i, file.ID) 73 | output.Parts = append(output.Parts, mc.slackFileToMatrix(ctx, portal, intent, client, partID, &file)) 74 | } 75 | for i, att := range msg.Attachments { 76 | if !isImageAttachment(&att) { 77 | continue 78 | } 79 | part, err := mc.renderImageBlock(ctx, portal, intent, att.Blocks.BlockSet[0].(*slack.ImageBlock).ImageURL) 80 | if err != nil { 81 | zerolog.Ctx(ctx).Err(err).Msg("Failed to render image block") 82 | } else { 83 | part.ID = slackid.MakePartID(slackid.PartTypeAttachment, len(msg.Files)+i, strconv.Itoa(att.ID)) 84 | output.Parts = append(output.Parts, part) 85 | } 86 | } 87 | if output.MergeCaption() { 88 | output.Parts[0].DBMetadata = &slackid.MessageMetadata{ 89 | CaptionMerged: true, 90 | } 91 | } 92 | if msg.Username != "" { 93 | for _, part := range output.Parts { 94 | // TODO reupload avatar 95 | part.Content.BeeperPerMessageProfile = &event.BeeperPerMessageProfile{ 96 | ID: msg.Username, 97 | Displayname: msg.Username, 98 | } 99 | } 100 | } 101 | return output 102 | } 103 | 104 | func (mc *MessageConverter) EditToMatrix( 105 | ctx context.Context, 106 | portal *bridgev2.Portal, 107 | intent bridgev2.MatrixAPI, 108 | source *bridgev2.UserLogin, 109 | msg *slack.Msg, 110 | origMsg *slack.Msg, 111 | existing []*database.Message, 112 | ) *bridgev2.ConvertedEdit { 113 | ctx = context.WithValue(ctx, contextKeyPortal, portal) 114 | ctx = context.WithValue(ctx, contextKeySource, source) 115 | client := source.Client.(SlackClientProvider).GetClient() 116 | output := &bridgev2.ConvertedEdit{} 117 | existingMap := make(map[networkid.PartID]*database.Message, len(existing)) 118 | for _, part := range existing { 119 | existingMap[part.PartID] = part 120 | partType, _, innerPartID, ok := slackid.ParsePartID(part.PartID) 121 | if ok && partType == slackid.PartTypeAttachment { 122 | innerPartIDInt, _ := strconv.Atoi(innerPartID) 123 | attachmentStillExists := slices.ContainsFunc(msg.Attachments, func(attachment slack.Attachment) bool { 124 | return attachment.ID == innerPartIDInt 125 | }) 126 | if !attachmentStillExists { 127 | output.DeletedParts = append(output.DeletedParts, part) 128 | } 129 | } 130 | } 131 | editTargetPart := existing[0] 132 | editTargetPart.Metadata.(*slackid.MessageMetadata).LastEditTS = msg.Edited.Timestamp 133 | modifiedPart := mc.makeTextPart(ctx, msg, portal, intent) 134 | captionMerged := false 135 | for i, file := range msg.Files { 136 | partID := slackid.MakePartID(slackid.PartTypeFile, i, file.ID) 137 | existingPart, ok := existingMap[partID] 138 | if file.Mode == "tombstone" { 139 | if ok { 140 | output.DeletedParts = append(output.DeletedParts, existingPart) 141 | } 142 | } else { 143 | // For edits where there's either only one media part, or there was no text part, 144 | // we'll need to fetch the first media part to merge it in 145 | if !captionMerged && modifiedPart != nil && (len(msg.Files) == 1 || editTargetPart.PartID != "") { 146 | if editTargetPart.PartID != "" { 147 | editTargetPart = existingPart 148 | } 149 | filePart := mc.slackFileToMatrix(ctx, portal, intent, client, partID, &file) 150 | modifiedPart = bridgev2.MergeCaption(modifiedPart, filePart) 151 | modifiedPart.DBMetadata = &slackid.MessageMetadata{ 152 | CaptionMerged: true, 153 | } 154 | captionMerged = true 155 | } 156 | } 157 | } 158 | if msg.Username != "" { 159 | modifiedPart.Content.BeeperPerMessageProfile = &event.BeeperPerMessageProfile{ 160 | ID: msg.Username, 161 | Displayname: msg.Username, 162 | } 163 | } 164 | // TODO this doesn't handle edits to captions in msg.Attachments gifs properly 165 | if modifiedPart != nil { 166 | output.ModifiedParts = append(output.ModifiedParts, modifiedPart.ToEditPart(editTargetPart)) 167 | } 168 | return output 169 | } 170 | 171 | func (mc *MessageConverter) makeTextPart(ctx context.Context, msg *slack.Msg, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) *bridgev2.ConvertedMessagePart { 172 | var text string 173 | if msg.Text != "" { 174 | text = msg.Text 175 | } 176 | for _, attachment := range msg.Attachments { 177 | if text != "" { 178 | text += "\n" 179 | } 180 | if attachment.Text != "" { 181 | text += attachment.Text 182 | } else if attachment.Fallback != "" { 183 | text += attachment.Fallback 184 | } 185 | } 186 | var textPart *bridgev2.ConvertedMessagePart 187 | if len(msg.Blocks.BlockSet) != 0 || len(msg.Attachments) != 0 { 188 | textPart = mc.trySlackBlocksToMatrix(ctx, portal, intent, msg.Blocks, msg.Attachments) 189 | } else if text != "" { 190 | textPart = mc.slackTextToMatrix(ctx, text) 191 | } 192 | if textPart != nil { 193 | switch msg.SubType { 194 | case slack.MsgSubTypeMeMessage: 195 | textPart.Content.MsgType = event.MsgEmote 196 | case "huddle_thread": 197 | teamID, channelID := slackid.ParsePortalID(portal.ID) 198 | textPart.Content.EnsureHasHTML() 199 | textPart.Content.Body += fmt.Sprintf("\n\nJoin via the Slack app: https://app.slack.com/client/%s/%s", teamID, channelID) 200 | textPart.Content.FormattedBody += fmt.Sprintf(`

Click here to join via the Slack app

`, teamID, channelID) 201 | } 202 | } 203 | return textPart 204 | } 205 | 206 | func (mc *MessageConverter) slackTextToMatrix(ctx context.Context, text string) *bridgev2.ConvertedMessagePart { 207 | mentions := &event.Mentions{} 208 | content := format.HTMLToContent(mc.mrkdwnToMatrixHtml(ctx, text, mentions)) 209 | content.Mentions = mentions 210 | return &bridgev2.ConvertedMessagePart{ 211 | Type: event.EventMessage, 212 | Content: &content, 213 | } 214 | } 215 | 216 | func makeErrorMessage(partID networkid.PartID, message string, args ...any) *bridgev2.ConvertedMessagePart { 217 | if len(args) > 0 { 218 | message = fmt.Sprintf(message, args...) 219 | } 220 | return &bridgev2.ConvertedMessagePart{ 221 | ID: partID, 222 | Type: event.EventMessage, 223 | Content: &event.MessageEventContent{ 224 | MsgType: event.MsgNotice, 225 | Body: message, 226 | }, 227 | } 228 | } 229 | 230 | type doctypeCheckingWriteProxy struct { 231 | io.Writer 232 | isStart bool 233 | } 234 | 235 | var errHTMLFile = errors.New("received HTML file") 236 | 237 | func (dtwp *doctypeCheckingWriteProxy) Write(p []byte) (n int, err error) { 238 | if dtwp.isStart && bytes.HasPrefix(p, []byte("")) { 239 | return 0, errHTMLFile 240 | } 241 | dtwp.isStart = false 242 | return dtwp.Writer.Write(p) 243 | } 244 | 245 | func (mc *MessageConverter) slackFileToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, client *slack.Client, partID networkid.PartID, file *slack.File) *bridgev2.ConvertedMessagePart { 246 | log := zerolog.Ctx(ctx).With().Str("file_id", file.ID).Logger() 247 | if file.FileAccess == "check_file_info" { 248 | connectFile, _, _, err := client.GetFileInfoContext(ctx, file.ID, 0, 0) 249 | if err != nil || connectFile == nil { 250 | log.Err(err).Str("file_id", file.ID).Msg("Failed to fetch slack connect file info") 251 | return makeErrorMessage(partID, "Failed to fetch Slack Connect file") 252 | } 253 | file = connectFile 254 | } 255 | if file.Size > mc.MaxFileSize { 256 | log.Debug().Int("file_size", file.Size).Msg("Dropping too large file") 257 | return makeErrorMessage(partID, "Too large file (%d MB)", file.Size/1_000_000) 258 | } 259 | content := convertSlackFileMetadata(file) 260 | var url string 261 | if file.URLPrivateDownload != "" { 262 | url = file.URLPrivateDownload 263 | } else if file.URLPrivate != "" { 264 | url = file.URLPrivate 265 | } 266 | if url == "" && file.PermalinkPublic == "" { 267 | log.Warn().Msg("No usable URL found in file object") 268 | return makeErrorMessage(partID, "File URL not found") 269 | } 270 | convertAudio := file.SubType == "slack_audio" && ffmpeg.Supported() 271 | needsMediaSize := content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") 272 | requireFile := convertAudio || needsMediaSize 273 | var retErr *bridgev2.ConvertedMessagePart 274 | var uploadErr error 275 | content.URL, content.File, uploadErr = intent.UploadMediaStream(ctx, portal.MXID, int64(file.Size), requireFile, func(dest io.Writer) (res *bridgev2.FileStreamResult, err error) { 276 | res = &bridgev2.FileStreamResult{ 277 | ReplacementFile: "", 278 | FileName: file.Name, 279 | MimeType: content.Info.MimeType, 280 | } 281 | if url != "" { 282 | err = client.GetFileContext(ctx, url, &doctypeCheckingWriteProxy{Writer: dest}) 283 | if errors.Is(err, errHTMLFile) { 284 | log.Warn().Msg("Received HTML file from Slack, retrying in 5 seconds") 285 | time.Sleep(5 * time.Second) 286 | err = client.GetFileContext(ctx, url, dest) 287 | } 288 | } else if file.PermalinkPublic != "" { 289 | var resp *http.Response 290 | // TODO don't use DefaultClient and use context 291 | resp, err = http.DefaultClient.Get(file.PermalinkPublic) 292 | if err == nil { 293 | _, err = io.Copy(dest, resp.Body) 294 | } 295 | } 296 | if err != nil { 297 | log.Err(err).Msg("Failed to download file from Slack") 298 | retErr = makeErrorMessage(partID, "Failed to download file from Slack") 299 | return 300 | } 301 | if convertAudio { 302 | destFile := dest.(*os.File) 303 | _ = destFile.Close() 304 | sourceMime := file.Mimetype 305 | // Slack claims audio messages are webm/opus, but actually stores mp4/aac? 306 | if strings.HasSuffix(url, ".mp4") { 307 | sourceMime = "audio/mp4" 308 | } 309 | tempFileWithExt := destFile.Name() + exmime.ExtensionFromMimetype(sourceMime) 310 | err = os.Rename(destFile.Name(), tempFileWithExt) 311 | if err != nil { 312 | log.Err(err).Msg("Failed to rename temp file") 313 | retErr = makeErrorMessage(partID, "Failed to rename temp file") 314 | return 315 | } 316 | res.ReplacementFile, err = ffmpeg.ConvertPath(ctx, tempFileWithExt, ".ogg", []string{}, []string{"-c:a", "libopus"}, true) 317 | if err != nil { 318 | log.Err(err).Msg("Failed to convert voice message") 319 | retErr = makeErrorMessage(partID, "Failed to convert voice message") 320 | return 321 | } 322 | content.Info.MimeType = "audio/ogg" 323 | content.Body += ".ogg" 324 | res.MimeType = "audio/ogg" 325 | res.FileName += ".ogg" 326 | if file.AudioWaveSamples == nil { 327 | file.AudioWaveSamples = []int{} 328 | } 329 | for i, val := range file.AudioWaveSamples { 330 | // Slack's waveforms are in the range 0-100, we need to convert them to 0-256 331 | file.AudioWaveSamples[i] = min(int(float64(val)*2.56), 256) 332 | } 333 | content.MSC1767Audio = &event.MSC1767Audio{ 334 | Duration: content.Info.Duration, 335 | Waveform: file.AudioWaveSamples, 336 | } 337 | content.MSC3245Voice = &event.MSC3245Voice{} 338 | } else if needsMediaSize { 339 | destRS := dest.(io.ReadSeeker) 340 | _, err = destRS.Seek(0, io.SeekStart) 341 | if err == nil { 342 | cfg, _, _ := image.DecodeConfig(destRS) 343 | content.Info.Width, content.Info.Height = cfg.Width, cfg.Height 344 | } 345 | } 346 | return 347 | }) 348 | if uploadErr != nil { 349 | if retErr != nil { 350 | return retErr 351 | } 352 | if errors.Is(uploadErr, mautrix.MTooLarge) { 353 | log.Err(uploadErr).Msg("Homeserver rejected too large file") 354 | } else if httpErr := (mautrix.HTTPError{}); errors.As(uploadErr, &httpErr) && httpErr.IsStatus(413) { 355 | log.Err(uploadErr).Msg("Proxy rejected too large file") 356 | } else { 357 | log.Err(uploadErr).Msg("Failed to upload file to Matrix") 358 | } 359 | return makeErrorMessage(partID, "Failed to transfer file") 360 | } 361 | return &bridgev2.ConvertedMessagePart{ 362 | ID: partID, 363 | Type: event.EventMessage, 364 | Content: &content, 365 | } 366 | } 367 | 368 | func convertSlackFileMetadata(file *slack.File) event.MessageEventContent { 369 | content := event.MessageEventContent{ 370 | Info: &event.FileInfo{ 371 | MimeType: file.Mimetype, 372 | Size: file.Size, 373 | }, 374 | } 375 | if file.OriginalW != 0 { 376 | content.Info.Width = file.OriginalW 377 | } 378 | if file.OriginalH != 0 { 379 | content.Info.Height = file.OriginalH 380 | } 381 | if file.DurationMS != 0 { 382 | content.Info.Duration = file.DurationMS 383 | } 384 | if file.Name != "" { 385 | content.Body = file.Name 386 | } else { 387 | mimeClass := strings.Split(file.Mimetype, "/")[0] 388 | switch mimeClass { 389 | case "application": 390 | content.Body = "file" 391 | default: 392 | content.Body = mimeClass 393 | } 394 | 395 | content.Body += exmime.ExtensionFromMimetype(file.Mimetype) 396 | } 397 | 398 | if strings.HasPrefix(file.Mimetype, "image") { 399 | content.MsgType = event.MsgImage 400 | } else if strings.HasPrefix(file.Mimetype, "video") { 401 | content.MsgType = event.MsgVideo 402 | } else if strings.HasPrefix(file.Mimetype, "audio") { 403 | content.MsgType = event.MsgAudio 404 | } else { 405 | content.MsgType = event.MsgFile 406 | } 407 | 408 | return content 409 | } 410 | -------------------------------------------------------------------------------- /pkg/msgconv/matrixfmt/blocks.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package matrixfmt 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "regexp" 23 | "slices" 24 | "strconv" 25 | "strings" 26 | 27 | "github.com/rs/zerolog" 28 | "github.com/slack-go/slack" 29 | "golang.org/x/net/html" 30 | "maunium.net/go/mautrix/bridgev2" 31 | "maunium.net/go/mautrix/bridgev2/networkid" 32 | "maunium.net/go/mautrix/event" 33 | "maunium.net/go/mautrix/format" 34 | "maunium.net/go/mautrix/id" 35 | 36 | "go.mau.fi/mautrix-slack/pkg/connector/slackdb" 37 | "go.mau.fi/mautrix-slack/pkg/slackid" 38 | ) 39 | 40 | type Context struct { 41 | Ctx context.Context 42 | Portal *bridgev2.Portal 43 | Mentions *event.Mentions 44 | TagStack format.TagStack 45 | Style slack.RichTextSectionTextStyle 46 | Link string 47 | 48 | PreserveWhitespace bool 49 | } 50 | 51 | func (ctx Context) WithTag(tag string) Context { 52 | ctx.TagStack = append(ctx.TagStack, tag) 53 | return ctx 54 | } 55 | 56 | func (ctx Context) WithWhitespace() Context { 57 | ctx.PreserveWhitespace = true 58 | return ctx 59 | } 60 | 61 | func (ctx Context) StyleBold() Context { 62 | ctx.Style.Bold = true 63 | return ctx 64 | } 65 | 66 | func (ctx Context) StyleItalic() Context { 67 | ctx.Style.Italic = true 68 | return ctx 69 | } 70 | 71 | func (ctx Context) StyleStrike() Context { 72 | ctx.Style.Strike = true 73 | return ctx 74 | } 75 | 76 | func (ctx Context) StyleCode() Context { 77 | ctx.Style.Code = true 78 | return ctx 79 | } 80 | 81 | func (ctx Context) StylePtr() *slack.RichTextSectionTextStyle { 82 | if !ctx.Style.Bold && !ctx.Style.Italic && !ctx.Style.Strike && !ctx.Style.Code && !ctx.Style.Highlight && !ctx.Style.ClientHighlight && !ctx.Style.Unlink { 83 | return nil 84 | } 85 | return &ctx.Style 86 | } 87 | 88 | func (ctx Context) WithLink(link string) Context { 89 | ctx.Link = link 90 | return ctx 91 | } 92 | 93 | // HTMLParser is a somewhat customizable Matrix HTML parser. 94 | type HTMLParser struct { 95 | br *bridgev2.Bridge 96 | db *slackdb.SlackDB 97 | } 98 | 99 | func New2(br *bridgev2.Bridge, db *slackdb.SlackDB) *HTMLParser { 100 | return &HTMLParser{br: br, db: db} 101 | } 102 | 103 | func (parser *HTMLParser) GetMentionedUserID(mxid id.UserID, ctx Context) string { 104 | if ctx.Mentions != nil && !slices.Contains(ctx.Mentions.UserIDs, mxid) { 105 | // If `m.mentions` is set and doesn't contain this user, don't convert the mention 106 | // TODO does slack have some way to do silent mentions? 107 | return "" 108 | } 109 | ghostID, ok := parser.br.Matrix.ParseGhostMXID(mxid) 110 | if ok { 111 | _, userID := slackid.ParseUserID(ghostID) 112 | return userID 113 | } 114 | user, err := parser.br.GetExistingUserByMXID(ctx.Ctx, mxid) 115 | if err != nil { 116 | zerolog.Ctx(ctx.Ctx).Err(err).Msg("Failed to get user by MXID to convert mention") 117 | } else if user != nil { 118 | portalTeamID, _ := slackid.ParsePortalID(ctx.Portal.ID) 119 | for _, userLoginID := range user.GetUserLoginIDs() { 120 | userTeamID, userID := slackid.ParseUserLoginID(userLoginID) 121 | if userTeamID == portalTeamID { 122 | return userID 123 | } 124 | } 125 | } 126 | return "" 127 | } 128 | 129 | func (parser *HTMLParser) GetMentionedChannelID(mxid id.RoomID, ctx Context) string { 130 | portal, err := parser.br.GetPortalByMXID(ctx.Ctx, mxid) 131 | if err != nil { 132 | zerolog.Ctx(ctx.Ctx).Err(err).Msg("Failed to get portal by MXID to convert mention") 133 | } else if portal != nil { 134 | _, channelID := slackid.ParsePortalID(portal.ID) 135 | return channelID 136 | } 137 | return "" 138 | } 139 | 140 | func (parser *HTMLParser) GetMentionedEventLink(roomID id.RoomID, eventID id.EventID, ctx Context) string { 141 | message, err := parser.br.DB.Message.GetPartByMXID(ctx.Ctx, eventID) 142 | if err != nil { 143 | zerolog.Ctx(ctx.Ctx).Err(err).Msg("Failed to get message by MXID to convert link") 144 | return "" 145 | } else if message == nil { 146 | return "" 147 | } 148 | teamID, channelID, timestamp, ok := slackid.ParseMessageID(message.ID) 149 | if !ok { 150 | return "" 151 | } 152 | teamPortalKey := networkid.PortalKey{ 153 | ID: slackid.MakeTeamPortalID(teamID), 154 | } 155 | if parser.br.Config.SplitPortals { 156 | teamPortalKey.Receiver = ctx.Portal.Receiver 157 | } 158 | teamPortal, err := parser.br.GetPortalByKey(ctx.Ctx, teamPortalKey) 159 | if err != nil { 160 | zerolog.Ctx(ctx.Ctx).Err(err).Msg("Failed to get team portal to convert message link") 161 | return "" 162 | } 163 | teamDomain := teamPortal.Metadata.(*slackid.PortalMetadata).TeamDomain 164 | timestampWithoutDot := strings.ReplaceAll(timestamp, ".", "") 165 | return fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", teamDomain, channelID, timestampWithoutDot) 166 | } 167 | 168 | func (parser *HTMLParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) { 169 | for _, attr := range node.Attr { 170 | if attr.Key == attribute { 171 | return attr.Val, true 172 | } 173 | } 174 | return "", false 175 | } 176 | 177 | func (parser *HTMLParser) getAttribute(node *html.Node, attribute string) string { 178 | val, _ := parser.maybeGetAttribute(node, attribute) 179 | return val 180 | } 181 | 182 | func listDepth(ts format.TagStack) (depth int) { 183 | for _, tag := range ts { 184 | if tag == "ol" || tag == "ul" { 185 | depth++ 186 | } 187 | } 188 | return 189 | } 190 | 191 | func (parser *HTMLParser) listToElement(node *html.Node, ctx Context) []slack.RichTextElement { 192 | style := slack.RTEListBullet 193 | offset := 0 194 | depth := listDepth(ctx.TagStack) - 1 195 | if node.Data == "ol" { 196 | style = slack.RTEListOrdered 197 | startStr := parser.getAttribute(node, "start") 198 | if len(startStr) > 0 { 199 | var err error 200 | offset, err = strconv.Atoi(startStr) 201 | if err == nil { 202 | offset-- 203 | } 204 | } 205 | } 206 | border := 0 207 | if ctx.TagStack.Has("blockquote") { 208 | border = 1 209 | } 210 | var output []slack.RichTextElement 211 | var elements []slack.RichTextSection 212 | for child := node.FirstChild; child != nil; child = child.NextSibling { 213 | if child.Type != html.ElementNode || child.Data != "li" { 214 | continue 215 | } 216 | item, sublists := parser.nodeAndSiblingsToElement(child.FirstChild, ctx) 217 | if len(item) > 0 { 218 | elements = append(elements, *slack.NewRichTextSection(item...)) 219 | } 220 | if len(sublists) > 0 { 221 | if len(elements) > 0 { 222 | output = append(output, slack.NewRichTextList(style, depth, offset, border, elements...)) 223 | } 224 | offset += len(elements) 225 | elements = nil 226 | output = append(output, sublists...) 227 | } 228 | } 229 | if len(elements) > 0 { 230 | output = append(output, slack.NewRichTextList(style, depth, offset, border, elements...)) 231 | } 232 | return output 233 | } 234 | 235 | func (parser *HTMLParser) applyBasicFormat(node *html.Node, ctx Context) Context { 236 | switch node.Data { 237 | case "b", "strong": 238 | ctx = ctx.StyleBold() 239 | case "i", "em": 240 | ctx = ctx.StyleItalic() 241 | case "s", "del", "strike": 242 | ctx = ctx.StyleStrike() 243 | case "tt", "code": 244 | ctx = ctx.StyleCode() 245 | case "a": 246 | ctx = ctx.WithLink(parser.getAttribute(node, "href")) 247 | } 248 | return ctx 249 | } 250 | 251 | func (parser *HTMLParser) tagToElement(node *html.Node, ctx Context) ([]slack.RichTextSectionElement, []slack.RichTextElement) { 252 | ctx = ctx.WithTag(node.Data) 253 | switch node.Data { 254 | case "br": 255 | return []slack.RichTextSectionElement{slack.NewRichTextSectionTextElement("\n", ctx.StylePtr())}, nil 256 | case "hr": 257 | return nil, []slack.RichTextElement{slack.NewRichTextSection(slack.NewRichTextSectionTextElement("---", ctx.StylePtr()))} 258 | case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "tt", "code", "a", "span", "font": 259 | ctx = parser.applyBasicFormat(node, ctx) 260 | return parser.nodeAndSiblingsToElement(node.FirstChild, ctx) 261 | case "img": 262 | src := parser.getAttribute(node, "src") 263 | dbEmoji, err := parser.db.Emoji.GetByMXC(ctx.Ctx, src) 264 | if err != nil { 265 | zerolog.Ctx(ctx.Ctx).Err(err).Msg("Failed to get emoji by MXC to convert image") 266 | } else if dbEmoji != nil { 267 | return []slack.RichTextSectionElement{slack.NewRichTextSectionEmojiElement(dbEmoji.EmojiID, 0, ctx.StylePtr())}, nil 268 | } 269 | if alt := parser.getAttribute(node, "alt"); alt != "" { 270 | return []slack.RichTextSectionElement{slack.NewRichTextSectionTextElement(alt, ctx.StylePtr())}, nil 271 | } else { 272 | return nil, nil 273 | } 274 | case "h1", "h2", "h3", "h4", "h5", "h6": 275 | length := int(node.Data[1] - '0') 276 | prefix := strings.Repeat("#", length) + " " 277 | ctx = ctx.StyleBold() 278 | sectionElems, elems := parser.nodeAndSiblingsToElement(node.FirstChild, ctx) 279 | sectionElems = append([]slack.RichTextSectionElement{slack.NewRichTextSectionTextElement(prefix, ctx.StylePtr())}, sectionElems...) 280 | elems = append([]slack.RichTextElement{slack.NewRichTextSection(sectionElems...)}, elems...) 281 | return nil, elems 282 | case "p", "blockquote": 283 | sectionElems, elems := parser.nodeAndSiblingsToElement(node.FirstChild, ctx) 284 | if len(sectionElems) > 0 { 285 | var firstElem slack.RichTextElement 286 | if ctx.TagStack.Has("blockquote") { 287 | border := 0 288 | if node.Data == "blockquote" && slices.Index(ctx.TagStack, "blockquote") < len(ctx.TagStack)-1 { 289 | border = 1 290 | } 291 | firstElem = slack.NewRichTextQuote(border, sectionElems...) 292 | } else { 293 | firstElem = slack.NewRichTextSection(sectionElems...) 294 | } 295 | elems = append([]slack.RichTextElement{firstElem}, elems...) 296 | } 297 | return nil, elems 298 | case "ol", "ul": 299 | return nil, parser.listToElement(node, ctx) 300 | case "pre": 301 | //var language string 302 | if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" { 303 | //class := parser.getAttribute(node.FirstChild, "class") 304 | //if strings.HasPrefix(class, "language-") { 305 | // language = class[len("language-"):] 306 | //} 307 | node = node.FirstChild 308 | } 309 | sectionElems, elems := parser.nodeAndSiblingsToElement(node.FirstChild, ctx.WithWhitespace()) 310 | border := 0 311 | if ctx.TagStack.Has("blockquote") { 312 | border = 1 313 | } 314 | elems = append([]slack.RichTextElement{slack.NewRichTextPreformatted(border, sectionElems...)}, elems...) 315 | return nil, elems 316 | default: 317 | return parser.nodeAndSiblingsToElement(node.FirstChild, ctx) 318 | } 319 | } 320 | 321 | func (parser *HTMLParser) textToElement(text string, ctx Context) slack.RichTextSectionElement { 322 | if ctx.Link != "" { 323 | parsedMatrix, _ := id.ParseMatrixURIOrMatrixToURL(ctx.Link) 324 | if parsedMatrix != nil { 325 | if parsedMatrix.Sigil1 == '@' { 326 | userID := parser.GetMentionedUserID(parsedMatrix.UserID(), ctx) 327 | if userID != "" { 328 | return slack.NewRichTextSectionUserElement(userID, ctx.StylePtr()) 329 | } 330 | // Don't fall back to a link for mentions of unknown users 331 | return slack.NewRichTextSectionTextElement(text, ctx.StylePtr()) 332 | } else if parsedMatrix.Sigil1 == '!' && parsedMatrix.Sigil2 == 0 { 333 | channelID := parser.GetMentionedChannelID(parsedMatrix.RoomID(), ctx) 334 | if channelID != "" { 335 | return slack.NewRichTextSectionChannelElement(channelID, ctx.StylePtr()) 336 | } 337 | } else if parsedMatrix.Sigil1 == '!' && parsedMatrix.Sigil2 == '$' { 338 | eventLink := parser.GetMentionedEventLink(parsedMatrix.RoomID(), parsedMatrix.EventID(), ctx) 339 | if eventLink != "" { 340 | return slack.NewRichTextSectionLinkElement(ctx.Link, text, ctx.StylePtr()) 341 | } 342 | } 343 | // TODO add aliases for rooms so they can be mentioned easily 344 | //else if parsedMatrix.Sigil1 == '#' { 345 | } 346 | return slack.NewRichTextSectionLinkElement(ctx.Link, text, ctx.StylePtr()) 347 | } 348 | return slack.NewRichTextSectionTextElement(text, ctx.StylePtr()) 349 | } 350 | 351 | const SlackApprovedTLDs = "com|net|org|edu|gov|info|biz|int|dev|" + 352 | "ac|ad|ae|af|ag|ai|al|am|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|" + 353 | "bs|bt|bw|bz|ca|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cv|cw|cx|cy|cz|de|dj|dk|dm|dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|" + 354 | "fo|fr|ga|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gw|gy|hk|hm|hn|hr|ht|hu|ie|il|im|in|io|iq|it|je|jm|jo|jp|ke|" + 355 | "kh|ki|km|kn|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|mg|mh|mk|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|" + 356 | "mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pm|pn|pr|ps|pt|pw|qa|re|ro|rs|ru|rw|sa|sb|sc|se|sg|" + 357 | "si|sk|sl|sm|sn|sr|ss|st|su|sv|sx|sz|tc|td|tg|th|tj|tk|tl|tm|tn|to|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|vg|vi|" + 358 | "vn|vu|wf|ws|ye|yt|za|zm|zw" 359 | const URLWithProtocolPattern = `https?://[^\s/_*]+(?:/\S*)?` 360 | const URLWithoutProtocolPattern = `[^\s/_*:]+\.(?:` + SlackApprovedTLDs + `)(?:/\S*)?` 361 | const RoomPattern = `@room` 362 | 363 | var URLOrRoomRegex = regexp.MustCompile(fmt.Sprintf("%s|%s|%s", URLWithProtocolPattern, URLWithoutProtocolPattern, RoomPattern)) 364 | var URLRegex = regexp.MustCompile(fmt.Sprintf("%s|%s", URLWithProtocolPattern, URLWithoutProtocolPattern)) 365 | 366 | func (parser *HTMLParser) textToElements(text string, ctx Context) []slack.RichTextSectionElement { 367 | if !ctx.PreserveWhitespace { 368 | text = strings.Replace(text, "\n", "", -1) 369 | } 370 | if text == "" { 371 | return nil 372 | } 373 | if ctx.TagStack.Has("code") || ctx.TagStack.Has("pre") || ctx.TagStack.Has("a") { 374 | return []slack.RichTextSectionElement{parser.textToElement(text, ctx)} 375 | } 376 | var pattern *regexp.Regexp 377 | if ctx.Mentions != nil && ctx.Mentions.Room { 378 | pattern = URLOrRoomRegex 379 | } else { 380 | pattern = URLRegex 381 | } 382 | indexPairs := pattern.FindAllStringIndex(text, -1) 383 | prevEnd := 0 384 | elems := make([]slack.RichTextSectionElement, 0, len(indexPairs)*2+1) 385 | for _, pair := range indexPairs { 386 | start, end := pair[0], pair[1] 387 | prefix := text[prevEnd:start] 388 | part := text[start:end] 389 | prevEnd = end 390 | if len(prefix) > 0 { 391 | elems = append(elems, parser.textToElement(prefix, ctx)) 392 | } 393 | if part == "@room" { 394 | elems = append(elems, slack.NewRichTextSectionBroadcastElement(slack.RichTextBroadcastRangeChannel)) 395 | } else if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") { 396 | elems = append(elems, slack.NewRichTextSectionLinkElement(part, part, ctx.StylePtr())) 397 | } else { 398 | elems = append(elems, slack.NewRichTextSectionLinkElement("http://"+part, part, ctx.StylePtr())) 399 | } 400 | } 401 | if prevEnd < len(text) { 402 | elems = append(elems, parser.textToElement(text[prevEnd:], ctx)) 403 | } 404 | return elems 405 | } 406 | 407 | func (parser *HTMLParser) nodeToElement(node *html.Node, ctx Context) ([]slack.RichTextSectionElement, []slack.RichTextElement) { 408 | switch node.Type { 409 | case html.TextNode: 410 | return parser.textToElements(node.Data, ctx), nil 411 | case html.ElementNode: 412 | return parser.tagToElement(node, ctx) 413 | case html.DocumentNode: 414 | return parser.nodeAndSiblingsToElement(node.FirstChild, ctx) 415 | default: 416 | return nil, nil 417 | } 418 | } 419 | 420 | func (parser *HTMLParser) nodeAndSiblingsToElement(node *html.Node, ctx Context) (sectionElems []slack.RichTextSectionElement, elems []slack.RichTextElement) { 421 | sectionElemsLocked := false 422 | var sectionCollector []slack.RichTextSectionElement 423 | for ; node != nil; node = node.NextSibling { 424 | se, e := parser.nodeToElement(node, ctx) 425 | if len(se) > 0 { 426 | if sectionElemsLocked { 427 | sectionCollector = append(sectionCollector, se...) 428 | } else { 429 | sectionElems = append(sectionElems, se...) 430 | } 431 | } 432 | if len(e) > 0 { 433 | sectionElemsLocked = true 434 | if len(sectionCollector) > 0 { 435 | elems = append(elems, slack.NewRichTextSection(sectionCollector...)) 436 | sectionCollector = nil 437 | } 438 | elems = append(elems, e...) 439 | } 440 | } 441 | if len(sectionCollector) > 0 { 442 | elems = append(elems, slack.NewRichTextSection(sectionCollector...)) 443 | } 444 | return 445 | } 446 | 447 | func (parser *HTMLParser) nodeToBlock(node *html.Node, ctx Context) *slack.RichTextBlock { 448 | sectionElems, elems := parser.nodeToElement(node, ctx) 449 | if len(sectionElems) > 0 { 450 | elems = append([]slack.RichTextElement{slack.NewRichTextSection(sectionElems...)}, elems...) 451 | } 452 | return slack.NewRichTextBlock("", elems...) 453 | } 454 | 455 | func (parser *HTMLParser) ParseText(ctx context.Context, text string, mentions *event.Mentions, portal *bridgev2.Portal) *slack.RichTextBlock { 456 | formatCtx := Context{ 457 | Ctx: ctx, 458 | TagStack: make(format.TagStack, 0), 459 | Portal: portal, 460 | Mentions: mentions, 461 | PreserveWhitespace: true, 462 | } 463 | elems := parser.textToElements(text, formatCtx) 464 | return slack.NewRichTextBlock("", slack.NewRichTextSection(elems...)) 465 | } 466 | 467 | // Parse converts Matrix HTML into text using the settings in this parser. 468 | func (parser *HTMLParser) Parse(ctx context.Context, htmlData string, mentions *event.Mentions, portal *bridgev2.Portal) *slack.RichTextBlock { 469 | formatCtx := Context{ 470 | Ctx: ctx, 471 | TagStack: make(format.TagStack, 0, 4), 472 | Portal: portal, 473 | Mentions: mentions, 474 | } 475 | node, _ := html.Parse(strings.NewReader(htmlData)) 476 | return parser.nodeToBlock(node, formatCtx) 477 | } 478 | -------------------------------------------------------------------------------- /pkg/msgconv/mrkdwn/parser.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package mrkdwn 18 | 19 | import ( 20 | "context" 21 | "regexp" 22 | "strings" 23 | 24 | "github.com/yuin/goldmark" 25 | "github.com/yuin/goldmark/parser" 26 | "github.com/yuin/goldmark/util" 27 | "maunium.net/go/mautrix/event" 28 | "maunium.net/go/mautrix/format" 29 | "maunium.net/go/mautrix/format/mdext" 30 | 31 | "go.mau.fi/mautrix-slack/pkg/emoji" 32 | ) 33 | 34 | // indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine. 35 | // Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear). 36 | type indentableParagraphParser struct { 37 | parser.BlockParser 38 | } 39 | 40 | var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()} 41 | 42 | func (b *indentableParagraphParser) CanAcceptIndentedLine() bool { 43 | return true 44 | } 45 | 46 | type SlackMrkdwnParser struct { 47 | Params *Params 48 | Markdown goldmark.Markdown 49 | } 50 | 51 | var removeFeatures = []any{ 52 | parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(), 53 | parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(), 54 | parser.NewCodeBlockParser(), parser.NewLinkParser(), parser.NewEmphasisParser(), 55 | } 56 | var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500))) 57 | 58 | func New(options *Params) *SlackMrkdwnParser { 59 | return &SlackMrkdwnParser{ 60 | Markdown: goldmark.New( 61 | goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeatures...)), 62 | fixIndentedParagraphs, 63 | format.HTMLOptions, 64 | goldmark.WithExtensions(mdext.ShortStrike, mdext.ShortEmphasis, &slackTag{Params: options}), 65 | ), 66 | } 67 | } 68 | 69 | var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`) 70 | 71 | func (smp *SlackMrkdwnParser) Parse(ctx context.Context, input string, mentions *event.Mentions) (string, error) { 72 | parserCtx := parser.NewContext() 73 | parserCtx.Set(ContextKeyContext, ctx) 74 | parserCtx.Set(ContextKeyMentions, mentions) 75 | 76 | input = emoji.ReplaceShortcodesWithUnicode(input) 77 | // TODO is this actually needed or was it just blindly copied from Discord? 78 | input = escapeFixer.ReplaceAllStringFunc(input, func(s string) string { 79 | return s[:2] + `\` + s[2:] 80 | }) 81 | 82 | var buf strings.Builder 83 | err := smp.Markdown.Convert([]byte(input), &buf, parser.WithContext(parserCtx)) 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | return format.UnwrapSingleParagraph(buf.String()), nil 89 | } 90 | -------------------------------------------------------------------------------- /pkg/msgconv/mrkdwn/tag.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package mrkdwn 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "html" 23 | "io" 24 | "regexp" 25 | "strconv" 26 | "strings" 27 | "time" 28 | 29 | "github.com/yuin/goldmark" 30 | "github.com/yuin/goldmark/ast" 31 | "github.com/yuin/goldmark/parser" 32 | "github.com/yuin/goldmark/renderer" 33 | "github.com/yuin/goldmark/text" 34 | goldmarkUtil "github.com/yuin/goldmark/util" 35 | "maunium.net/go/mautrix/event" 36 | "maunium.net/go/mautrix/id" 37 | ) 38 | 39 | type astSlackTag struct { 40 | ast.BaseInline 41 | 42 | label string 43 | } 44 | 45 | var _ ast.Node = (*astSlackTag)(nil) 46 | var astKindSlackTag = ast.NewNodeKind("SlackTag") 47 | 48 | func (n *astSlackTag) Dump(source []byte, level int) { 49 | ast.DumpHelper(n, source, level, nil, nil) 50 | } 51 | 52 | func (n *astSlackTag) Kind() ast.NodeKind { 53 | return astKindSlackTag 54 | } 55 | 56 | type astSlackUserMention struct { 57 | astSlackTag 58 | 59 | userID string 60 | mxid id.UserID 61 | name string 62 | } 63 | 64 | func (n *astSlackUserMention) String() string { 65 | if n.label != "" { 66 | return fmt.Sprintf("<@%s|%s>", n.userID, n.label) 67 | } else { 68 | return fmt.Sprintf("<@%s>", n.userID) 69 | } 70 | } 71 | 72 | type astSlackChannelMention struct { 73 | astSlackTag 74 | 75 | serverName string 76 | channelID string 77 | mxid id.RoomID 78 | alias id.RoomAlias 79 | name string 80 | } 81 | 82 | func (n *astSlackChannelMention) String() string { 83 | if n.label != "" { 84 | return fmt.Sprintf("<#%s|%s>", n.channelID, n.label) 85 | } else { 86 | return fmt.Sprintf("<#%s>", n.channelID) 87 | } 88 | } 89 | 90 | type astSlackURL struct { 91 | astSlackTag 92 | 93 | url string 94 | } 95 | 96 | func (n *astSlackURL) String() string { 97 | if n.label != n.url { 98 | return fmt.Sprintf("<%s|%s>", n.url, n.label) 99 | } else { 100 | return fmt.Sprintf("<%s>", n.url) 101 | } 102 | } 103 | 104 | type astSlackSpecialMention struct { 105 | astSlackTag 106 | 107 | content string 108 | } 109 | 110 | func (n *astSlackSpecialMention) String() string { 111 | if n.label != "" { 112 | return fmt.Sprintf("", n.content, n.label) 113 | } else { 114 | return fmt.Sprintf("", n.content) 115 | } 116 | } 117 | 118 | type Params struct { 119 | ServerName string 120 | GetUserInfo func(ctx context.Context, userID string) (mxid id.UserID, name string) 121 | GetChannelInfo func(ctx context.Context, channelID string) (mxid id.RoomID, alias id.RoomAlias, name string) 122 | } 123 | 124 | type slackTagParser struct { 125 | *Params 126 | } 127 | 128 | // Regex matching Slack docs at https://api.slack.com/reference/surfaces/formatting#retrieving-messages 129 | var slackTagRegex = regexp.MustCompile(`<(#|@|!|)([^|>]+)(\|([^|>]*))?>`) 130 | 131 | func (s *slackTagParser) Trigger() []byte { 132 | return []byte{'<'} 133 | } 134 | 135 | var ( 136 | ContextKeyContext = parser.NewContextKey() 137 | ContextKeyMentions = parser.NewContextKey() 138 | ) 139 | 140 | func (s *slackTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 141 | //before := block.PrecendingCharacter() 142 | line, _ := block.PeekLine() 143 | match := slackTagRegex.FindSubmatch(line) 144 | if match == nil { 145 | return nil 146 | } 147 | //seg := segment.WithStop(segment.Start + len(match[0])) 148 | block.Advance(len(match[0])) 149 | 150 | sigil := string(match[1]) 151 | content := string(match[2]) 152 | text := string(match[4]) 153 | 154 | ctx := pc.Get(ContextKeyContext).(context.Context) 155 | 156 | tag := astSlackTag{label: text} 157 | switch sigil { 158 | case "@": 159 | mxid, name := s.GetUserInfo(ctx, content) 160 | pc.Get(ContextKeyMentions).(*event.Mentions).Add(mxid) 161 | return &astSlackUserMention{astSlackTag: tag, userID: content, mxid: mxid, name: name} 162 | case "#": 163 | mxid, alias, name := s.GetChannelInfo(ctx, content) 164 | return &astSlackChannelMention{astSlackTag: tag, channelID: content, serverName: s.ServerName, mxid: mxid, alias: alias, name: name} 165 | case "!": 166 | switch content { 167 | case "channel", "everyone", "here": 168 | pc.Get(ContextKeyMentions).(*event.Mentions).Room = true 169 | default: 170 | } 171 | return &astSlackSpecialMention{astSlackTag: tag, content: content} 172 | case "": 173 | return &astSlackURL{astSlackTag: tag, url: content} 174 | default: 175 | return nil 176 | } 177 | } 178 | 179 | func (s *slackTagParser) CloseBlock(parent ast.Node, pc parser.Context) { 180 | // nothing to do 181 | } 182 | 183 | type slackTagHTMLRenderer struct{} 184 | 185 | var defaultSlackTagHTMLRenderer = &slackTagHTMLRenderer{} 186 | 187 | func (r *slackTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 188 | reg.Register(astKindSlackTag, r.renderSlackTag) 189 | } 190 | 191 | func UserMentionToHTML(out io.Writer, userID string, mxid id.UserID, name string) { 192 | if mxid != "" { 193 | _, _ = fmt.Fprintf(out, `%s`, mxid.URI().MatrixToURL(), html.EscapeString(name)) 194 | } else { 195 | _, _ = fmt.Fprintf(out, "<@%s>", userID) 196 | } 197 | } 198 | 199 | func RoomMentionToHTML(out io.Writer, channelID string, mxid id.RoomID, alias id.RoomAlias, name, serverName string) { 200 | if alias != "" { 201 | _, _ = fmt.Fprintf(out, `%s`, alias.URI().MatrixToURL(), html.EscapeString(name)) 202 | } else if mxid != "" { 203 | _, _ = fmt.Fprintf(out, `%s`, mxid.URI(serverName).MatrixToURL(), html.EscapeString(name)) 204 | } else if name != "" { 205 | _, _ = fmt.Fprintf(out, "%s", name) 206 | } else { 207 | _, _ = fmt.Fprintf(out, "<#%s>", channelID) 208 | } 209 | } 210 | 211 | func (r *slackTagHTMLRenderer) renderSlackTag(w goldmarkUtil.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) { 212 | status = ast.WalkContinue 213 | if !entering { 214 | return 215 | } 216 | switch node := n.(type) { 217 | case *astSlackUserMention: 218 | UserMentionToHTML(w, node.userID, node.mxid, node.name) 219 | return 220 | case *astSlackChannelMention: 221 | RoomMentionToHTML(w, node.channelID, node.mxid, node.alias, node.name, node.serverName) 222 | return 223 | case *astSlackSpecialMention: 224 | parts := strings.Split(node.content, "^") 225 | switch parts[0] { 226 | case "date": 227 | timestamp, converr := strconv.ParseInt(parts[1], 10, 64) 228 | if converr != nil { 229 | return 230 | } 231 | t := time.Unix(timestamp, 0) 232 | 233 | mapping := map[string]string{ 234 | "{date_num}": t.Local().Format("2006-01-02"), 235 | "{date}": t.Local().Format("January 2, 2006"), 236 | "{date_pretty}": t.Local().Format("January 2, 2006"), 237 | "{date_short}": t.Local().Format("Jan 2, 2006"), 238 | "{date_short_pretty}": t.Local().Format("Jan 2, 2006"), 239 | "{date_long}": t.Local().Format("Monday, January 2, 2006"), 240 | "{date_long_pretty}": t.Local().Format("Monday, January 2, 2006"), 241 | "{time}": t.Local().Format("15:04 MST"), 242 | "{time_secs}": t.Local().Format("15:04:05 MST"), 243 | } 244 | 245 | for k, v := range mapping { 246 | parts[2] = strings.ReplaceAll(parts[2], k, v) 247 | } 248 | 249 | if len(parts) > 3 { 250 | _, _ = fmt.Fprintf(w, `%s`, html.EscapeString(parts[3]), html.EscapeString(parts[2])) 251 | } else { 252 | _, _ = w.WriteString(html.EscapeString(parts[2])) 253 | } 254 | return 255 | case "channel", "everyone", "here": 256 | // do @room mentions? 257 | return 258 | case "subteam": 259 | // do subteam handling? more spaces? 260 | return 261 | default: 262 | return 263 | } 264 | case *astSlackURL: 265 | label := node.label 266 | if label == "" { 267 | label = node.url 268 | } 269 | _, _ = fmt.Fprintf(w, `%s`, html.EscapeString(node.url), html.EscapeString(label)) 270 | return 271 | } 272 | stringifiable, ok := n.(fmt.Stringer) 273 | if ok { 274 | _, _ = w.WriteString(stringifiable.String()) 275 | } else { 276 | _, _ = w.Write(source) 277 | } 278 | return 279 | } 280 | 281 | type slackTag struct { 282 | *Params 283 | } 284 | 285 | func (e *slackTag) Extend(m goldmark.Markdown) { 286 | m.Parser().AddOptions(parser.WithInlineParsers( 287 | goldmarkUtil.Prioritized(&slackTagParser{Params: e.Params}, 150), 288 | )) 289 | m.Renderer().AddOptions(renderer.WithNodeRenderers( 290 | goldmarkUtil.Prioritized(defaultSlackTagHTMLRenderer, 150), 291 | )) 292 | } 293 | -------------------------------------------------------------------------------- /pkg/msgconv/msgconv.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package msgconv 18 | 19 | import ( 20 | "context" 21 | "net" 22 | "net/http" 23 | "time" 24 | 25 | "github.com/rs/zerolog" 26 | "github.com/slack-go/slack" 27 | "maunium.net/go/mautrix/bridgev2" 28 | "maunium.net/go/mautrix/bridgev2/networkid" 29 | "maunium.net/go/mautrix/id" 30 | 31 | "go.mau.fi/mautrix-slack/pkg/connector/slackdb" 32 | "go.mau.fi/mautrix-slack/pkg/msgconv/matrixfmt" 33 | "go.mau.fi/mautrix-slack/pkg/msgconv/mrkdwn" 34 | "go.mau.fi/mautrix-slack/pkg/slackid" 35 | ) 36 | 37 | type MessageConverter struct { 38 | Bridge *bridgev2.Bridge 39 | HTTP http.Client 40 | 41 | MatrixHTMLParser *matrixfmt.HTMLParser 42 | SlackMrkdwnParser *mrkdwn.SlackMrkdwnParser 43 | 44 | ServerName string 45 | MaxFileSize int 46 | } 47 | 48 | type contextKey int 49 | 50 | const ( 51 | contextKeyPortal contextKey = iota 52 | contextKeySource 53 | ) 54 | 55 | type SlackClientProvider interface { 56 | GetClient() *slack.Client 57 | GetEmoji(context.Context, string) (string, bool) 58 | } 59 | 60 | func (mc *MessageConverter) GetMentionedUserInfo(ctx context.Context, userID string) (mxid id.UserID, name string) { 61 | source := ctx.Value(contextKeySource).(*bridgev2.UserLogin) 62 | teamID, loggedInUserID := slackid.ParseUserLoginID(source.ID) 63 | ghost, err := mc.Bridge.GetGhostByID(ctx, slackid.MakeUserID(teamID, userID)) 64 | if err != nil { 65 | zerolog.Ctx(ctx).Err(err).Msg("Failed to get mentioned ghost") 66 | } else if ghost != nil { 67 | /*if ghost.Name == "" { 68 | // TODO update ghost info 69 | }*/ 70 | name = ghost.Name 71 | mxid = ghost.Intent.GetMXID() 72 | } 73 | if userID == loggedInUserID { 74 | mxid = source.UserMXID 75 | } else if otherUserLogin := mc.Bridge.GetCachedUserLoginByID(slackid.MakeUserLoginID(teamID, userID)); otherUserLogin != nil { 76 | mxid = otherUserLogin.UserMXID 77 | } 78 | return 79 | } 80 | 81 | func (mc *MessageConverter) GetMentionedRoomInfo(ctx context.Context, channelID string) (mxid id.RoomID, alias id.RoomAlias, name string) { 82 | source := ctx.Value(contextKeySource).(*bridgev2.UserLogin) 83 | teamID, _ := slackid.ParseUserLoginID(source.ID) 84 | portal, err := mc.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ 85 | ID: slackid.MakePortalID(teamID, channelID), 86 | Receiver: source.ID, 87 | }) 88 | if err != nil { 89 | zerolog.Ctx(ctx).Err(err).Msg("Failed to get mentioned portal") 90 | } else if portal == nil { 91 | client := source.Client.(SlackClientProvider).GetClient() 92 | if client == nil { 93 | return 94 | } 95 | // TODO fetch info and update portal 96 | return 97 | } 98 | return portal.MXID, "", portal.Name 99 | } 100 | 101 | func New(br *bridgev2.Bridge, db *slackdb.SlackDB) *MessageConverter { 102 | mc := &MessageConverter{ 103 | Bridge: br, 104 | HTTP: http.Client{ 105 | Transport: &http.Transport{ 106 | DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, 107 | TLSHandshakeTimeout: 10 * time.Second, 108 | ResponseHeaderTimeout: 20 * time.Second, 109 | ForceAttemptHTTP2: true, 110 | }, 111 | Timeout: 60 * time.Second, 112 | }, 113 | 114 | MaxFileSize: 50 * 1024 * 1024, 115 | ServerName: br.Matrix.ServerName(), 116 | 117 | MatrixHTMLParser: matrixfmt.New2(br, db), 118 | } 119 | mc.SlackMrkdwnParser = mrkdwn.New(&mrkdwn.Params{ 120 | ServerName: br.Matrix.ServerName(), 121 | GetUserInfo: mc.GetMentionedUserInfo, 122 | GetChannelInfo: mc.GetMentionedRoomInfo, 123 | }) 124 | return mc 125 | } 126 | -------------------------------------------------------------------------------- /pkg/slackid/dbmeta.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package slackid 18 | 19 | import ( 20 | "go.mau.fi/util/jsontime" 21 | ) 22 | 23 | type PortalMetadata struct { 24 | // Only present for team portals, not channels 25 | TeamDomain string `json:"team_domain,omitempty"` 26 | EditMaxAge *int `json:"edit_max_age,omitempty"` 27 | AllowDelete *bool `json:"allow_delete,omitempty"` 28 | } 29 | 30 | type GhostMetadata struct { 31 | SlackUpdatedTS int64 `json:"slack_updated_ts"` 32 | LastSync jsontime.Unix `json:"last_sync"` 33 | } 34 | 35 | type UserLoginMetadata struct { 36 | Email string `json:"email"` 37 | Token string `json:"token"` 38 | CookieToken string `json:"cookie_token,omitempty"` 39 | AppToken string `json:"app_token,omitempty"` 40 | } 41 | 42 | type MessageMetadata struct { 43 | CaptionMerged bool `json:"caption_merged"` 44 | LastEditTS string `json:"last_edit_ts"` 45 | } 46 | -------------------------------------------------------------------------------- /pkg/slackid/id.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package slackid 18 | 19 | import ( 20 | "fmt" 21 | "strconv" 22 | "strings" 23 | "time" 24 | 25 | "maunium.net/go/mautrix/bridgev2/networkid" 26 | ) 27 | 28 | func MakeMessageID(teamID, channelID, timestamp string) networkid.MessageID { 29 | return networkid.MessageID(fmt.Sprintf("%s-%s-%s", teamID, channelID, timestamp)) 30 | } 31 | 32 | func ParseMessageID(id networkid.MessageID) (teamID, channelID, timestamp string, ok bool) { 33 | parts := strings.Split(string(id), "-") 34 | if len(parts) != 3 { 35 | return 36 | } 37 | return parts[0], parts[1], parts[2], true 38 | } 39 | 40 | func ParseSlackTimestamp(timestamp string) time.Time { 41 | parts := strings.Split(timestamp, ".") 42 | 43 | seconds, err := strconv.ParseInt(parts[0], 10, 64) 44 | if err != nil { 45 | return time.Now().UTC() 46 | } 47 | 48 | var nanoSeconds int64 49 | if len(parts) > 1 { 50 | nsecPart := parts[1] + strings.Repeat("0", 9-len(parts[1])) 51 | nsec, err := strconv.ParseInt(nsecPart, 10, 64) 52 | if err != nil { 53 | nanoSeconds = 0 54 | } else { 55 | nanoSeconds = nsec 56 | } 57 | } 58 | 59 | return time.Unix(seconds, nanoSeconds) 60 | } 61 | 62 | func MakeUserID(teamID, userID string) networkid.UserID { 63 | return networkid.UserID(fmt.Sprintf("%s-%s", strings.ToLower(teamID), strings.ToLower(userID))) 64 | } 65 | 66 | func MakeUserLoginID(teamID, userID string) networkid.UserLoginID { 67 | return networkid.UserLoginID(fmt.Sprintf("%s-%s", teamID, userID)) 68 | } 69 | 70 | func ParseUserID(id networkid.UserID) (teamID, userID string) { 71 | parts := strings.Split(string(id), "-") 72 | if len(parts) != 2 { 73 | return "", "" 74 | } 75 | return strings.ToUpper(parts[0]), strings.ToUpper(parts[1]) 76 | } 77 | 78 | func ParseUserLoginID(id networkid.UserLoginID) (teamID, userID string) { 79 | parts := strings.Split(string(id), "-") 80 | if len(parts) != 2 { 81 | return "", "" 82 | } 83 | return parts[0], parts[1] 84 | } 85 | 86 | func UserIDToUserLoginID(userID networkid.UserID) networkid.UserLoginID { 87 | return networkid.UserLoginID(strings.ToUpper(string(userID))) 88 | } 89 | 90 | func UserLoginIDToUserID(userLoginID networkid.UserLoginID) networkid.UserID { 91 | return networkid.UserID(strings.ToLower(string(userLoginID))) 92 | } 93 | 94 | func MakeTeamPortalID(teamID string) networkid.PortalID { 95 | return networkid.PortalID(teamID) 96 | } 97 | 98 | func MakePortalID(teamID, channelID string) networkid.PortalID { 99 | if channelID == "" { 100 | return MakeTeamPortalID(teamID) 101 | } 102 | return networkid.PortalID(fmt.Sprintf("%s-%s", teamID, channelID)) 103 | } 104 | 105 | func ParsePortalID(id networkid.PortalID) (teamID, channelID string) { 106 | parts := strings.Split(string(id), "-") 107 | if len(parts) == 1 { 108 | return parts[0], "" 109 | } else if len(parts) == 2 { 110 | return parts[0], parts[1] 111 | } else { 112 | return 113 | } 114 | } 115 | 116 | func MakePortalKey(teamID, channelID string, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) { 117 | key.ID = MakePortalID(teamID, channelID) 118 | if wantReceiver { 119 | key.Receiver = userLoginID 120 | } 121 | return 122 | } 123 | 124 | type PartType string 125 | 126 | const ( 127 | PartTypeFile PartType = "file" 128 | PartTypeAttachment PartType = "attachment" 129 | ) 130 | 131 | func MakePartID(partType PartType, index int, id string) networkid.PartID { 132 | return networkid.PartID(fmt.Sprintf("%s-%d-%s", partType, index, id)) 133 | } 134 | 135 | func ParsePartID(partID networkid.PartID) (partType PartType, index int, id string, ok bool) { 136 | parts := strings.Split(string(partID), "-") 137 | if len(parts) != 3 { 138 | return 139 | } 140 | var err error 141 | index, err = strconv.Atoi(parts[1]) 142 | if err != nil { 143 | return 144 | } 145 | partType = PartType(parts[0]) 146 | id = parts[2] 147 | ok = true 148 | return 149 | } 150 | -------------------------------------------------------------------------------- /pkg/slackid/id_test.go: -------------------------------------------------------------------------------- 1 | // mautrix-slack - A Matrix-Slack puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package slackid 18 | 19 | import ( 20 | "testing" 21 | "time" 22 | 23 | "github.com/stretchr/testify/assert" 24 | ) 25 | 26 | func TestParseSlackTimestamp(t *testing.T) { 27 | type testCase struct { 28 | name string 29 | input string 30 | expected time.Time 31 | } 32 | testCases := []testCase{ 33 | {"Normal", "1234567890.123456", time.Unix(1234567890, 123456000)}, 34 | {"OffBy1-", "1234567890.12345", time.Unix(1234567890, 123450000)}, 35 | {"OffBy1+", "1234567890.1234567", time.Unix(1234567890, 123456700)}, 36 | {"SecondsOnly", "1234567890", time.Unix(1234567890, 0)}, 37 | {"Millis", "1234567890.123", time.UnixMilli(1234567890123)}, 38 | } 39 | for _, tc := range testCases { 40 | t.Run(tc.name, func(t *testing.T) { 41 | assert.Equal(t, tc.expected, ParseSlackTimestamp(tc.input)) 42 | }) 43 | } 44 | } 45 | --------------------------------------------------------------------------------