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の通知関連はGuildVoiceStatesのintentを有効にしないと、使用できない。
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を動かすだけで完成!