` 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 | 
3 | [](LICENSE)
4 | [](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 |
--------------------------------------------------------------------------------