-
Notifications
You must be signed in to change notification settings - Fork 73
/
Copy pathsubstitutions.ts
368 lines (325 loc) · 13.1 KB
/
substitutions.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Logger } from "matrix-appservice-bridge";
import * as emoji from "node-emoji";
import { Main } from "./Main";
import { ISlackFile } from "./BaseSlackHandler";
import escapeStringRegexp from "escape-string-regexp";
const log = new Logger("substitutions");
const ATTACHMENT_TYPES = ["m.audio", "m.video", "m.file", "m.image"];
const PILL_REGEX = /<a href="https:\/\/matrix\.to\/#\/(#|@|\+)([^"]+)">([^<]+)<\/a>/g;
/**
* Will return the emoji's name within ':'.
* @param name The emoji's name.
*/
export const getFallbackForMissingEmoji = (name: string): string => (
`:${name}:`
);
interface PillItem {
id: string;
text: string;
}
export interface IMatrixToSlackResult {
link_names: boolean; // This no longer works for nicks but is needed to make @channel work.
text?: string;
username?: string;
attachments?: [{
fallback: string,
image_url: string,
}];
encrypted_file?: string;
}
class Substitutions {
/**
* Performs any escaping, unescaping, or substituting required to make the text
* of a Slack message appear like the text of a Matrix message.
*
* @param body the text, in Slack's format.
* @param file options slack file object
*/
public slackToMatrix(body: string, file?: ISlackFile): string {
log.debug("running substitutions on ", body);
body = this.htmlUnescape(body);
body = body.replace("<!channel>", "@room");
body = body.replace("<!here>", "@room");
body = body.replace("<!everyone>", "@room");
// if we have a file, attempt to get the direct link to the file
if (file && file.permalink_public && file.url_private && file.permalink) {
const url = this.getSlackFileUrl({
permalink_public: file.permalink_public,
url_private: file.url_private,
});
body = url ? body.replace(file.permalink, url) : body;
}
body = emoji.emojify(body, getFallbackForMissingEmoji);
return body;
}
/**
* Performs any escaping, unescaping, or substituting required to make the text
* of a Matrix message appear like the text of a Slack message.
*
* @param event the Matrix event.
* @param main the toplevel main instance
* @return An object which can be posted as JSON to the Slack API.
*/
public async matrixToSlack(event: any, main: Main, teamId: string): Promise<IMatrixToSlackResult|null> {
if (
!event ||
typeof event !== 'object' ||
!event.content ||
typeof event.content !== 'object' ||
typeof event.sender !== "string"
) {
return null;
}
const msgType = event.content.msgtype || "m.text";
const isAttachment = ATTACHMENT_TYPES.includes(msgType);
let body: string = event.content.body;
if ((typeof(body) !== "string" || body.length < 1) && !isAttachment) {
return null;
}
if (isAttachment) {
// If it's an attachment, we can allow the body.
body = typeof(body) === "string" ? body : "";
}
body = this.htmlEscape(body);
// Convert markdown links to slack mrkdwn links
body = body.replace(/!?\[(.*?)\]\((.+?)\)/gm, "<$2|$1>");
// emotes in slack are just italicised
if (msgType === "m.emote") {
body = `_${body}_`;
}
// replace Element "pill" behavior to "@" mention for slack users
const format = event.content.format || "org.matrix.custom.html";
const htmlString: string|undefined = event.content.formatted_body;
let messageHadPills = false;
if (htmlString && format === "org.matrix.custom.html") {
const mentions = this.pillsToItems(htmlString);
messageHadPills = (mentions.aliases.concat(mentions.communities, mentions.users).length > 0);
for (const alias of mentions.aliases) {
if (!body.includes(alias.text)) {
// Do not process an item we have no way to replace.
continue;
}
try {
const roomIdResponse = await main.botIntent.resolveRoom(alias.id);
const room = main.rooms.getByMatrixRoomId(roomIdResponse);
if (room) {
// aliases are faily unique in form, so we can replace these easily enough
const aliasRegex = new RegExp(escapeStringRegexp(alias.text), "g");
body = body.replace(aliasRegex, `<#${room.SlackChannelId}>`);
}
} catch (ex) {
// We failed the lookup so just continue
continue;
}
}
for (const user of mentions.users) {
// This also checks if the user is a slack user.
const ghost = await main.ghostStore.getExisting(user.id);
if (ghost && ghost.slackId) {
// We need to replace the user's displayname with the slack mention, but we need to
// ensure to do it only on whitespace wrapped strings.
const userRegex = new RegExp(`(?<=^|\\s)${escapeStringRegexp(user.text)}(?=$|\\s|: )`, "g");
body = body.replace(userRegex, `<@${ghost.slackId}>`);
}
}
// Nothing to be done on communities yet.
}
// convert @room to @channel
body = body.replace("@room", "@channel");
// Strip out any language specifier on the code tags, as they are not supported by slack.
// TODO: https://github.com/matrix-org/matrix-appservice-slack/issues/279
body = body.replace(/```[\w*]+\n/g, "```\n");
if (!messageHadPills && teamId) {
// Note: This slower plainTextSlackMentions call is only used if there is not a pill in the message,
// meaning if we can we use the much simpler pill subs rather than this.
body = await plainTextSlackMentions(main, body, teamId);
}
if (!isAttachment) {
return {
link_names: true, // This no longer works for nicks but is needed to make @channel work.
text: body,
username: event.sender,
};
}
if (!event.content.url || !event.content.url.startsWith("mxc://")) {
// Url is missing or wrong. We don't want to send any messages
// in this case.
return null;
}
const url = main.getUrlForMxc(event.content.url, main.encryptRoom);
if (main.encryptRoom) {
return {
encrypted_file: url,
link_names: false,
};
}
if (msgType === "m.image") {
// Images are special, we can send those as attachments.
return {
link_names: false,
username: event.sender,
attachments: [
{
fallback: body,
image_url: url,
},
],
};
}
// Send all other types as links
return {
link_names: true,
text: `<${url}|${body}>`,
username: event.sender,
};
}
/**
* This will parse a message and return the "pills" found within.
* @param htmlBody HTML content of a Matrix message
*/
private pillsToItems(htmlBody: string) {
const ret: { users: PillItem[], aliases: PillItem[], communities: PillItem[]} = {
users: [],
aliases: [],
communities: [],
};
const MAX_ITERATIONS = 15;
let res: RegExpExecArray|null = PILL_REGEX.exec(htmlBody);
for (let i = 0; i < MAX_ITERATIONS && res != null; i++) {
const sigil = res[1];
const item: PillItem = {
id: res[1] + res[2],
text: res[3],
};
if (sigil === "@") {
ret.users.push(item);
} else if (sigil === "#") {
ret.aliases.push(item);
} else if (sigil === "+") {
ret.communities.push(item);
}
res = PILL_REGEX.exec(htmlBody);
}
return ret;
}
/**
* Replace &, < and > in a string with their HTML escaped counterparts.
*/
public htmlEscape(s: string): string {
return s.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
/**
* Replace <, > and & in a string with their real counterparts.
*/
public htmlUnescape(s: string): string {
return s.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/&/g, "&");
}
public makeDiff(prev: string, curr: string): { prev: string, curr: string, before: string, after: string} {
let i;
for (i = 0; i < curr.length && i < prev.length; i++) {
if (curr.charAt(i) !== prev.charAt(i)) { break; }
}
// retreat to the start of a word
while (i > 0 && /\S/.test(curr.charAt(i - 1))) { i--; }
const prefixLen = i;
for (i = 0; i < curr.length && i < prev.length; i++) {
if (rcharAt(curr, i) !== rcharAt(prev, i)) { break; }
}
// advance to the end of a word
while (i > 0 && /\S/.test(rcharAt(curr, i - 1))) { i--; }
const suffixLen = i;
// Extract the common prefix and suffix strings themselves and
// mutate the prev/curr strings to only contain the differing
// middle region
const prefix = curr.slice(0, prefixLen);
curr = curr.slice(prefixLen);
prev = prev.slice(prefixLen);
let suffix = "";
if (suffixLen > 0) {
suffix = curr.slice(-suffixLen);
curr = curr.slice(0, -suffixLen);
prev = prev.slice(0, -suffixLen);
}
// At this point, we have four strings; the common prefix and
// suffix, and the edited middle part. To display it nicely as a
// matrix message we'll use the final word of the prefix and the
// first word of the suffix as "context" for a customly-formatted
// message.
let before = finalWord(prefix);
if (before !== prefix) { before = "... " + before; }
let after = firstWord(suffix);
if (after !== suffix) { after = after + " ..."; }
return {prev, curr, before, after};
}
public getSlackFileUrl(file: {
permalink_public: string,
url_private: string,
}): string|undefined {
const pubSecret = file.permalink_public.match(/https?:\/\/slack-files.com\/[^-]*-[^-]*-(.*)/);
if (!pubSecret) {
throw Error("Could not determine pub_secret");
}
// try to get direct link to the file
if (pubSecret && pubSecret.length > 0) {
return `${file.url_private}?pub_secret=${pubSecret[1]}`;
}
}
}
const substitutions = new Substitutions();
export default substitutions;
/**
* Replace plain text form of @displayname mentions with the slack mention syntax.
*
* @param main the toplevel main instance
* @param string The string to perform replacements on.
* @param room_id The room the message was sent in.
* @return The string with replacements performed.
*/
const plainTextSlackMentions = async(main: Main, body: string, teamId: string) => {
const allUsers = await main.datastore.getAllUsersForTeam(teamId);
const users = allUsers.filter((u) => u.display_name && u.display_name.length > 0) as {display_name: string, slack_id: string}[];
users.sort((u1, u2) => u2.display_name.length - u1.display_name.length);
for (const user of users) {
const displayName = `@${user.display_name}`;
if (body.includes(displayName)) {
const userRegex = new RegExp(`${escapeStringRegexp(displayName)}(?=$|s)`, "g");
body = body.replace(userRegex, `<@${user.slack_id}>`);
}
}
return body;
};
// These functions are copied and modified from the Gitter AS
// idx counts backwards from the end of the string; 0 is final character
const rcharAt = (s: string, idx: number) => (
s.charAt(s.length - 1 - idx)
);
/**
* Gets the first word in a given string.
*/
const firstWord = (s: string): string => {
const groups = s.match(/^\s*\S+/);
return groups ? groups[0] : "";
};
/**
* Gets the final word in a given string.
*/
const finalWord = (s: string): string => {
const groups = s.match(/\S+\s*$/);
return groups ? groups[0] : "";
};