Skip to content

Commit

Permalink
feat: ようこそメッセージの追加 (#1028)
Browse files Browse the repository at this point in the history
  • Loading branch information
Sho Sakuma authored Sep 6, 2023
1 parent 8e7f1af commit 0294cbc
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 21 deletions.
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
DISCORD_TOKEN=
MAIN_CHANNEL_ID=
ENTRANCE_CHANNEL_ID=
APPLICATION_ID=
GUILD_ID=
PREFIX=!
FEATURE=MESSAGE_CREATE,MESSAGE_UPDATE,COMMAND,VOICE_ROOM,ROLE,EMOJI
FEATURE=MESSAGE_CREATE,MESSAGE_UPDATE,COMMAND,VOICE_ROOM,ROLE,EMOJI,SLASH_COMMAND,MEMBER
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ OreOreBot2 の [Discussions](https://github.com/approvers/OreOreBot2/discussions

起動時にデフォルト値が存在する変数の値が指定されていない場合は、そのデフォルト値が使われます。

| 変数名 | 説明 | 必須 |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| `DISCORD_TOKEN` | BOT のトークン | True |
| `MAIN_CHANNEL_ID` | VoiceDiff(VC 入退室ログ)を送信する **テキスト** チャンネルの ID | True |
| `APPLICATION_ID` | BOT のアプリケーション ID | True |
| `GUILD_ID` | 限界開発鯖の ID | True |
| `PREFIX` | コマンドの接頭辞、デフォルト値は `"!"` | False |
| `FEATURE` | 有効にする機能のカンマ区切り文字列、デフォルト値は全ての機能。`"MESSAGE_CREATE"`, `"MESSAGE_UPDATE"`, `"COMMAND"`, `"VOICE_ROOM"`, `"ROLE"`, `"EMOJI"`, `"SLASH_COMMAND"` を組み合わせ可能。 | False |
| 変数名 | 説明 | 必須 |
| ----------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------| ----- |
| `DISCORD_TOKEN` | BOT のトークン | True |
| `MAIN_CHANNEL_ID` | VoiceDiff(VC 入退室ログ)を送信する **テキスト** チャンネルの ID | True |
| `APPLICATION_ID` | BOT のアプリケーション ID | True |
| `GUILD_ID` | 限界開発鯖の ID | True |
| `PREFIX` | コマンドの接頭辞、デフォルト値は `"!"` | False |
| `FEATURE` | 有効にする機能のカンマ区切り文字列、デフォルト値は全ての機能。`"MESSAGE_CREATE"`, `"MESSAGE_UPDATE"`, `"COMMAND"`, `"VOICE_ROOM"`, `"ROLE"`, `"EMOJI"`, `"SLASH_COMMAND"`, `"MEMBER"` を組み合わせ可能。 | False |

### インストールと実行

Expand Down
28 changes: 26 additions & 2 deletions src/adaptor/discord/output.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ChannelType, type Client } from 'discord.js';

import type { EmbedMessage } from '../../model/embed-message.js';
import type { StandardOutput } from '../../service/output.js';
import type { EntranceOutput, StandardOutput } from '../../service/output.js';
import { convertEmbed } from '../embed-convert.js';

export class DiscordOutput implements StandardOutput {
export class DiscordStandardOutput implements StandardOutput {
constructor(
private readonly client: Client,
private readonly channelId: string
Expand All @@ -22,3 +22,27 @@ export class DiscordOutput implements StandardOutput {
});
}
}

export class DiscordEntranceOutput implements EntranceOutput {
constructor(
private readonly client: Client,
private readonly channelId: string
) {}

async sendEmbedWithMention(
embed: EmbedMessage,
userId: string
): Promise<void> {
const channel = await this.client.channels.fetch(this.channelId);

if (!channel || channel.type !== ChannelType.GuildText) {
throw new Error(`the channel (${this.channelId}) is not text channel`);
}

const made = convertEmbed(embed);
await channel.send({
content: `<@${userId}>`,
embeds: [made]
});
}
}
19 changes: 19 additions & 0 deletions src/adaptor/proxy/member.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Client, GuildMember } from 'discord.js';

import type { Snowflake } from '../../model/id.js';
import type { MemberResponseRunner } from '../../runner/member.js';
import type { NewMember as AllMemberModel } from '../../service/welcome-message.js';

const map = ({ id, user }: GuildMember): AllMemberModel => ({
userId: id as Snowflake,
isBot: user.bot
});

export const memberProxy = (
client: Client,
runner: MemberResponseRunner<AllMemberModel>
) => {
client.on('guildMemberAdd', (member) =>
runner.triggerEvent('JOIN', map(member))
);
};
31 changes: 31 additions & 0 deletions src/runner/member.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export type MemberEvent = 'JOIN';

export interface MemberEventResponder<M> {
on(event: MemberEvent, member: M): Promise<void>;
}

export const composeMemberEventResponders = <M>(
...responders: readonly MemberEventResponder<M>[]
): MemberEventResponder<M> => ({
async on(event, member) {
await Promise.all(
responders.map((responder) => responder.on(event, member))
);
}
});

export class MemberResponseRunner<M> {
async triggerEvent(event: MemberEvent, member: M): Promise<void> {
try {
await Promise.all(this.responder.map((res) => res.on(event, member)));
} catch (e) {
console.error(e);
}
}

private responder: MemberEventResponder<M>[] = [];

addResponder(responder: MemberEventResponder<M>) {
this.responder.push(responder);
}
}
33 changes: 24 additions & 9 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { DiscordWS } from '../adaptor/discord/ws.js';
import { loadEmojiSeqYaml } from '../adaptor/emoji-seq-loader.js';
import {
ActualClock,
DiscordOutput,
DiscordStandardOutput,
DiscordParticipant,
DiscordVoiceConnectionFactory,
DiscordVoiceRoomController,
Expand All @@ -27,9 +27,11 @@ import {
VoiceRoomProxy,
middlewareForMessage,
middlewareForUpdateMessage,
roleProxy
roleProxy,
DiscordEntranceOutput
} from '../adaptor/index.js';
import { DiscordCommandProxy } from '../adaptor/proxy/command.js';
import { memberProxy } from '../adaptor/proxy/member.js';
import { loadSchedule } from '../adaptor/signal-schedule.js';
import { GenVersionFetcher } from '../adaptor/version/fetch.js';
import type { Snowflake } from '../model/id.js';
Expand All @@ -42,11 +44,13 @@ import {
ScheduleRunner,
VoiceRoomResponseRunner
} from '../runner/index.js';
import { MemberResponseRunner } from '../runner/member.js';
import type { GyokuonAssetKey } from '../service/command/gyokuon.js';
import type { KaereMusicKey } from '../service/command/kaere.js';
import type { AssetKey } from '../service/command/party.js';
import {
allEmojiResponder,
allMemberResponder,
allMessageEventResponder,
allMessageUpdateEventResponder,
allRoleResponder,
Expand All @@ -63,6 +67,7 @@ dotenv.config();
const {
DISCORD_TOKEN: token,
MAIN_CHANNEL_ID: mainChannelId,
ENTRANCE_CHANNEL_ID: entranceChannelId,
GUILD_ID,
PREFIX,
FEATURE,
Expand All @@ -71,21 +76,24 @@ const {
[
'DISCORD_TOKEN',
'MAIN_CHANNEL_ID',
'ENTRANCE_CHANNEL_ID',
'GUILD_ID',
'PREFIX',
'FEATURE',
'APPLICATION_ID'
],
{
PREFIX: '!',
FEATURE: 'MESSAGE_CREATE,MESSAGE_UPDATE,COMMAND,VOICE_ROOM,ROLE,EMOJI'
FEATURE:
'MESSAGE_CREATE,MESSAGE_UPDATE,COMMAND,VOICE_ROOM,ROLE,EMOJI,MEMBER'
}
);

const features = FEATURE.split(',');

const intents = [
GatewayIntentBits.Guilds, // GUILD_CREATE による初期化
GatewayIntentBits.GuildMembers, // メンバーの参加を検知する機能
GatewayIntentBits.GuildMessages, // ほとんどのメッセージに反応する機能
GatewayIntentBits.GuildMessageReactions, // タイマー削除をリアクションでキャンセルする機能
GatewayIntentBits.GuildVoiceStates, // VoiceDiff 機能
Expand All @@ -98,7 +106,8 @@ const typoRepo = new InMemoryTypoRepository();
const reservationRepo = new InMemoryReservationRepository();
const clock = new ActualClock();
const sequencesYaml = loadEmojiSeqYaml(['assets', 'emoji-seq.yaml']);
const output = new DiscordOutput(client, mainChannelId);
const standardOutput = new DiscordStandardOutput(client, mainChannelId);
const entranceOutput = new DiscordEntranceOutput(client, entranceChannelId);

const scheduleRunner = new ScheduleRunner(clock);
const messageCreateRunner = new MessageResponseRunner(
Expand All @@ -113,7 +122,7 @@ if (features.includes('MESSAGE_CREATE')) {
runner: scheduleRunner,
clock,
schedule: loadSchedule(['assets', 'time-signal.yaml']),
output
output: standardOutput
});
}

Expand Down Expand Up @@ -169,7 +178,7 @@ if (features.includes('COMMAND')) {
guildRepo: stats,
roleCreateRepo: roleManager,
queen: new MathRandomGenerator(),
stdout: output,
stdout: standardOutput,
channelRepository
});
}
Expand Down Expand Up @@ -270,7 +279,7 @@ const provider = new VoiceRoomProxy<VoiceChannelParticipant>(
);
const voiceRoomRunner = new VoiceRoomResponseRunner(provider);
if (features.includes('VOICE_ROOM')) {
voiceRoomRunner.addResponder(new VoiceDiff(output));
voiceRoomRunner.addResponder(new VoiceDiff(standardOutput));
}

const roleRunner = new RoleResponseRunner();
Expand All @@ -279,15 +288,21 @@ if (features.includes('ROLE')) {
allRoleResponder({
kawaemonId: KAWAEMON_ID,
roleManager,
output
output: standardOutput
})
);
roleProxy(client, roleRunner);
}

const emojiRunner = new EmojiResponseRunner(new EmojiProxy(client));
if (features.includes('EMOJI')) {
emojiRunner.addResponder(allEmojiResponder(output));
emojiRunner.addResponder(allEmojiResponder(standardOutput));
}

const memberRunner = new MemberResponseRunner();
if (features.includes('MEMBER')) {
memberRunner.addResponder(allMemberResponder(entranceOutput));
memberProxy(client, memberRunner);
}

// PID 1 問題のためのシグナルハンドラ
Expand Down
7 changes: 6 additions & 1 deletion src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
composeMessageUpdateEventResponders,
composeRoleEventResponders
} from '../runner/index.js';
import { composeMemberEventResponders } from '../runner/member.js';
import {
type BoldItalicCop,
BoldItalicCopReporter
Expand All @@ -25,7 +26,8 @@ import {
KawaemonHasAllRoles,
type RoleManager
} from './kawaemon-has-all-roles.js';
import type { StandardOutput } from './output.js';
import type { EntranceOutput, StandardOutput } from './output.js';
import { WelcomeMessage } from './welcome-message.js';

const stfuIgnorePredicate = (content: string): boolean => content === '!stfu';

Expand Down Expand Up @@ -62,3 +64,6 @@ export const allRoleResponder = ({

export const allEmojiResponder = (output: StandardOutput) =>
composeEmojiEventResponders(new EmojiLog(output));

export const allMemberResponder = (output: EntranceOutput) =>
composeMemberEventResponders(new WelcomeMessage(output));
10 changes: 10 additions & 0 deletions src/service/output.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import type { EmbedMessage } from '../model/embed-message.js';

/**
* "#無法地帯" に埋め込みを送信する interface.
*/
export interface StandardOutput {
sendEmbed(embed: EmbedMessage): Promise<void>;
}

/**
* "#玄関" に埋め込みを送信する interface.
*/
export interface EntranceOutput {
sendEmbedWithMention(embed: EmbedMessage, userId: string): Promise<void>;
}
71 changes: 71 additions & 0 deletions src/service/welcome-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import type { Snowflake } from '../model/id.js';
import type { EntranceOutput } from './output.js';
import { WelcomeMessage } from './welcome-message.js';

describe('WelcomeMessage', () => {
afterEach(() => {
vi.restoreAllMocks();
});

const output: EntranceOutput = {
sendEmbedWithMention: () => Promise.resolve()
};
const responder = new WelcomeMessage(output);

it('send welcome message', async () => {
const sendEmbed = vi.spyOn(output, 'sendEmbedWithMention');

await responder.on('JOIN', {
userId: '586824421470109716' as Snowflake,
isBot: false
});

expect(sendEmbed).toHaveBeenCalledWith(
{
title: '***†WELCOME TO UNDERGROUND†***',
description:
'司令官。ようこそ、限界開発鯖へ\nまずはじめに知っていてほしいことを教えるよ\n詳しい説明は [鯖民向けドキュメント](https://docs.approvers.dev/reference/getting-started) を見てね',
fields: [
{
name: 'メンバーデータの追加',
value:
'限界開発鯖では各メンバーの情報をデータベースに保存し、それらを元に様々な機能を提供しているよ。\n限界開発鯖に参加したら、まずは自分のメンバーデータを追加してね\n',
inline: false
},
{
name: '自己紹介',
value:
'参加したら <#687977635132997634> で同じ司令官のみんなに自己紹介しよう',
inline: false
},
{
name: 'メインチャンネル',
value:
'限界開発鯖では以下のチャンネルがメインで使われているよ\n- <#690909527461199922>\n- <#891210643938611260>\n- <#683939861539192865>',
inline: false
},
{
name: 'Botの導入',
value:
'限界開発鯖では自分が開発したBotを導入できるよ。導入に関する詳しい説明は [Bot 製作ガイドライン](https://docs.approvers.dev/guideline/bot-create-guideline) を確認してね',
inline: false
}
]
},
'586824421470109716'
);
});

it('skip welcome message (join a bot)', async () => {
const sendEmbed = vi.spyOn(output, 'sendEmbedWithMention');

await responder.on('JOIN', {
userId: '586824421470109716' as Snowflake,
isBot: true
});

expect(sendEmbed).not.toHaveBeenCalled();
});
});
Loading

0 comments on commit 0294cbc

Please sign in to comment.