こんにちは!
普段はクライアントエンジニアしているやっすんです!
今回は趣味でサーバを書く機会があったので、クライアントエンジニア視点でサーバ実装について解説したいと思います。
(半分くらいはChrome拡張の話になっています)
目次
そもそも話
さて、クライアントエンジニアがなぜサーバの実装するに至ったかというと、趣味でやっているゲームが動機でした。
そのゲームでは、ゲーム内チャットとしてギルドチャットが存在しているのですが、その中の一部メッセージをDiscordサーバに投稿できたら面白いな!という話があがったのです。
たまたま私がエンジニアだったこともあり、技術的にもやったことないことで面白そうだと思いやってみることにしました。
仕様と構成
まず仕様についてですが、大前提として利用規約を遵守したツールを作る必要があります。このゲームではユーザ作成のツール等は比較的歓迎されていているのですが、利用規約を守らない場合は当然ですがBAN対象となります。
そこで前提としては、
- スクレイピングは使用不可
- マクロも使用不可
- 通常ログインを行いプレイ中にのみ動作すること
この条件を元に、ローカルサーバーとChome拡張を利用して特定のチャットをDiscordに投稿するツールの作成に取り掛かることにしました。
簡易的な構成図は下記のようになりました。
まず、チャットの投稿はhtml要素として取得できるので、Chome拡張を使ってエレメントを取得するようにします。
実行間隔についてはチャットの速さはそれほど速くないので、3分に1回実行するようにしています。
htmlからチャットのstringを取得できたら、ローカルで動作しているサーバにPOSTします。
ローカルサーバでは、POSTが叩かれたら動作し、投稿条件をチェックしてチェックが通ったものをDiscord APIを利用してDiscordサーバのチャンネルに投稿するという流れです。
コード解説
Chrome拡張
chrome拡張部分は構成図にもある通り2つに分かれています。
chrome拡張ではcontent_scriptとbackgroundに分かれるような構成を推奨しており、content_scriptはwebページに対して直接行う操作やUIの表示などを担当し、backgroundはその名の通り裏で行う処理や通信関係の処理などを担当します。
ちなみにChrome拡張の言語はJavaScriptとなっています。
background
1つ目は定期実行を行うための処理で、background側です。
Chrome拡張では定期実行にアラームという機能を使用します。
アラームは初期開始時間と、実行間隔をそれぞれ指定でき、そのアラームをリッスンすることで定期実行が可能となり、実行間隔は最小30秒で10秒刻みで指定可能です。
今回は頻繁に実行せずある程度の間隔で実行を考えていたので、3分と設定しました。
1 2 3 |
chrome.alarms.create("excute-alarm", { "periodInMinutes": 3 }); |
アラームをリッスンする側については、addListenerを設定することでアラームの周期間隔ごとに処理が実行可能になっています。
こちらのコードでは、アラームで実行される際にinvoke alarm
と表示を行い、その後で成功したらsuccess
、失敗すればmessage error
と表示するようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
chrome.alarms.onAlarm.addListener(function (alarm) { console.log("invoke alarm"); if (currentTabId !== -1){ chrome.tabs.sendMessage(currentTabId, { name: 'invoke_alarm' }) .then((response) => { console.log("success"); }) .catch((error) => { console.log("message error"); }) } }); |
content_script
さて、続いてはcontent_scriptの説明になるのですが、その前にcontent_scriptとbackgrondのデータの受け渡しについて説明です。
content_scriptとbackgrond間のデータのやり取りについては、メッセージを介して行います。
メッセージの形式はjsonとなっており、好きなプロパティー名を設定して渡すことができます。
今回の例では、content_scriptにnameというパラメーター名でinvoke_alarm
という文字列を渡しています。
invoke_alarm
はイベントトリガーとして利用しています。
1 2 3 |
chrome.tabs.sendMessage(currentTabId, { name: 'invoke_alarm' }) |
続いてcontent_script側の実装です。
こちらの主な役割は特定の文言が入っているチャットの文章を取得です。
そのためにhtml要素へのアクセスと、サーバへ送信する際のデータの編集を行っています。
先ほどbackgroundから送信されたメッセージの受け取りは、onMessage.addListener
を利用します。
1 |
chrome.runtime.onMessage.addListener(async function (message, sender, sendResponse) { } |
実際の処理はこうなっており、4行目の関数でいい感じにデータを編集しています。
取得したデータはbackground側でサーバとの通信を行っているので、再びメッセージを利用してbackgroundにデータを投げます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
chrome.runtime.onMessage.addListener(async function (message, sender, sendResponse) { if (message.name === 'invoke_alarm') { console.log("alarm invoke !!!"); const prDic = getDesiredChatMessage(); // bgに編集したデータをメッセージング // 第一引数はjson chrome.runtime.sendMessage({ action: 'sendData', content: prDic }, (response) => { console.log(response.status); }); } }); |
データの編集処理については割愛しますが、正規表現を利用して特定の文言の検出と、日付情報の加工を行なっています。
送信しているprDic
は連想配列となっていて、 日付をkeyとした配列になっています。
- key: 日付
- value: 投稿者名「メッセージ」 (日付)
のように格納されてbackgroundに送信されています。
background (サーバ送信)
再びbackgroundに戻ってきました。
ここではローカルサーバへのデータ送信部分についてです。
先ほどと同様にcontent_scriptで送信されたメッセージを受信するためにonMessage.addListener
を利用します。
そしてfetch
を利用して、ローカルサーバへPOSTを行うことでデータの送信を実現しています。
送信先はlocalhost:8110/data
、bodyには受け取ったprDicをjson変換して受け渡しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === 'sendData') { fetch('http://localhost:8110/data', { mode: 'cors', method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(message.content) // prDicをJSON文字列に変換して送信 }) .then(response => response.text()) .then(text => sendResponse({ status: 'success', response: text })) .catch(error => sendResponse({ status: 'error', response: error.toString() })); } }); |
ローカルサーバ
いよいよローカルサーバの実装です。
まずどの言語を選択するかですが、今回は過去に少しだけ触ったことがあり、尚且つサーバが建てやすいと噂のnode.jsとexpressを使用しました!
expressを利用すると、簡単な記述だけでサーバを立てることができ、
1 2 3 4 5 6 7 |
const port = 8110 const app = express() // server起動、待ち受け開始 app.listen(port, () => { console.log(`Example app listening on port ${port}!`) }); |
最小構成としては上記だけで実現可能です。
今回はずっと処理されるのではなく、Chrome拡張から任意のタイミングで処理を実行したかったため、POSTメソッドを実装しその中でDiscordにメッセージを投稿する処理を記述しています。
POSTメソッドの作成は下記のようになっていて、第一引数にAPI名を自由に設定でき、第二引数はリクエストとレスポンスが固定で入っています。
1 |
app.post('/data', async (req, res) => { } |
POSTメソッドを叩くのは、前述した通り、http://localhost:8110/data
のようにすることでサーバ側で受け取ることができます。
また、Chrome拡張ではオリジンが動作させるサイトのドメインという判定になります。サーバはlocalhostのため異なるドメイン間の通信となってしまい、corsの設定を行う必要があります。
expressにはcorsのの設定も用意されており、1行で設定を行うことができます。
1 |
app.use(cors({ origin: true, credentials: true })); |
今回はサーバはlocalhostのため全て許可で問題ないので、 originもcredentialsも両方trueとしました。
Discord API
コード解説最後は今回の実装で一番大変だったDiscord APIです。
主に苦戦した内容としては、英語も含め記事がほとんど存在しないこと、直前に破壊的変更が入ったようでネットにある情報があまり役に立たないという状況でした。
CharGPTなども利用しながら、試行錯誤して実装にこぎつけることができました。
DiscordのAPIを実行するにはいくつかの前準備と、段階を踏む必要があります。
- 前準備
- Botの設定および、Botトークンの生成
- クライアントインスタンス生成前に必要な権限を洗い出し、
intens
に記載する
- クライアントインスタンス生成とログイン
- アクセスするサーバやチャンネルの取得
上記を行って初めてメッセージを投稿できます。
最初に前準備としてintens
に記述するものに関して、botの権限の設定はbotを作成するdiscord developer portalから設定を行います。
discord developer portalから設定する権限についてはbotをDiscordサーバに導入する際の承認に関係しますが、それと同時にコード側でも使用する権限について事前に宣言を行わないと適切にAPIを利用することができません。
今回必要な権限は
- チャンネルの閲覧
- チャンネルに投稿されたメッセージを閲覧
- メッセージを投稿
の3つとなっています。
「チャンネルに投稿されたメッセージを閲覧」については、重複投稿を避けるために行なっています。
intens
の記述はDiscord APIのクライアントインスタンス生成時に宣言を行い、今回の場合は下記のようになります。
1 2 3 4 5 6 7 |
const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent ] }); |
Guildsがチャンネル、GuildMessageがチャンネルに投稿されたメッセージの閲覧、MessageContentがメッセージの投稿に対応しています。
intens
の設定と同時にクライアントインスタンスの生成を行ったので、次はbotのDiscodeサーバへのログイン処理になります。
1 2 3 4 5 6 |
client.once(Events.ClientReady, c => { console.log(`準備OKです! ${c.user.tag}がログインします。`); isDiscodeRedy = true; }); client.login(process.env.BOT_TOKEN); |
上のコードは、onceを利用して1度だけ実行されるようにして、クライアントインスタンスの初期化などを待ってからログ出力とログイン処理を行っています。
実際の使用箇所では関数に分けてログイン処理を行っているので、isDiscodeRedy
のフラグでメッセージ投稿可能かどうかを判断しています。
最後にメッセージ投稿です。
メッセージ投稿はチャンネルを取得してから、send
関数を利用して送信できます。
チャンネル取得にはそのチャンネルのIDが必要になっており、こちらはDisocordのチャンネルを右クリックするなどで取得できるIDを指定しています。
1 2 3 4 5 6 7 8 |
function sendMessage(message) { // チャンネル取得 const channel = client.channels.cache.get(process.env.CHANNEL_ID); // メッセージ送信 channel.send(message); console.log("【投稿】" + message); } |
取得を行っている関数名にcacheとありますが、初回に特殊な処理を行ってキャッシュさせるなどの処理は必要なく、ログインすれば自動的にキャッシュされるようです。
メッセージの取得についても解説します。
channel.messages.fetch
を行いメッセージを取得、取得したメッセージは配列として取得できるのでforEachなどでメッセージ毎の処理を行っていくという流れです。fetch
の引数に指定している limit: 100
は新しい順に最大100件のメッセージを取得するというもので、デフォルトだと50件までしか取れないため指定を行っています。
1 2 3 4 5 6 7 8 |
const channel = client.channels.cache.get(process.env.CHANNEL_ID); await channel.messages.fetch({ limit: 100 }) .then(messages => { messages.forEach(message => { // 取得したメッセージ毎の処理 }); }) |
サーバを書いてみて
コード解説まで行ってきましたが、サーバの実装を行なってみてまず思ったのは、思っていたよりも簡単にサーバの実装と、APIを生やすことが簡単なんだなと思いました。実装してみる前はもっとサーバの環境構築や、ポート開放など、手間がかかるものだと思っていましたが、node.jsでサクッとサーバが建てることができて感動しました!
また、Chrome拡張についても初めて触ってみたのですが、思ったよりもいろんなことができるとわかった反面、デメリットについても見えてきました。
Chrome拡張ではローカルのファイルアクセスや、データ通信など特にユーザ承認や警告なども出ずに動作できたので、やろうと思えばマルウェアとしての動作もできる可能性があり、野良のChrome拡張を導入する時は十分気をつけようと思いました。(Chrome拡張のストアには多くのマルウェアが存在しているというニュースもありましたし)
今回は勉強も兼ねて今まで触ったことのないもので、あえて実装してみるという試みだったのですが、ChatGPTにすごく助けられました。
特にDiscordAPIについては、直近で破壊的変更が入ってしまったのかネットの記事があまり役に立たなかったのですが、ChatGPTを使いつつ推論することで実装できました。ChatGPT最強!
というわけでクライアントエンジニアのやっすんがお送りしました!