White Box技術部

WEB開発のあれこれ(と何か)

Vercelを使ってNext.jsアプリでSlackのメッセージをDiscordに配信する

これは、

「Slackの無料アカウントだと90日前のログが見えなくなってしまうから、とりあえずDiscordに転送しておいて見れるようにだけはしておきたいよね」っていうコミュニティ運営問題先送りマンが、 「まあメッセージ転送アプリならboltとdiscord.js使えばサクッといけるっしょ!Vercelの無料枠で大丈夫そうだし」 という感じで作業したら8時間もかかってしまったよ。

という話です。

改めて文章にするとホントSlackにお金払うかDiscord使おうよって話ですね・・・

1. コード作成

実装方針

軽く調査した結果、boltでメッセージをサブスクライブして、discord.jsでWebhookを使ったメッセージ送信をするとサクッとできそうだったので、boltでアプリを作ってどこかでホスティングするのが一番簡単そうでした。

そこでお世話になりがちなVercelでなんか良い感じにできないかなと調べたら、公式にUsing Express.js with Vercelといういけそうなページがあり読んでみたのですが、「サーバレスファンクションとしてなら使えるよ」みたいな内容で、boltアプリを起動させておくのは(昔ながらのserver.jsを使えばいける気もしますが)実質無理っぽかったので、せっかくならとNext.jsのAPIで動作させるコードを目指してみました。

処理コード

もろもろのハマりを解消したあとにコードを見ると単純で、以下のことをしているだけです。

  1. Slackからのリクエストを解析し、メッセージ送信かをチェック
  2. そうであればslack/boltでSlackのユーザ情報を取得し、メッセージを準備
  3. discord.jsでメッセージ送信

最初のurl_verification分岐は、後述のRequest URLのchallengeクリアに必要です。

  • /app/api/events/route.ts
import { App } from '@slack/bolt';
import { WebhookClient } from 'discord.js';
import { NextResponse } from 'next/server';

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});
const webhookClient = new WebhookClient({ url: process.env.DISCORD_WEBHOOK_URL || '' });

export async function POST(request: Request, response: Response) {
  const body = await request.json();
  const type = body.type;

  if (type === 'url_verification') {
    console.log('req body challenge is:', body.challenge);
    return NextResponse.json({ challenge: body.challenge });
  } else if (type === 'event_callback' && body.event.type === 'message') {
    let username: string | undefined = 'slackbot';
    let avatarURL: string | undefined = undefined;
    if (body.event.user) {
      const result = await app.client.users.info({
        user: body.event.user,
      });
      username = result.user?.real_name;
      avatarURL = result.user?.profile?.image_512;
    }
    const fileInfo = body.files ? '(ファイル添付あり)' : '';

    webhookClient.send({
      content: (body.event.text || '') + fileInfo,
      username: username,
      avatarURL: avatarURL,
    });
  }

  return NextResponse.json({ status: 'ok' });
}

ちゃんとやるのであれば、SlackとDiscordのクライアントはglobalになければ作る系の処理を入れ、エラーハンドリングを入れると良いと思います。

next.config.jsの変更

discord.jsでのメッセージ送信にzlib-syncが必要だったので、ライブラリを追加したのですが、それだけだと以下のようなエラーになります。

Import trace for requested module:
./node_modules/zlib-sync/build/Release/zlib_sync.node
./node_modules/zlib-sync/index.js
./node_modules/discord.js/src/client/websocket/WebSocketManager.js
./node_modules/discord.js/src/index.js
./src/app/api/events/route.ts
 ⨯ ./node_modules/zlib-sync/build/Release/zlib_sync.node
Module parse failed: Unexpected character '�' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
(Source code omitted for this binary file)

これはライブラリにbrowserify-zlibを追加し、webpackの設定をnext.config.jsに追加することで解消しました。

/** @type {import('next').NextConfig} */
const nextConfig = {
  poweredByHeader: false,
  reactStrictMode: true,
  swcMinify: true,
  webpack: (config) => {
    config.resolve = {
      ...config.resolve,
      alias: {
        ...config.resolve.alias,
        "zlib-sync": require.resolve('browserify-zlib'),
      }
    };
    return config;
  },
};

module.exports = nextConfig;

2. Slack側の設定

Slackでアプリを作成します。

作成したらBotを作成し、以下の権限を付与してワークスペースにインストールします。

インストール後は以下の値をメモし、Nextアプリケーションの環境変数に設定します

  • App CredentialsのSigning Secret
    • SLACK_SIGNING_SECRETに設定
  • OAuth & PermissionsのBot User OAuth Token
    • SLACK_BOT_TOKENに設定

Request URLを設定

SlackからのイベントをNextアプリケーションで受け取るために、Event SubscriptionsでEventsを有効にし、Request URLを設定する必要があります。 Request URLはhttpsの必要があるので、ローカルでテストする場合はngrokを利用してnextアプリに転送するのが楽でした。

ngrokの設定手順は省略しますが、設定後にNextアプリケーションを起動し、ngrokを起動して、インターネットからローカルのNextアプリケーションにアクセスできるようにします。

ターミナル1(next)
$ yarn dev

ターミナル2(ngrok)
$ ngrok http http://localhost:3000

ngrokが起動するとhttpsのngrokドメインがコンソールに表示されるので、それに/api/eventsを追加したURLをRequest URLに設定し、challengeをクリアさせます。

※このURLはVercelデプロイ後に再度変更します

Slack動作確認

ここで一度Slackアプリ側に戻り、メッセージを転送したいチャンネルに先ほどインストールしたアプリを招待します。 その後メッセージを送信し、Nextアプリケーションが反応すれば成功です。 ただし、Discordの設定をまだ行っていないため、Discord送信には失敗します。

3. Discord側の設定

Discordアプリを開き、メッセージを転送したいサーバのServer Settingsを開きます。

Webhookの取得

IntegrationsのWebhooksから、Slackのメッセージを転送したいDiscordチャンネルのWebhookを作成します。

  • DISCORD_WEBHOOK_URLに設定する

Botの作成

BotはDiscordアプリからは作成できないので、開発サイトに移動します。

New Applicationから新しくアプリケーションを作成し、SETTINGSのBotからボットを作成します。 IconとUsernameを設定するだけでOKです。

ボットの権限

次にSETTINGSのOAuth2からOAuth2 URL Generatorを選択し、 SCOPESのbotにチェックを付け、BOT PERMISSIONSに以下を設定します。

  • 最低限必要なもの
    • Send Messages
  • 付けたけどどうなんだろってもの
    • Send Messages in Threads
    • Manage Message
    • Embed Links
    • Attach Files

設定後はGENERATED URLのURLにブラウザでアクセスし、ボットを利用するサーバに追加してください。

ここまでやると、SlackのメッセージがDiscordにも送信されるようになります。

4. Vercelの設定

最後にVercelにデプロイしてしまいましょう。 コードをGitHubにpushしたら、Vercelのアカウントから当該リポジトリを選択し、起動させます。 ビルドオプション等はデフォルトで構いませんが、環境変数にこれまで出てきた以下の3つを指定します。

  • SLACK_SIGNING_SECRET
  • SLACK_BOT_TOKEN
  • DISCORD_WEBHOOK_URL

デプロイ後はVercelから与えられたドメインに、SlackのEvent SubscriptionのRequest URLを変更して完了です。

まとめ

コードの全量はこちらです。

とまあ頑張ればNext.jsでもできましたが、typeのチェック処理などはコード補完が効きませんし、Socket modeも使えないので、素直にRender等のホスティングサービスを使ってboltのappをstartさせるのが良いかと思います。

落ち穂拾い

いろいろ試しているときに@discordjs/restを使ったメッセージ送信もやってみたので、サンプルを残しておきます。

まず、Discordのユーザ設定から開発者モードを有効にし、送信対象のDiscordチャンネルIDを取得します。
また、開発サイトで作成したBotのTokenを取得し、アプリケーションのDISCORD_TOKENに設定します。

  • ライブラリを追加
yarn add -E @discordjs/rest discord-api-types
  • コード変更
import { App } from '@slack/bolt';
import { NextResponse } from 'next/server';
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v10';

const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN || '');

export async function POST(request: Request, response: Response) {
    // ...省略...
    const fileInfo = body.files ? '(ファイル添付あり)' : ''; // POST内はここまでdiscord.jsと同じ

    await rest.post(Routes.channelMessages('送信先チャンネルID'), {
      body: {
        content: (body.event.text || '') + fileInfo,
      },
    });
  }
  return NextResponse.json({ status: 'ok' });

こっちだとzlib-syncなしでメッセージ送信をすることができますが、Discordにメッセージを送信するBotの情報を変更できないため、今回は利用しませんでした。

そしてあのエラーと戦いました・・・

GitHub Actionとactの話(secretsとoutputs)

前回actの記事を書いたのですが、また知見を得たので残します。

seri.hatenablog.com

今回のactのバージョンは以下になります。

  • act version 0.2.57(前回は0.2.55)

書いていること

  • inputs.XXXXでもinputsが取れる
  • Github Token利用の手順とシークレットの使い方
  • job間でoutputの値を連携する方法
  • workflow_callでoutputsの値が渡らない

前回記事への訂正

再利用ワークフローのinput値参照方法について

また通常はinputs.XXXXでとれるinputsの値が、actを利用した場合はgithub.event.inputs.XXXXと書かないと取れませんでした

前回はこのように書いたのですが、今のバージョンではinputs.XXXXで値が取得できることが確認できました。 もちろんgithub.event.inputs.XXXXでも同じ値が取れました。

GITHUB_TOKENとシークレットの使い方

こちらも前回の最後に

actでGITHUB_TOKENを利用したワークフローを試す場合は、自身のGitHub個人トークンを発行し、.secretsファイルから読み込ませるとできるはずです。

というように書きましたが、実際には以下の手順になりました。

  1. 前提としてGitHub CLIは入れておく
  2. actの拡張機能を追加する(https://github.com/nektos/gh-act
    1. Issueでの説明:https://github.com/nektos/act/issues/1343#issuecomment-1646578864
  3. コマンドに-sオプションを追加する
    1. act -s GITHUB_TOKEN="$(gh auth token)" -W .github/workflows/sample.yml

シークレット値をまとめて読み込む

シークレット値は、env形式で値を記述したxxxx.secretsファイルを作成し、--secret-fileオプションで渡すと利用可能になります

例:act --secret-file act.secrets -s GITHUB_TOKEN="$(gh auth token)" -W .github/workflows/sample.yml

job間でoutputの値を連携する方法

actではなくGitHub Actionの話なのですが、ジョブ間でOutputの値を利用するためにはjobs.<job_id>.outputsを定義する必要があります。

それを踏まえて以下のように書いたとします。

  • sample.yml
name: sample
on:
  workflow_call:
jobs:
  sample-job:
    runs-on: ubuntu-latest
    outputs:
      title: ${{ steps.step1.outputs.title }}
      description: ${{ steps.step2.outputs.description }}
    steps:
      - id: step1
        run: echo "title=hello" >> "$GITHUB_OUTPUT"
      - id: step2
        run: echo "description=world" >> "$GITHUB_OUTPUT"
  test-echo:
    runs-on: ubuntu-latest
    needs: sample-job
    steps:
      - name: hoge1
        run: echo "${{ needs.sample-job.outputs.title }}"
      - name: hoge2
        run: echo "${{ needs.sample-job.outputs.description }}"
  test-call:
    needs: sample-job
    uses: ./.github/workflows/reuse.yml
    with:
      title: ${{ needs.sample-job.outputs.title }}
      description: ${{ needs.sample-job.outputs.description }}

個人的にはこれでなんの問題もなく動くと思ったのですが、実際にact workflow_call -W .github/workflows/sample.ymlを叩いて動かしてみると、test-echoではoutputsの値が取れますが、test-callではoutputsの値が取れませんでした。

というかtest-call.with.<input>title: "hogehoge"とベタ書きしても、reuseのワークフローには値が渡っていませんでした。

一応解決した方法

上記のsample.ymlにおいて、workflow_dispatchを追加し、act workflow_dispatch -W .github/workflows/sample.ymlとして実行すると、reuse.ymlにoutputsの値を渡すことができました。

name: sample
on:
  workflow_call:
  workflow_dispatch: # ←これを追加

・・・もうそれはどうなんだって感じはしますが、やりたかったワークフローの動作テストは一応できるので、 もし再利用ワークフローから再利用ワークフローを呼ぶテストがうまくいかないときがあれば、思い出してみてください。

GitHub Actionsのテストをactを使って行う

GitHub Actionsのトリガーと分岐条件のテストにactを使ったので、そのときの知見です。

  • act version 0.2.55

M1 Macなので以下のaliasを設定して作業しました。

alias act='act --container-architecture linux/amd64'

書いていること

  • トリガーしたいブランチの指定方法
  • github.event_nameでworkflow_dispatchが返ってくる方法
  • false == ''がtrueの話(なんちゃって三項演算だと困るという話)

指定のブランチへのpushを検証する

  • act pushだけ行うとデフォルトブランチへのpushトリガーが発火するので、対象ブランチを変えたい場合は--defaultbranchオプションでブランチを指定します
  • ワークフローのファイルを指定しなかった場合、.github/workflowsディレクトリ配下の全ファイルがチェック対象になるので、 特定のファイルを指定したい場合は-Wオプションでファイルを指定します
stagingブランチへのpush確認
$ act push --defaultbranch staging
$ act push --defaultbranch staging -W .github/workflows/cd-staging.yml

手動実行ワークフローの検証をする

  • ワークフローに渡すパラメータは。JSONファイルに定義して-eオプションで指定します
  • 実行させたいワークフローは、実行したいジョブ名でも限定できます(--jobオプション)
手動実行の確認
$ act workflow_dispatch --job action-test -e act_cd.json -W .github/workflows/cd-staging.yml
$ act workflow_dispatch --job build-test -e act_build.json -W .github/workflows/build.yml

 ハマったところ

  • act workflow_dispatch 〜というようにworkflow_dispatchを入れないと、github.event_nameがworkflow_dispatchになりませんでした
  • act 〜だとinputのJSONファイルにactionをworkflow_dispatchで定義していても、github.event_nameはpushになっていました
    • そもそもJSONファイルにactionは記載しなくても動作しました
  • また通常はinputs.XXXXでとれるinputsの値が、actを利用した場合はgithub.event.inputs.XXXXと書かないと取れませんでした

ここまでのサンプルファイル

.github/workflows/cd-staging.yml

name: TEST Deploy
on:
  push:
    branches:
      - staging
  workflow_dispatch:
    inputs:
      deploy-app:
        description: "deploy app"
        type: boolean
        default: true
      migration:
        description: "migration"
        type: boolean
        default: false
jobs:
  build-images:
    uses: ./.github/workflows/build.yml
    with:
      build-app: true
      environment: "stg"
    if: ${{ github.event_name == 'push' }}
  action-test:
    runs-on: ubuntu-latest
    needs: build-images
    if: always()
    steps:
      - name: Check
        run: echo "${{ github.event.inputs }},${{ github.event_name }} deploy!"
      - name: Check deploy app Image
        if: ${{ github.event_name == 'push' || github.event.inputs.deploy-app }}
        run: echo "deploy-app true"
      - name: Check deploy migration Image
        if: ${{ github.event_name == 'push' || github.event.inputs.migration }}
        run: echo "deploy-migration true"

act_cd.json

{
  "action": "workflow_dispatch",
  "inputs": {
    "deploy-app": true,
    "migration": false
  }
}

.github/workflows/build.yml

name: TEST Build
on:
  workflow_call:
    inputs:
      build-app:
        required: true
        type: boolean
      environment:
        required: true
        type: string
  workflow_dispatch:
    inputs:
      build-app:
        description: "appのimage作成"
        type: boolean
        default: true
      environment:
        description: "environment"
        type: choice
        required: true
        options:
          - prd
          - stg
          - dev
jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - name: Check
        run: echo "${{ inputs }},${{ github.event_name }} build!"
      - name: Check build app
        if: ${{ github.event_name == 'push' || github.event.inputs.build-app }}
        run: echo "build-app true"
      - name: Check build environment
        if: ${{ github.event_name == 'push' || github.event.inputs.environment }}
        run: echo "${{ github.event.inputs.environment }}"

act_build.json

{
  "action": "workflow_dispatch",
  "inputs": {
    "build-app": true,
    "environment": "stg"
  }
}

false == ''がtrueの話

最後にちょっと困った話を書いておきます。

GitHub Actionsのドキュメントなどには、$$||を使って、疑似的に三項演算子のような書き方をしているときがあります。

env:
  MY_ENV_VAR: ${{ github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches' }}

説明のために解説すると、式A && 式B || 式Cという式があった場合、式Aがtrueであれば式Bの結果が、式Aがfalseであれば式Cの結果が返ってくるのですが、 これだと式Aで左辺の値が未定義ではなくfalseとして定義されているかをチェックすることができません。

というのもGitHub ActionsにはJavaScriptでいうところの厳密比較がないので、'' == falsenull == falseはtrueになり、チェックしたい値 == falseとした場合、 チェックしたい値がfalseでも未定義でも結果がtrueになるからです。

なんでこんなことでハマったのかというと、最初はgithub.event_nameのことを忘却しており、 ワークフローのトリガーがworkflow_callかworkflow_dispatchかをinputsの値の有無で判定しようとしたのですが、 inputsの変数にfalseが入ってきたときににっちもさっちもいかなくなったからでした。

前半の解説にちょいちょいgithub.event_nameやinputsの話が出てきているのはこれが理由です

まとめ

act自体はGitHubにymlファイルをpushすることなくワークフローが試せるのでおすすめです。
今回はやらなかったのですが、actでGITHUB_TOKENを利用したワークフローを試す場合は、自身のGitHub個人トークンを発行し、.secretsファイルから読み込ませるとできるはずです。

2024/01/18追記

inputsとGITHUB_TOKENの話はこちらに補足があります。

seri.hatenablog.com

あとがき

この記事を最近買ったNuPhy Air60 V2をMacBookの上に置いてBluetoothで繋いで書いたのですが、Discordのコミュニティで提供されていたQMK_firmware_nuphy_air60_v2_ansi_v1.1.1.binを適用した後も3回ほどBluetooth接続が切れてしまいました。 キーボード自体は思っていた以上に打ち心地が良かったので、改善されたfirmwareが出ることを期待しています。

DESKEYSでパーツを買って、HHKBをメンテナンスした

長年HHKBを使っているのですが、先日初めてラバードームに保護スプレーを吹いたり、キースイッチにシリコンオイルを塗ったりしたところ、あまりにも調子が良くなったので、本腰を入れてメンテナンスをしました。

結果、すこぶる打ちやすくなったので、打ち心地確認を兼ねてメンテナンス内容を書いていきます。

ラバードームの交換と静音リング追加

先日のメンテナンスで、ラバードームとキースイッチをメンテナンスしたHHKB BTは、メンテナンス直後の状態を維持してくれたのですが、 キースイッチのみをメンテナンスしたHHKB Type-Sは、1週間ぐらいでまたキーが重く感じるようになってしまいました。

となるとやはりラバードームの状態が重要に感じたので、DESKEYSでラバードームの一番軽いやつと、ついでに静音リングを購入しました。

物は注文してから5日で届きました。

ラバードームはせっかくならと家にある3台分買いました。 静音リングがあまりにも商品画像と違うので、本当にあってるのかはわかりませんw

さっそくいつも使っているHHKB BTからメンテナンスをしました。
メンテナンス自体は、ちまちまとキースイッチを外して、リング付けて、グリス塗って、ハメてを繰り返していく、「なんでこんなことしてるんだろう」と思うような時間でしたが、 ラバードームも変えて2時間半ほどで終了しました。

保護スプレーはゴムの劣化を防ぐものなので、新品のラバードームにも吹きかけておきました。
ラバードームは純正のものと比べると少し端が大きいので、ハサミで切り取っておくと収まりが良かったです。

メンテナンスしてどうだったのか?

キーの軽さを求めて30gのRealForceをここ数年ずっと検討していた身からすると、やっと求めていた打ち心地を手に入れれて感動しています。 HHKB BTのBluetoothが、というよりMBPのBluetoothがいまいちで接続が安定しないのは変わりませんが、これなら乗り換えなくていいかなという想いでいます。

ここのところロープロファイルのキーボードも検討していたのですが、またしばらくはHHKBに頑張ってもらおうと思います。

でも残り2台のメンテナンスは・・・まあそのうち・・・やる、かな・・・という感じです。