FIX: Do not consider code-blocks when parsing mentions (#23280)

We have the max_mentions_per_chat_message site settings; when a user tries 
to mention more users than allowed, no one gets mentioned.

Chat messages may contain code-blocks with strings that look like mentions:

  def foo
    @bar + @baz
  end

The problem is that the parsing code considers these as real mentions and counts 
them when checking the limit. This commit fixes the problem.
This commit is contained in:
Andrei Prigorshnev
2023-09-07 16:13:13 +04:00
committed by GitHub
parent f165c99d77
commit 73781c8a96
8 changed files with 260 additions and 85 deletions

View File

@ -4,7 +4,7 @@ import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
import Bookmark from "discourse/models/bookmark";
import I18n from "I18n";
import { generateCookFunction } from "discourse/lib/text";
import { generateCookFunction, parseMentions } from "discourse/lib/text";
import transformAutolinks from "discourse/plugins/chat/discourse/lib/transform-auto-links";
import { getOwner } from "discourse-common/lib/get-owner";
import discourseLater from "discourse-common/lib/later";
@ -170,33 +170,11 @@ export default class ChatMessage {
}
async cook() {
const site = getOwner(this).lookup("service:site");
if (this.isDestroyed || this.isDestroying) {
return;
}
const markdownOptions = {
featuresOverride:
site.markdown_additional_options?.chat?.limited_pretty_text_features,
markdownItRules:
site.markdown_additional_options?.chat
?.limited_pretty_text_markdown_rules,
hashtagTypesInPriorityOrder:
site.hashtag_configurations?.["chat-composer"],
hashtagIcons: site.hashtag_icons,
};
if (ChatMessage.cookFunction) {
this.cooked = ChatMessage.cookFunction(this.message);
} else {
const cookFunction = await generateCookFunction(markdownOptions);
ChatMessage.cookFunction = (raw) => {
return transformAutolinks(cookFunction(raw));
};
this.cooked = ChatMessage.cookFunction(this.message);
}
await this.#ensureCookFunctionInitialized();
this.cooked = ChatMessage.cookFunction(this.message);
}
get read() {
@ -263,6 +241,10 @@ export default class ChatMessage {
this.version++;
}
async parseMentions() {
return await parseMentions(this.message, this.#markdownOptions);
}
toJSONDraft() {
if (
this.message?.length === 0 &&
@ -361,6 +343,31 @@ export default class ChatMessage {
}
}
async #ensureCookFunctionInitialized() {
if (ChatMessage.cookFunction) {
return;
}
const cookFunction = await generateCookFunction(this.#markdownOptions);
ChatMessage.cookFunction = (raw) => {
return transformAutolinks(cookFunction(raw));
};
}
get #markdownOptions() {
const site = getOwner(this).lookup("service:site");
return {
featuresOverride:
site.markdown_additional_options?.chat?.limited_pretty_text_features,
markdownItRules:
site.markdown_additional_options?.chat
?.limited_pretty_text_markdown_rules,
hashtagTypesInPriorityOrder:
site.hashtag_configurations?.["chat-composer"],
hashtagIcons: site.hashtag_icons,
};
}
#initChatMessageReactionModel(reactions = []) {
return reactions.map((reaction) => ChatMessageReaction.create(reaction));
}

View File

@ -2,7 +2,6 @@ import Service, { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import discourseDebounce from "discourse-common/lib/debounce";
import { bind } from "discourse-common/utils/decorators";
import { mentionRegex } from "pretty-text/mentions";
import { cancel } from "@ember/runloop";
import { tracked } from "@glimmer/tracking";
@ -34,11 +33,7 @@ export default class ChatComposerWarningsTracker extends Service {
@bind
reset() {
this.unreachableGroupMentions = [];
this.unreachableGroupMentions = [];
this.overMembersLimitGroupMentions = [];
this.tooManyMentions = false;
this.channelWideMentionDisallowed = false;
this.#resetMentionStats();
this.mentionsCount = 0;
cancel(this.mentionsTimer);
}
@ -63,61 +58,42 @@ export default class ChatComposerWarningsTracker extends Service {
return;
}
const mentions = this._extractMentions(currentMessage.message);
this.mentionsCount = mentions?.length;
currentMessage.parseMentions().then((mentions) => {
this.mentionsCount = mentions?.length;
if (this.mentionsCount > 0) {
this.tooManyMentions =
this.mentionsCount > this.siteSettings.max_mentions_per_chat_message;
if (this.mentionsCount > 0) {
this.tooManyMentions =
this.mentionsCount > this.siteSettings.max_mentions_per_chat_message;
if (!this.tooManyMentions) {
const newMentions = mentions.filter(
(mention) => !(mention in this._mentionWarningsSeen)
);
if (!this.tooManyMentions) {
const newMentions = mentions.filter(
(mention) => !(mention in this._mentionWarningsSeen)
);
this.channelWideMentionDisallowed =
!currentMessage.channel.allowChannelWideMentions &&
(mentions.includes("here") || mentions.includes("all"));
this.channelWideMentionDisallowed =
!currentMessage.channel.allowChannelWideMentions &&
(mentions.includes("here") || mentions.includes("all"));
if (newMentions?.length > 0) {
this._recordNewWarnings(newMentions, mentions);
} else {
this._rebuildWarnings(mentions);
}
}
} else {
this.tooManyMentions = false;
this.channelWideMentionDisallowed = false;
this.unreachableGroupMentions = [];
this.overMembersLimitGroupMentions = [];
}
}
_extractMentions(message) {
const regex = mentionRegex(this.siteSettings.unicode_usernames);
const mentions = [];
let mentionsLeft = true;
while (mentionsLeft) {
const matches = message.match(regex);
if (matches) {
const mention = matches[1] || matches[2];
mentions.push(mention);
message = message.replaceAll(`${mention}`, "");
if (mentions.length > this.siteSettings.max_mentions_per_chat_message) {
mentionsLeft = false;
if (newMentions?.length > 0) {
this.#recordNewWarnings(newMentions, mentions);
} else {
this.#rebuildWarnings(mentions);
}
}
} else {
mentionsLeft = false;
this.#resetMentionStats();
}
}
return mentions;
});
}
_recordNewWarnings(newMentions, mentions) {
#resetMentionStats() {
this.tooManyMentions = false;
this.channelWideMentionDisallowed = false;
this.unreachableGroupMentions = [];
this.overMembersLimitGroupMentions = [];
}
#recordNewWarnings(newMentions, mentions) {
ajax("/chat/api/mentions/groups.json", {
data: { mentions: newMentions },
})
@ -135,12 +111,12 @@ export default class ChatComposerWarningsTracker extends Service {
this._mentionWarningsSeen[warning] = MENTION_RESULT["invalid"];
});
this._rebuildWarnings(mentions);
this.#rebuildWarnings(mentions);
})
.catch(this._rebuildWarnings(mentions));
.catch(this.#rebuildWarnings(mentions));
}
_rebuildWarnings(mentions) {
#rebuildWarnings(mentions) {
const newWarnings = mentions.reduce(
(memo, mention) => {
if (

View File

@ -0,0 +1,106 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { fillIn, visit } from "@ember/test-helpers";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
acceptance("Chat | Mentions", function (needs) {
const channelId = 1;
const actingUser = {
id: 1,
username: "acting_user",
};
const channel = {
id: channelId,
chatable_id: 1,
chatable_type: "Category",
meta: { message_bus_last_ids: {}, can_delete_self: true },
current_user_membership: { following: true },
allow_channel_wide_mentions: false,
chatable: { id: 1 },
};
needs.settings({ chat_enabled: true });
needs.user({
...actingUser,
has_chat_enabled: true,
chat_channels: {
public_channels: [channel],
direct_message_channels: [],
meta: { message_bus_last_ids: {} },
tracking: {},
},
});
needs.hooks.beforeEach(function () {
pretender.post(`/chat/drafts`, () => response({}));
pretender.get(`/chat/api/channels/${channelId}/messages`, () =>
response({
messages: [],
meta: {
can_load_more_future: false,
},
})
);
pretender.get("/chat/api/mentions/groups.json", () =>
response({
unreachable: [],
over_members_limit: [],
invalid: ["and"],
})
);
});
test("shows warning when mention limit exceeded", async function (assert) {
this.siteSettings.max_mentions_per_chat_message = 2;
await visit(`/chat/c/-/${channelId}`);
await fillIn(".chat-composer__input", `Hey @user1 @user2 @user3`);
assert.dom(".chat-mention-warnings").exists();
});
test("shows warning for @here mentions when channel-wide mentions are disabled", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await fillIn(".chat-composer__input", `Hey @here`);
assert.dom(".chat-mention-warnings").exists();
});
test("shows warning for @all mention when channel-wide mentions are disabled", async function (assert) {
await visit(`/chat/c/-/${channelId}`);
await fillIn(".chat-composer__input", `Hey @all`);
assert.dom(".chat-mention-warnings").exists();
});
test("ignores duplicates when counting mentions", async function (assert) {
this.siteSettings.max_mentions_per_chat_message = 2;
await visit(`/chat/c/-/${channelId}`);
const mention = `@user1`;
await fillIn(
".chat-composer__input",
`Hey ${mention} ${mention} ${mention}`
);
assert.dom(".chat-mention-warnings").doesNotExist();
});
test("doesn't consider code-blocks when counting mentions", async function (assert) {
this.siteSettings.max_mentions_per_chat_message = 2;
await visit(`/chat/c/-/${channelId}`);
// since @bar is inside a code-block it shouldn't be considered a mention
const message = `Hey @user1 @user2
\`\`\`
def foo
@bar = true
end
\`\`\`
`;
await fillIn(".chat-composer__input", message);
assert.dom(".chat-mention-warnings").doesNotExist();
});
});