Discordのbot作ってみる

まずtsプロジェクトを作成

mkdir lambda && cd lambda
npm init -y
npm install discord.js
npm install -D typescript @types/node tsx
npx tsc --init


まずこうしてみた

packge,jsonはこんな感じ

{
  "name": "lambda",
  "version": "1.0.0",
  "description": "Discord Bot",
  "main": "src/index.ts",
  "scripts": {
    "start": "tsx src/index.ts",
    "build": "tsc"
  },
  "keywords": [],
  "author": "First commiter: Rerurate_514, Project team: softtechtohtech.work",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "discord.js": "^14.26.2"
  },
  "devDependencies": {
    "@types/node": "^25.6.0",
    "tsx": "^4.21.0",
    "typescript": "^6.0.2"
  }
}

mainを変えたのと、scriptにstartを追加
ts用の依存関係と、discordjsを追加

これを参考にして、docker立ててみる

こんな感じでDockerfileを作成

# ビルド
FROM node:25-alpine3.22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build 
 
# 本番
FROM node:25-alpine3.22
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm install --omit=dev
CMD ["node", "dist/index.js"]

ここでエラー発生。

 => [builder 5/6] COPY . .                                                                                                                                                                                                                                       0.4s
 => ERROR [builder 6/6] RUN npm run build                                                                                                                                                                                                                        0.8s
------
 > [builder 6/6] RUN npm run build:
0.341 
0.341 > lambda@1.0.0 build
0.341 > tsc
0.341 
0.781 tsconfig.json(9,5): error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout.
0.781   Visit https://aka.ms/ts6 for migration information.
------
Dockerfile:7
--------------------

これはtsconfig.jsonにrootDirが設定されていなかったかららしい

それでdockerイメージを作成

docker image build --tag node-app:0.0.1 .

そしたらイメージが完成

PS C:\Users\rerur\.wk\bot\lambda> docker image build --tag node-app:0.0.1 .
[+] Building 4.9s (14/14) FINISHED                                                                                                                                                                                                               docker:desktop-linux
 => [internal] load build definition from Dockerfile                                                                                                                                                                                                             0.0s
 => => transferring dockerfile: 363B                                                                                                                                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/node:25-alpine3.22                                                                                                                                                                                            0.7s
 => [internal] load .dockerignore                                                                                                                                                                                                                                0.0s
 => => transferring context: 2B                                                                                                                                                                                                                                  0.0s
 => [builder 1/6] FROM docker.io/library/node:25-alpine3.22@sha256:45ee7adabf5073b70c18746ef34be804077b8f5fa09d0188c19697590463b53e                                                                                                                              0.0s
 => => resolve docker.io/library/node:25-alpine3.22@sha256:45ee7adabf5073b70c18746ef34be804077b8f5fa09d0188c19697590463b53e                                                                                                                                      0.0s
 => [internal] load build context                                                                                                                                                                                                                                0.1s
 => => transferring context: 242.29kB                                                                                                                                                                                                                            0.1s
 => CACHED [builder 2/6] WORKDIR /app                                                                                                                                                                                                                            0.0s
 => CACHED [builder 3/6] COPY package*.json ./                                                                                                                                                                                                                   0.0s
 => CACHED [builder 4/6] RUN npm install                                                                                                                                                                                                                         0.0s
 => [builder 5/6] COPY . .                                                                                                                                                                                                                                       0.3s
 => [builder 6/6] RUN npm run build                                                                                                                                                                                                                              0.9s
 => [stage-1 3/5] COPY --from=builder /app/dist ./dist                                                                                                                                                                                                           0.0s
 => [stage-1 4/5] COPY --from=builder /app/package*.json ./                                                                                                                                                                                                      0.1s
 => [stage-1 5/5] RUN npm install --omit=dev                                                                                                                                                                                                                     1.7s
 => exporting to image                                                                                                                                                                                                                                           1.0s
 => => exporting layers                                                                                                                                                                                                                                          0.7s
 => => exporting manifest sha256:77f1281141c68885db82466c39f747a5738857c1f113201e2baf92ee045d6844                                                                                                                                                                0.0s
 => => exporting config sha256:618aa7031168ca301da21270646be0cdc536a24c847aa901c28e48d9debd64fb                                                                                                                                                                  0.0s
 => => exporting attestation manifest sha256:1789971b1777e10e99c774056b614fc221ef40e71ba1496c133bf25a7c7b130a                                                                                                                                                    0.0s
 => => exporting manifest list sha256:b95c7bbb3c9912f9057c933adf3b3182d0189c5c922fce172d261abeb7b4be7d                                                                                                                                                           0.0s
 => => naming to docker.io/library/node-app:0.0.1                                                                                                                                                                                                                0.0s
 => => unpacking to docker.io/library/node-app:0.0.1                                                                                                                                                                                                             0.3s

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/3s88m5fy3r5ro53ex5omh4bgy

AIに適当にテスト用でtsを書いてもらった

// テスト用:トークン不要の動作確認コード
const test = async () => {
    console.log("TypeScriptのビルド環境は正常です。");
 
    // ライブラリの読み込みテスト
    const version = "14.26.2";
    console.log(`Discord.js バージョン: ${version}`);
 
    console.log("ボットの初期化処理をシミュレートします...");
    await new Promise(resolve => setTimeout(resolve, 1000));
 
    console.log("すべて正常に動作しました。環境構築は完了です。");
};
 
test().catch(console.error);

そして動かしてみる。

docker run --rm node-app:0.0.1

そしたら動いた!

PS C:\Users\rerur\.wk\bot\lambda> docker run --rm node-app:0.0.1
TypeScriptのビルド環境は正常です。
Discord.js バージョン: 14.26.2
ボットの初期化処理をシミュレートします...
すべて正常に動作しました。環境構築は完了です。

あとはdocker-composeも作成

services:
  discord-bot:
    build: .
    restart: always
    env_file:
      - .env
    volumes:
      - ./data:/app/data
 

これを書くと、動かす側でnpm installとかしなくてもよくて、
docker compose up -dって打つだけで動くらしいおもろ

botをとりあえず、開発者ポータルから作成

それで一応eslintも入れとく。
eslint.config.mjsを作成
中身はこれを参考にしてみた

依存関係

"devDependencies": {
	"@eslint/js": "^10.0.1",
	"@types/node": "^25.6.0",
	"eslint": "^10.2.0",
	"eslint-config-prettier": "^10.1.8",
	"tsx": "^4.21.0",
	"typescript": "^6.0.2",
	"typescript-eslint": "^8.58.1"
}

早速index.tsに書いてみる
これを参考にして書いてみる

docker上だとdotenvが内蔵されてるらしいからimportしなくていいんだって

import { Client, GatewayIntentBits } from 'discord.js';
 
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
 
client.once('ready', () => {
    console.log(`Ready! Logged in as ${client.user?.tag}`);
});
 
await client.login(process.env.DISCORD_TOKEN);

なんかエラー出た

2026-04-12 08:50:56 discord-bot-1  | 
2026-04-12 08:50:56 discord-bot-1  | Error: Used disallowed intents
2026-04-12 08:50:56 discord-bot-1  |     at WebSocketShard.onClose (/app/node_modules/@discordjs/ws/dist/index.js:1151:18)
2026-04-12 08:50:56 discord-bot-1  |     at connection.onclose (/app/node_modules/@discordjs/ws/dist/index.js:688:17)
2026-04-12 08:50:56 discord-bot-1  |     at callListener (/app/node_modules/ws/lib/event-target.js:290:14)
2026-04-12 08:50:56 discord-bot-1  |     at WebSocket.onClose (/app/node_modules/ws/lib/event-target.js:220:9)
2026-04-12 08:50:56 discord-bot-1  |     at WebSocket.emit (node:events:509:20)
2026-04-12 08:50:56 discord-bot-1  |     at WebSocket.emitClose (/app/node_modules/ws/lib/websocket.js:273:10)
2026-04-12 08:50:56 discord-bot-1  |     at TLSSocket.socketOnClose (/app/node_modules/ws/lib/websocket.js:1346:15)
2026-04-12 08:50:56 discord-bot-1  |     at TLSSocket.emit (node:events:521:24)
2026-04-12 08:50:56 discord-bot-1  |     at node:net:350:12
2026-04-12 08:50:56 discord-bot-1  |     at TCP.done (node:internal/tls/wrap:673:7)
2026-04-12 08:50:56 discord-bot-1  | 
2026-04-12 08:50:56 discord-bot-1  | Node.js v25.9.0
2026-04-12 08:50:59 discord-bot-1  | /app/node_modules/@discordjs/ws/dist/index.js:1151
2026-04-12 08:50:59 discord-bot-1  |           error: new Error("Used disallowed intents")

権限設定の問題らしい
discordの開発者ポータルからBOT


この辺ONにした

pingを作成してみる

import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
 
export const data = new SlashCommandBuilder()
    .setName('ping')
    .setDescription('Pongと返します。Botテスト用!');
 
export async function execute(interaction: ChatInputCommandInteraction) {
    await interaction.reply('Pong!');
}

index.ts

import { Client, Collection, GatewayIntentBits, Events } from 'discord.js';
import * as pingCommand from './commands/botest/ping.js';
 
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
 
const commands = new Collection<string, any>();
commands.set(pingCommand.data.name, pingCommand);
 
client.once('ready', () => {
    console.log(`Ready! Logged in as ${client.user?.tag}`);
});
 
client.on(Events.InteractionCreate, async (interaction) => {
    if (!interaction.isChatInputCommand()) return;
 
    const command = commands.get(interaction.commandName);
    if (!command) return;
 
    try {
        await command.execute(interaction);
    } catch (error) {
        console.error(error);
    }
});
 
await client.login(process.env.DISCORD_TOKEN);

どうやらコマンドを作成したことをdiscordに通知しないといけないらしい

import { REST, Routes } from 'discord.js';
import * as pingCommand from './commands/botest/ping.js';
 
const commands = [pingCommand.data.toJSON()];
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN!);
 
(async () => {
    try {
        console.log('コマンド登録開始...');
        await rest.put(
            Routes.applicationGuildCommands(
                process.env.CLIENT_ID!,
                process.env.GUILD_ID!
            ),
            { body: commands }
        );
        console.log('コマンド登録完了!');
    } catch (error) {
        console.error(error);
    }
})();

これを

npx tsx --env-file=.env src/deploy-commands.ts

で起動!

ダメだた;;

これで再試行

docker compose down
docker compose up --build -d

いけた!

ボタンでroleを付けれるようにもしてみる

import {
    ChatInputCommandInteraction,
    SlashCommandBuilder,
    EmbedBuilder,
    ActionRowBuilder,
    ButtonBuilder,
    ButtonStyle,
} from 'discord.js';
 
export const roleButtons = [
    {
        customId: "2026",
        label: "2026年度入学",
        roleId: "1492043xxxxxxxxxxxxx8636"
    },
]
 
export const data = new SlashCommandBuilder()
    .setName("onboarding-add-role")
    .setDescription("新入生向けに押した人に対して、ロールを付与するものです。");
 
export async function execute(interaction: ChatInputCommandInteraction) {
    const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
        roleButtons.map(btn =>
            new ButtonBuilder()
                .setCustomId(btn.customId)
                .setLabel(btn.label)
                .setStyle(ButtonStyle.Primary)
        )
    );
 
    const embed = new EmbedBuilder()
        .setTitle('🎭 ロール取得')
        .setDescription('欲しいロールのボタンを押してください。')
        .setColor(0x5865f2);
 
    await interaction.reply({ embeds: [embed], components: [row] });
}

indextsにも追加

    else if (interaction.isButton()) {
        const btnConfig = roleButtons.find(b => b.customId === interaction.customId);
        if (!btnConfig) return;
 
        const member = interaction.guild?.members.cache.get(interaction.user.id);
        const role = interaction.guild?.roles.cache.get(btnConfig.roleId);
 
        if (!member || !role) {
            await interaction.reply({ content: 'ロールが見つかりませんでした。', ephemeral: true });
            return;
        }
 
        if (member.roles.cache.has(role.id)) {
            await interaction.reply({ content: `すでに ${role.name} を持っています。`, ephemeral: true });
            return;
        }
 
        await member.roles.add(role);
        await interaction.reply({ content: `✅ ${role.name} を付与しました!`, ephemeral: true });
    }

いい感じになったね

dockercomposeをこう書き換えてデプロイを勝手にやってくれるようにしてみた

services:
  discord-bot:
    build: .
    restart: always
    env_file:
      - .env
    command: >
      sh -c "npx tsx --env-file=.env src/deploy-commands.ts && npx tsx --env-file=.env src/index.ts"
    volumes:
      - .:/app
      - /app/node_modules
      - ./data:/app/data
 

趣味とか特技を設定するボタンの追加もしてみた

import {
    ChatInputCommandInteraction,
    SlashCommandBuilder,
    EmbedBuilder,
    ActionRowBuilder,
    ButtonBuilder,
    ButtonStyle,
} from 'discord.js';
 
export const interestButtons = [
    { customId: 'interest_programming', label: '🖥️ プログラミング', roleId: process.env.ROLE_PROGRAMMING! },
    { customId: 'interest_3dcg',        label: '🎮 3DCG',          roleId: process.env.ROLE_3DCG! },
    { customId: 'interest_illust',      label: '🎨 イラスト',       roleId: process.env.ROLE_ILLUST! },
    { customId: 'interest_vr',          label: '💻 VR',             roleId: process.env.ROLE_VR! },
    { customId: 'interest_gamedev',     label: '🕹️ ゲーム制作',     roleId: process.env.ROLE_GAMEDEV! },
    { customId: 'interest_member',      label: '🚪 部員ロール',      roleId: process.env.ROLE_MEMBER! },
];
 
export const data = new SlashCommandBuilder()
    .setName('onboarding-add-interest-role')
    .setDescription('趣味・特技のロールを取得できます。');
 
export async function execute(interaction: ChatInputCommandInteraction) {
    const row1 = new ActionRowBuilder<ButtonBuilder>().addComponents(
        interestButtons.slice(0, 5).map(btn =>
            new ButtonBuilder()
                .setCustomId(btn.customId)
                .setLabel(btn.label)
                .setStyle(ButtonStyle.Primary)
        )
    );
    const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
        interestButtons.slice(5).map(btn =>
            new ButtonBuilder()
                .setCustomId(btn.customId)
                .setLabel(btn.label)
                .setStyle(btn.customId === 'interest_member' ? ButtonStyle.Success : ButtonStyle.Primary)
        )
    );
 
    const embed = new EmbedBuilder()
        .setTitle('趣味, 特技, これからやりたいこと!')
        .setDescription(
            '興味ある内容のボタンを押してください!!\n' +
            '部員ロールをつけることでメッセージが送れるようになります\n' +
            '※1回押すと付与、2回押すと撤回です。'
        )
        .setColor(0x5865f2);
 
    await interaction.reply({ embeds: [embed], components: [row1, row2] });
}

どうやらActionButtonBuilderはボタンを5個までしか置けないらしく、それで二個作成している。

そしてindexもきれいにloginのみにしてみた

VCの通知関連はGuildVoiceStatesintentを有効にしないと、使用できない。

import { Client, GatewayIntentBits } from "discord.js";
 
export const client = new Client({ 
    intents: [
        GatewayIntentBits.Guilds, 
        GatewayIntentBits.GuildMessages, 
        GatewayIntentBits.MessageContent,
        GatewayIntentBits.GuildVoiceStates
    ] 
});

今回は通知チャンネルに通知を飛ばしたいので、envからチャンネルIDを取得するようにする。

client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => {
    const targetChannel = await newState.client.channels.fetch(
        process.env.VOICE_NOTIFY_ID!
    ).catch(() => null);
 
    if (!targetChannel?.isTextBased()) return;
 
    const member = newState.member;
    const oldChannel = oldState.channel;
    const newChannel = newState.channel;
});

Events.VoiceStateUpdateはVCのステートが変更されたときに発火します。
誰かが入室したまたは、退出した時に動作します。

if (!oldChannel && newChannel) {
	//誰かが入室した時の処理
}
else if (oldChannel && !newChannel) {
	//誰かが退出した時の処理
}

今回はembedを使用しました。EmbedBuilderは埋め込みを作成してくれるクラスです。
これで作成したembedをsend関数に渡すだけ!

const embed = new EmbedBuilder()
	.setColor(0x57f287)
	.setAuthor({
		name: member?.displayName ?? "Unknown",
		iconURL: avatarURL,
	})
	.setTitle("🟢  入室しました")
	.addFields(
		{
			name: "📢  チャンネル",
			value: `**${newChannel.name}**`,
			inline: true,
		},
		{
			name: "👥  現在の人数",
			value: `**${newChannel.members.size}** 人`,
			inline: true,
		},
		{
			name: "🔗  リンク",
			value: `<#${newChannel.id}>`,
			inline: true,
		}
	)
	.setThumbnail(avatarURL ?? null)
	.setFooter({
		text: `ユーザーID: ${member?.id ?? "不明"}`,
	})
	.setTimestamp(now);
	
await (targetChannel as TextChannel).send({ embeds: [embed] }).catch(console.error);

これを実行すると、


のようになります。

あとはラズパイでdockerを動かすだけで完成!