Skip to content

Commit 8ffcbce

Browse files
First go at reaction duplication (#50)
* First go at emoji duplication * Add some unit tests * Add more unit tests to unrelated things, because why not? * xkcd nit * Move reaction changes into a constants file
1 parent b6d5329 commit 8ffcbce

9 files changed

Lines changed: 207 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## 0.4.0 - 2022-10-11
8+
## 0.5.0 - 2022-10-13
99
### Added
10-
- A message context menu command to fix Twitter links using vxtwitter
10+
- We now join in on the server community by sharing reactions (mostly randomly). The default :star: emoji is ignored, in the interest of future features. :same: and :no_u: emoji are reciprocated 1 in 5 times, and every other emote is reciprocated 1 in 100 times.
11+
12+
## 0.4.0 - 2022-10-13
13+
### Added
14+
- A message context menu command to fix Twitter links using vxtwitter.
1115

1216
## 0.3.0 - 2022-10-02
1317
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Retrieves the profile picture of the given user.
138138

139139
### /xkcd
140140

141-
Retrieves the most recent [XKCD](https://xkcd.com/) comic, or the given one.
141+
Retrieves the most recent [xkcd](https://xkcd.com/) comic, or the given one.
142142

143143
## Context Menu Commands
144144

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "csbot",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"description": "The One beneath the Supreme Overlord's rule. A bot to help manage the BYU CS Discord, successor to Ze Kaiser (https://github.com/arkenstorm/ze-kaiser)",
55
"private": true,
66
"scripts": {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// TODO: Make these configurable
2+
export const DEFAULT_CHANCE = 100;
3+
4+
// The chances, where 1 is always, 100 is once every 100 times, and 0 is never.
5+
// We're using integers here because floating-point math is silly
6+
export const chances: Record<string, number> = {
7+
no_u: 5,
8+
nou: 5,
9+
same: 5,
10+
'⭐': 0, // certain default emoji are represented in the API as emoji characters, not names
11+
};

src/events/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ export function registerEventHandlers(client: Client): void {
5757
// Install event handlers
5858
import { error } from './error';
5959
import { interactionCreate } from './interactionCreate';
60+
import { messageReactionAdd } from './messageReactionAdd';
6061
import { ready } from './ready';
6162

6263
_add(error as EventHandler);
6364
_add(interactionCreate as EventHandler);
65+
_add(messageReactionAdd as EventHandler);
6466
_add(ready as EventHandler);
6567
// Not sure why these type casts are necessary, but they seem sound. We can remove them when TS gets smarter, or we learn what I did wrong
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { MessageReaction, User } from 'discord.js';
2+
import { messageReactionAdd } from './messageReactionAdd';
3+
4+
describe('Reaction duplication', () => {
5+
const mockResendReact = jest.fn<Promise<unknown>, []>();
6+
7+
let mockRandom: jest.SpyInstance<number, []>;
8+
let mockReaction: MessageReaction;
9+
let mockSender: User;
10+
11+
beforeEach(() => {
12+
mockRandom = jest.spyOn(global.Math, 'random').mockReturnValue(1);
13+
14+
mockReaction = {
15+
me: false,
16+
client: {
17+
user: {
18+
id: 'itz-meeee',
19+
},
20+
},
21+
message: {
22+
author: {
23+
id: 'other-user',
24+
},
25+
},
26+
emoji: {
27+
name: 'blue_square',
28+
},
29+
count: 1,
30+
react: mockResendReact,
31+
} as unknown as MessageReaction;
32+
33+
mockSender = {
34+
bot: false,
35+
} as unknown as User;
36+
});
37+
38+
afterEach(() => {
39+
mockRandom.mockRestore();
40+
});
41+
42+
test("sometimes duplicates a user's react", async () => {
43+
await expect(messageReactionAdd.execute(mockReaction, mockSender)).resolves.toBeUndefined();
44+
expect(mockResendReact).toHaveBeenCalledOnce();
45+
});
46+
47+
test("sometimes ignores a user's react", async () => {
48+
mockRandom.mockReturnValue(0.5);
49+
await expect(messageReactionAdd.execute(mockReaction, mockSender)).resolves.toBeUndefined();
50+
expect(mockResendReact).not.toHaveBeenCalled();
51+
});
52+
53+
test('ignores emoji with an empty name', async () => {
54+
mockReaction.emoji.name = '';
55+
await expect(messageReactionAdd.execute(mockReaction, mockSender)).resolves.toBeUndefined();
56+
expect(mockResendReact).not.toHaveBeenCalled();
57+
});
58+
59+
test('ignores emoji with a null name', async () => {
60+
mockReaction.emoji.name = null;
61+
await expect(messageReactionAdd.execute(mockReaction, mockSender)).resolves.toBeUndefined();
62+
expect(mockResendReact).not.toHaveBeenCalled();
63+
});
64+
65+
test('ignores bot reacts', async () => {
66+
mockSender.bot = true;
67+
await expect(messageReactionAdd.execute(mockReaction, mockSender)).resolves.toBeUndefined();
68+
expect(mockResendReact).not.toHaveBeenCalled();
69+
});
70+
71+
test("ignores the bot's own reacts", async () => {
72+
mockReaction.me = true;
73+
await expect(messageReactionAdd.execute(mockReaction, mockSender)).resolves.toBeUndefined();
74+
expect(mockResendReact).not.toHaveBeenCalled();
75+
});
76+
77+
test('ignores :star:', async () => {
78+
mockReaction.emoji.name = '⭐';
79+
await expect(messageReactionAdd.execute(mockReaction, mockSender)).resolves.toBeUndefined();
80+
expect(mockResendReact).not.toHaveBeenCalled();
81+
});
82+
});

src/events/messageReactionAdd.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as logger from '../logger';
2+
import { chances, DEFAULT_CHANCE } from '../constants/reactionDuplication';
3+
import { onEvent } from '../helpers/onEvent';
4+
5+
/**
6+
* The event handler for emoji reactions.
7+
*/
8+
export const messageReactionAdd = onEvent('messageReactionAdd', {
9+
once: false,
10+
async execute(reaction, user) {
11+
const ogEmojiName = reaction.emoji.name ?? '';
12+
const emojiName = ogEmojiName.toLowerCase();
13+
14+
// Ignore nameless emoji. Not sure what those are about
15+
if (!emojiName) return;
16+
17+
if (
18+
user.bot || // ignore bots
19+
reaction.me || // ignore own reacts
20+
reaction.message.author?.id === reaction.client.user.id || // never self-react
21+
(reaction.count ?? 0) > 1 // never join the bandwagon
22+
) {
23+
return;
24+
}
25+
26+
// The chances, where 1 is always, 100 is once every 100 times, and 0 is never
27+
const chance = chances[emojiName] ?? DEFAULT_CHANCE;
28+
29+
if (chance === 0) {
30+
logger.debug(`There is no chance I'd react to :${ogEmojiName}:`);
31+
return;
32+
}
33+
34+
logger.debug(`There is a 1-in-${chance} chance that I'd react to :${ogEmojiName}:`);
35+
const random = Math.round(Math.random() * 100);
36+
if (random % chance === 0) {
37+
logger.debug('I did.');
38+
await reaction.react();
39+
} else {
40+
logger.debug('I did not.');
41+
}
42+
},
43+
});

src/helpers/actions/messages/replyToMessage.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@ import { error as mockLoggerError } from '../../../logger';
77
import { replyWithPrivateMessage, sendMessageInChannel } from './replyToMessage';
88

99
describe('Replies', () => {
10-
const mockUserSend = jest.fn().mockResolvedValue({});
10+
const mockUserSend = jest.fn();
1111

1212
describe('to interactions', () => {
1313
const mockReply = jest.fn();
1414
let interaction: CommandInteraction;
1515

1616
beforeEach(() => {
17+
mockReply.mockResolvedValue({});
18+
mockUserSend.mockResolvedValue({});
1719
interaction = {
1820
user: {
1921
id: 'user-1234',
22+
send: mockUserSend,
23+
},
24+
channel: {
25+
id: 'channel-1234',
2026
},
2127
reply: mockReply,
2228
} as unknown as CommandInteraction;
@@ -51,6 +57,58 @@ describe('Replies', () => {
5157
expect(mockReply).toHaveBeenCalledOnce();
5258
expect(mockReply).toHaveBeenCalledWith({ content, ephemeral: true });
5359
});
60+
61+
describe('in DMs', () => {
62+
test('sends text DM to user with return prompt', async () => {
63+
const content = 'yo';
64+
await expect(replyWithPrivateMessage(interaction, content, true)).resolves.toBeObject();
65+
expect(mockReply).not.toHaveBeenCalled();
66+
expect(mockUserSend).toHaveBeenCalledOnce();
67+
expect(mockUserSend).toHaveBeenCalledWith(`(Reply from <#channel-1234>)\n${content}`);
68+
});
69+
70+
test('sends object DM to user with return prompt', async () => {
71+
const options = { content: 'yo' };
72+
await expect(replyWithPrivateMessage(interaction, options, true)).resolves.toBeObject();
73+
expect(mockReply).not.toHaveBeenCalled();
74+
expect(mockUserSend).toHaveBeenCalledOnce();
75+
expect(mockUserSend).toHaveBeenCalledWith({
76+
content: `(Reply from <#channel-1234>)\n${options.content}`,
77+
});
78+
});
79+
80+
test('informs the user if the text DM failed', async () => {
81+
const error = new Error('This is a test');
82+
mockUserSend.mockRejectedValueOnce(error);
83+
84+
const content = 'yo';
85+
await expect(replyWithPrivateMessage(interaction, content, true)).resolves.toBeTrue();
86+
expect(mockUserSend).toHaveBeenCalledOnce();
87+
expect(mockUserSend).toHaveBeenCalledWith(`(Reply from <#channel-1234>)\n${content}`);
88+
expect(mockReply).toHaveBeenCalledOnce();
89+
expect(mockReply).toHaveBeenCalledWith({
90+
content: expect.stringContaining('tried to DM you') as string,
91+
ephemeral: true,
92+
});
93+
});
94+
95+
test('informs the user if the object DM failed', async () => {
96+
const error = new Error('This is a test');
97+
mockUserSend.mockRejectedValueOnce(error);
98+
99+
const options = { content: 'yo' };
100+
await expect(replyWithPrivateMessage(interaction, options, true)).resolves.toBeTrue();
101+
expect(mockUserSend).toHaveBeenCalledOnce();
102+
expect(mockUserSend).toHaveBeenCalledWith({
103+
content: `(Reply from <#channel-1234>)\n${options.content}`,
104+
});
105+
expect(mockReply).toHaveBeenCalledOnce();
106+
expect(mockReply).toHaveBeenCalledWith({
107+
content: expect.stringContaining('tried to DM you') as string,
108+
ephemeral: true,
109+
});
110+
});
111+
});
54112
});
55113

56114
describe('to messages', () => {

0 commit comments

Comments
 (0)