Skip to content

Commit a45e07c

Browse files
authored
Merge pull request #273 from Throne3d/improve/mentions
Improve IRC → Discord mentions for non-word characters and partial word matches
2 parents 569893a + cff6eee commit a45e07c

File tree

2 files changed

+120
-18
lines changed

2 files changed

+120
-18
lines changed

lib/bot.js

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,18 @@ class Bot {
367367
return null;
368368
}
369369

370+
// compare two strings case-insensitively
371+
// for discord mention matching
372+
static caseComp(str1, str2) {
373+
return str1.toUpperCase() === str2.toUpperCase();
374+
}
375+
376+
// check if the first string starts with the second case-insensitively
377+
// for discord mention matching
378+
static caseStartsWith(str1, str2) {
379+
return str1.toUpperCase().startsWith(str2.toUpperCase());
380+
}
381+
370382
sendToDiscord(author, channel, text) {
371383
const discordChannel = this.findDiscordChannel(channel);
372384
if (!discordChannel) return;
@@ -400,29 +412,73 @@ class Bot {
400412
}
401413

402414
const { guild } = discordChannel;
403-
const withMentions = withFormat.replace(/@[^\s]+\b/g, (match) => {
404-
const search = match.substring(1);
405-
const nickUser = guild.members.find('nickname', search);
406-
if (nickUser) {
407-
return nickUser;
408-
}
415+
const withMentions = withFormat.replace(/@([^\s#]+)#(\d+)/g, (match, username, discriminator) => {
416+
// @username#1234 => mention
417+
// skips usernames including spaces for ease (they cannot include hashes)
418+
// checks case insensitively as Discord does
419+
const user = guild.members.find(x =>
420+
Bot.caseComp(x.user.username, username.toUpperCase())
421+
&& x.user.discriminator === discriminator);
422+
if (user) return user;
409423

410-
const user = this.discord.users.find('username', search);
411-
if (user) {
412-
return user;
413-
}
424+
return match;
425+
}).replace(/@([^\s]+)/g, (match, reference) => {
426+
// this preliminary stuff is ultimately unnecessary
427+
// but might save time over later more complicated calculations
428+
// @nickname => mention, case insensitively
429+
const nickUser = guild.members.find(x =>
430+
x.nickname !== null && Bot.caseComp(x.nickname, reference));
431+
if (nickUser) return nickUser;
432+
433+
// @username => mention, case insensitively
434+
const user = guild.members.find(x => Bot.caseComp(x.user.username, reference));
435+
if (user) return user;
436+
437+
// @role => mention, case insensitively
438+
const role = guild.roles.find(x => x.mentionable && Bot.caseComp(x.name, reference));
439+
if (role) return role;
440+
441+
// No match found checking the whole word. Check for partial matches now instead.
442+
// @nameextra => [mention]extra, case insensitively, as Discord does
443+
// uses the longest match, and if there are two, whichever is a match by case
444+
let matchLength = 0;
445+
let bestMatch = null;
446+
let caseMatched = false;
447+
448+
// check if a partial match is found in reference and if so update the match values
449+
const checkMatch = function (matchString, matchValue) {
450+
// if the matchString is longer than the current best and is a match
451+
// or if it's the same length but it matches by case unlike the current match
452+
// set the best match to this matchString and matchValue
453+
if ((matchString.length > matchLength && Bot.caseStartsWith(reference, matchString))
454+
|| (matchString.length === matchLength && !caseMatched
455+
&& reference.startsWith(matchString))) {
456+
matchLength = matchString.length;
457+
bestMatch = matchValue;
458+
caseMatched = reference.startsWith(matchString);
459+
}
460+
};
414461

415-
const role = guild.roles.find('name', search);
416-
if (role && role.mentionable) {
417-
return role;
418-
}
462+
// check users by username and nickname
463+
guild.members.forEach((member) => {
464+
checkMatch(member.user.username, member);
465+
if (bestMatch === member || member.nickname === null) return;
466+
checkMatch(member.nickname, member);
467+
});
468+
// check mentionable roles by visible name
469+
guild.roles.forEach((member) => {
470+
if (!member.mentionable) return;
471+
checkMatch(member.name, member);
472+
});
473+
474+
// if a partial match was found, return the match and the unmatched trailing characters
475+
if (bestMatch) return bestMatch.toString() + reference.substring(matchLength);
419476

420477
return match;
421478
}).replace(/:(\w+):/g, (match, ident) => {
479+
// :emoji: => mention, case sensitively
422480
const emoji = guild.emojis.find(x => x.name === ident && x.requiresColons);
423-
if (emoji) {
424-
return `<:${emoji.identifier}>`; // identifier = name + ":" + id
425-
}
481+
if (emoji) return emoji;
426482

427483
return match;
428484
});

test/bot.test.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,24 @@ describe('Bot', function () {
569569

570570
const username = 'ircuser';
571571
const text = 'Hello, @testuser!';
572-
const expected = `**<${username}>** Hello, <@${testUser.id}>!`;
572+
const expected = `**<${username}>** Hello, ${testUser}!`;
573+
574+
this.bot.sendToDiscord(username, '#irc', text);
575+
this.sendStub.should.have.been.calledWith(expected);
576+
});
577+
578+
it('should convert username-discriminator mentions from IRC properly', function () {
579+
const user1 = this.addUser({ username: 'user', id: '123', discriminator: '9876' });
580+
const user2 = this.addUser({
581+
username: 'user',
582+
id: '124',
583+
discriminator: '5555',
584+
nickname: 'secondUser'
585+
});
586+
587+
const username = 'ircuser';
588+
const text = 'hello @user#9876 and @user#5555 and @fakeuser#1234';
589+
const expected = `**<${username}>** hello ${user1} and ${user2} and @fakeuser#1234`;
573590

574591
this.bot.sendToDiscord(username, '#irc', text);
575592
this.sendStub.should.have.been.calledWith(expected);
@@ -637,6 +654,35 @@ describe('Bot', function () {
637654
this.sendStub.should.have.been.calledWith(expected);
638655
});
639656

657+
it('should convert overlapping mentions from IRC properly and case-insensitively', function () {
658+
const user = this.addUser({ username: 'user', id: '111' });
659+
const nickUser = this.addUser({ username: 'user2', id: '112', nickname: 'userTest' });
660+
const nickUserCase = this.addUser({ username: 'user3', id: '113', nickname: 'userTEST' });
661+
const role = this.addRole({ name: 'userTestRole', id: '12345', mentionable: true });
662+
663+
const username = 'ircuser';
664+
const text = 'hello @User, @user, @userTest, @userTEST, @userTestRole and @usertestrole';
665+
const expected = `**<${username}>** hello ${user}, ${user}, ${nickUser}, ${nickUserCase}, ${role} and ${role}`;
666+
667+
this.bot.sendToDiscord(username, '#irc', text);
668+
this.sendStub.should.have.been.calledWith(expected);
669+
});
670+
671+
it('should convert partial matches from IRC properly', function () {
672+
const user = this.addUser({ username: 'user', id: '111' });
673+
const longUser = this.addUser({ username: 'user-punc', id: '112' });
674+
const nickUser = this.addUser({ username: 'user2', id: '113', nickname: 'nick' });
675+
const nickUserCase = this.addUser({ username: 'user3', id: '114', nickname: 'NiCK' });
676+
const role = this.addRole({ name: 'role', id: '12345', mentionable: true });
677+
678+
const username = 'ircuser';
679+
const text = '@user-ific @usermore, @user\'s friend @user-punc, @nicks and @NiCKs @roles';
680+
const expected = `**<${username}>** ${user}-ific ${user}more, ${user}'s friend ${longUser}, ${nickUser}s and ${nickUserCase}s ${role}s`;
681+
682+
this.bot.sendToDiscord(username, '#irc', text);
683+
this.sendStub.should.have.been.calledWith(expected);
684+
});
685+
640686
it('should successfully send messages with default config', function () {
641687
const bot = new Bot(configMsgFormatDefault);
642688
bot.connect();

0 commit comments

Comments
 (0)