Skip to content

Commit eeaa003

Browse files
feat: ignore word commands (#52)
* chore: ignoreword data * refactor: split ignoreword/unignoreword * refactor: style * refactor: whitespace pattern as util * chore: word model * chore: ignore word utils * feat: ignoreword callbacK * refactor: ignore command data * chore: add fastest-levenshtein * chore: query parser util * feat: unignoreword autocomplete * feat: unignoreword callback * feat: implement word ignoring * feat: implement autocomplete handling * feat: unignoreallwords command * fix: ignoreword warning * fix: word test case insensitivity * revert: 8daf64e
1 parent 39cd94b commit eeaa003

File tree

13 files changed

+295
-26
lines changed

13 files changed

+295
-26
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"dependencies": {
1818
"discord.js": "^14.16.3",
1919
"fast-syllablize": "ducktrshessami/fast-syllablize#v2.1.5",
20+
"fastest-levenshtein": "^1.0.16",
2021
"sequelize": "^6.37.3",
2122
"sqlite3": "^5.1.7",
2223
"undici": "^6.19.8"

src/discord/buttify.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ class ContentItem<Word extends boolean = boolean> {
1818
private _buttified: boolean;
1919

2020
constructor(private readonly content: ButtifiedContent, public readonly chars: string) {
21-
this.word = <typeof this.word>WordPattern.test(chars);
22-
this.allCaps = <typeof this.allCaps>(this.word ? AllCapsPattern.test(chars) : false);
21+
this.word = <typeof this.word>(WordPattern.test(this.chars) && !this.content.ignoreWords.includes(this.chars.toLowerCase()));
22+
this.allCaps = <typeof this.allCaps>(this.word ? AllCapsPattern.test(this.chars) : false);
2323
this.pluralChar = this.word &&
2424
!this.content.pluralWord &&
25-
PluralPattern.test(chars[chars.length - 1]) ?
26-
chars[chars.length - 1] :
25+
PluralPattern.test(this.chars[this.chars.length - 1]) ?
26+
this.chars[this.chars.length - 1] :
2727
null;
28-
this._syllables = <typeof this._syllables>(this.word ? syllablize(chars) : null);
29-
this._current = <typeof this._current>(this.word ? chars : null);
28+
this._syllables = <typeof this._syllables>(this.word ? syllablize(this.chars) : null);
29+
this._current = <typeof this._current>(this.word ? this.chars : null);
3030
this._buttified = false;
3131
this.buttify();
3232
}
@@ -94,7 +94,8 @@ class ButtifiedContent {
9494
constructor(
9595
readonly original: string,
9696
readonly word: string,
97-
readonly rate: number
97+
readonly rate: number,
98+
readonly ignoreWords: Array<string>
9899
) {
99100
this.pluralWord = PluralPattern.test(word[word.length - 1]);
100101
this.items = original
@@ -143,12 +144,14 @@ function attempts(rate: number, syllables: number): number {
143144
export function buttify(
144145
content: string,
145146
word: string = config.default.word,
146-
rate: number = config.default.rate
147+
rate: number = config.default.rate,
148+
ignoreWords: Array<string> = []
147149
): string | null {
148150
const buttifiedContent = new ButtifiedContent(
149151
content,
150152
word,
151-
rate
153+
rate,
154+
ignoreWords
152155
);
153156
if (buttifiedContent.syllables < 2) {
154157
return null;

src/discord/commands/ignoreword.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
ApplicationCommandOptionType,
3+
ApplicationCommandType,
4+
ChatInputCommandInteraction,
5+
InteractionContextType,
6+
PermissionFlagsBits,
7+
RESTPostAPIApplicationCommandsJSONBody
8+
} from "discord.js";
9+
import config from "../../config.js";
10+
import { ignoreWord } from "../ignore.js";
11+
import { resolvePermissionString } from "../util.js";
12+
13+
export const data: RESTPostAPIApplicationCommandsJSONBody = {
14+
type: ApplicationCommandType.ChatInput,
15+
name: "ignoreword",
16+
description: "I will never buttify this word.",
17+
contexts: [InteractionContextType.Guild],
18+
default_member_permissions: resolvePermissionString(PermissionFlagsBits.ManageGuild),
19+
options: [{
20+
type: ApplicationCommandOptionType.String,
21+
name: "word",
22+
description: "The word for me to ignore. Alphabetical characters only, please!",
23+
required: true,
24+
max_length: config.limit.wordLength > 0 ? config.limit.wordLength : undefined
25+
}]
26+
};
27+
28+
export async function callback(interaction: ChatInputCommandInteraction<"cached">): Promise<void> {
29+
await interaction.deferReply();
30+
const word = interaction.options
31+
.getString("word", true)
32+
.toLowerCase();
33+
if (/[^A-Z]/i.test(word)) {
34+
await interaction.editReply("Alphabetical characters only, please!");
35+
}
36+
else {
37+
const ignored = await ignoreWord(word, interaction.guildId);
38+
await interaction.editReply(ignored ? "Okay." : "I'm already ignoring that word.");
39+
}
40+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {
2+
ApplicationCommandType,
3+
ChatInputCommandInteraction,
4+
InteractionContextType,
5+
PermissionFlagsBits,
6+
RESTPostAPIChatInputApplicationCommandsJSONBody
7+
} from "discord.js";
8+
import { smile } from "../emoji.js";
9+
import { unignoreAllWords } from "../ignore.js";
10+
import { resolvePermissionString } from "../util.js";
11+
12+
export const data: RESTPostAPIChatInputApplicationCommandsJSONBody = {
13+
type: ApplicationCommandType.ChatInput,
14+
name: "unignoreallwords",
15+
description: "I'll buttify any word!",
16+
contexts: [InteractionContextType.Guild],
17+
default_member_permissions: resolvePermissionString(PermissionFlagsBits.ManageGuild)
18+
};
19+
20+
export async function callback(interaction: ChatInputCommandInteraction<"cached">): Promise<void> {
21+
await interaction.deferReply();
22+
await unignoreAllWords(interaction.guildId);
23+
await interaction.editReply(`Okay ${smile(interaction)}`);
24+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
ApplicationCommandOptionType,
3+
ApplicationCommandType,
4+
AutocompleteInteraction,
5+
ChatInputCommandInteraction,
6+
InteractionContextType,
7+
PermissionFlagsBits,
8+
RESTPostAPIApplicationCommandsJSONBody
9+
} from "discord.js";
10+
import config from "../../config.js";
11+
import { smile } from "../emoji.js";
12+
import { getIgnoredWords, unignoreWord } from "../ignore.js";
13+
import { parseQuery, resolvePermissionString } from "../util.js";
14+
15+
export const data: RESTPostAPIApplicationCommandsJSONBody = {
16+
type: ApplicationCommandType.ChatInput,
17+
name: "unignoreword",
18+
description: "Undo ignoreword!",
19+
contexts: [InteractionContextType.Guild],
20+
default_member_permissions: resolvePermissionString(PermissionFlagsBits.ManageGuild),
21+
options: [{
22+
type: ApplicationCommandOptionType.String,
23+
name: "word",
24+
description: "The word for me to stop ignoring!",
25+
required: true,
26+
autocomplete: true,
27+
max_length: config.limit.wordLength > 0 ? config.limit.wordLength : undefined
28+
}]
29+
};
30+
31+
export async function autocomplete(interaction: AutocompleteInteraction<"cached">): Promise<void> {
32+
const query = interaction.options.getFocused();
33+
const words = await getIgnoredWords(interaction.guildId);
34+
const choices = parseQuery(query, words, word => ({
35+
name: word,
36+
value: word
37+
}));
38+
await interaction.respond(choices);
39+
}
40+
41+
export async function callback(interaction: ChatInputCommandInteraction<"cached">): Promise<void> {
42+
await interaction.deferReply();
43+
const word = interaction.options
44+
.getString("word", true)
45+
.toLowerCase();
46+
const unignored = await unignoreWord(word, interaction.guildId);
47+
await interaction.editReply(unignored ? `Okay ${smile(interaction)}` : "I'm not ignoring that word.");
48+
}

src/discord/guild.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CreationAttributes, Transaction } from "sequelize";
2-
import { Guild } from "../models/index.js";
2+
import { Guild, IgnoreWord } from "../models/index.js";
33

44
const UpdatableAttributes = [
55
"frequency",
@@ -14,8 +14,8 @@ export async function initializeGuild(guildId: string, transaction?: Transaction
1414
});
1515
}
1616

17-
export async function getGuild(guildId: string): Promise<Guild | null> {
18-
return await Guild.findByPk(guildId);
17+
export async function getGuild(guildId: string, withIgnoredWords: boolean = false): Promise<Guild | null> {
18+
return await Guild.findByPk(guildId, { include: withIgnoredWords ? IgnoreWord : undefined });
1919
}
2020

2121
export async function updateGuild(guildId: string, values: Omit<CreationAttributes<Guild>, "id">): Promise<void> {

src/discord/ignore.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import {
88
MediaChannel,
99
Message
1010
} from "discord.js";
11+
import { Op } from "sequelize";
1112
import {
1213
IgnoreChannel,
1314
IgnoreUser,
15+
IgnoreWord,
1416
sequelize
1517
} from "../models/index.js";
1618
import { initializeGuild } from "./guild.js";
17-
import { Op } from "sequelize";
1819

1920
export const IgnorableChannelTypes: Array<IgnorableChannel["type"]> = [
2021
ChannelType.GuildText,
@@ -112,4 +113,36 @@ export async function ignoreMessage(message: Message): Promise<boolean> {
112113
return channel || !!user;
113114
}
114115

116+
export async function ignoreWord(word: string, guildId: string): Promise<boolean> {
117+
const [_, created] = await IgnoreWord.findOrCreate({
118+
where: {
119+
word,
120+
GuildId: guildId
121+
}
122+
});
123+
return created
124+
}
125+
126+
export async function getIgnoredWords(guildId: string): Promise<Array<string>> {
127+
const ignores = await IgnoreWord.findAll({
128+
where: { GuildId: guildId }
129+
});
130+
return ignores.map(ignore => ignore.word);
131+
}
132+
133+
export async function unignoreWord(word: string, guildId: string): Promise<boolean> {
134+
return !!await IgnoreWord.destroy({
135+
where: {
136+
word,
137+
GuildId: guildId
138+
}
139+
});
140+
}
141+
142+
export async function unignoreAllWords(guildId: string): Promise<void> {
143+
await IgnoreWord.destroy({
144+
where: { GuildId: guildId }
145+
});
146+
}
147+
115148
export type IgnorableChannel = GuildTextBasedChannel | CategoryChannel | ForumChannel | MediaChannel;

src/discord/index.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,21 @@ const client = new Client({
9494
})
9595
.on(Events.InteractionCreate, async interaction => {
9696
try {
97-
if (interaction.isChatInputCommand()) {
97+
if ("commandName" in interaction) {
9898
const command = commands.get(interaction.commandName);
9999
if (
100100
command && (
101101
interaction.inCachedGuild() ||
102102
command.data.contexts?.some(context => context !== InteractionContextType.Guild)
103103
)
104104
) {
105-
console.log(`[discord] ${interaction.user.id} used ${interaction}`);
106-
await command.callback(interaction);
105+
if (interaction.isChatInputCommand()) {
106+
console.log(`[discord] ${interaction.user.id} used ${interaction}`);
107+
await command.callback(interaction);
108+
}
109+
else if (interaction.isAutocomplete() && command.autocomplete) {
110+
await command.autocomplete(interaction);
111+
}
107112
}
108113
}
109114
}
@@ -137,12 +142,13 @@ const client = new Client({
137142
if (!message.inGuild()) {
138143
return;
139144
}
140-
const guildModel = await getGuild(message.guildId);
145+
const guildModel = await getGuild(message.guildId, true);
141146
if (buttifiable(message, guildModel?.frequency)) {
142147
const buttified = buttify(
143148
message.content,
144149
guildModel?.word,
145-
guildModel?.rate
150+
guildModel?.rate,
151+
guildModel?.IgnoreWords?.map(ignore => ignore.word)
146152
);
147153
if (buttified) {
148154
await message.channel.send(buttified);

src/discord/util.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,80 @@
1-
import { PermissionResolvable, PermissionsBitField } from "discord.js";
1+
import { APIApplicationCommandOptionChoice, PermissionResolvable, PermissionsBitField } from "discord.js";
2+
import { distance } from "fastest-levenshtein";
23

34
export function resolvePermissionString(...permissions: PermissionResolvable[]): string {
45
return PermissionsBitField
56
.resolve(permissions)
67
.toString();
78
}
9+
10+
function standardizeString(str: string): string {
11+
return str
12+
.replaceAll(/\s+/g, "_")
13+
.toLowerCase();
14+
}
15+
16+
function parseChoiceData<T extends string | number>(query: string, choiceData: APIApplicationCommandOptionChoice<T>): QueriedChoiceData<T> {
17+
const standardized = standardizeString(choiceData.name);
18+
return {
19+
...choiceData,
20+
startsWith: Number(standardized.startsWith(query)),
21+
distance: distance(query, choiceData.name)
22+
};
23+
}
24+
25+
interface Reducible<K, V> {
26+
reduce<T>(
27+
fn: (
28+
accumulator: T,
29+
value: V,
30+
key: K,
31+
reducible: this
32+
) => T,
33+
initialValue?: T
34+
): T;
35+
}
36+
37+
type QueriedChoiceData<T extends string | number> = APIApplicationCommandOptionChoice<T> & {
38+
startsWith: number;
39+
distance: number;
40+
};
41+
42+
export function parseQuery<K, V, T extends string | number>(
43+
query: string,
44+
reducible: Reducible<K, V>,
45+
fn: (value: V, key: K, reducible: Reducible<K, V>) => APIApplicationCommandOptionChoice<T> | null,
46+
firstWhenEmptyQuery?: T
47+
): APIApplicationCommandOptionChoice<T>[] {
48+
let choices: APIApplicationCommandOptionChoice<T>[];
49+
if (query) {
50+
const standardizedQuery = standardizeString(query);
51+
choices = reducible
52+
.reduce<QueriedChoiceData<T>[]>((accumulator, value, key, reducible) => {
53+
const data = fn(value, key, reducible);
54+
if (data) {
55+
accumulator.push(parseChoiceData(standardizedQuery, data));
56+
}
57+
return accumulator;
58+
}, [])
59+
.sort((a, b) =>
60+
a.startsWith === b.startsWith ?
61+
a.distance - b.distance :
62+
b.startsWith - a.startsWith
63+
);
64+
}
65+
else {
66+
choices = reducible.reduce<APIApplicationCommandOptionChoice<T>[]>((accumulator, value, key, reducible) => {
67+
const data = fn(value, key, reducible);
68+
if (data) {
69+
if (data.value === firstWhenEmptyQuery) {
70+
accumulator.unshift(data);
71+
}
72+
else {
73+
accumulator.push(data);
74+
}
75+
}
76+
return accumulator;
77+
}, []);
78+
}
79+
return choices.slice(0, 25);
80+
}

src/models/Guild.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import {
99
} from "sequelize";
1010
import config from "../config.js";
1111
import IgnoreChannel from "./IgnoreChannel.js";
12+
import IgnoreWord from "./IgnoreWord.js";
1213

1314
export default class Guild extends Model<InferAttributes<Guild>, InferCreationAttributes<Guild>> {
1415
declare id: string;
1516
declare word: CreationOptional<string>;
1617
declare frequency: CreationOptional<number>;
1718
declare rate: CreationOptional<number>;
1819
declare IgnoreChannels?: NonAttribute<Array<IgnoreChannel>>;
20+
declare IgnoreWords?: NonAttribute<Array<IgnoreWord>>;
1921

2022
static initialize(sequelize: Sequelize): void {
2123
this.init({

0 commit comments

Comments
 (0)