👥

VRChat APIを使用して複数ユーザーにInviteを送るサンプル

に公開

VRChat APIを使用して複数プレイヤーにInviteを送る機会があったため、サンプルコードとして残しておきます。

前提

VRChatのWebやクライアント内部で使用されているAPIは、ユーザーがBOTやアプリケーションの制作で使用してもよいとされており、実際VRChat公式の文書であるクリエイターガイドラインには、非公式ながら存在するドキュメントにリンクが貼られ、ガイドラインを守る限りAPIを使用してよい、との旨が明記されています。

本稿では、このVRChat APIを使用し、認証の自動化から特定ユーザーへInvite(招待)を送るまでのサンプルコードを、javascript(Node.js)で掲示します。

VRChat APIはドキュメントが非公式なうえ日本語での解説も少ないことから、対応アプリケーション開発はかなりハードルの高いものとなっています。とはいえ各言語に対応したライブラリ等も存在し、わかってしまえばそれほど難解なものではありません。

本稿が皆様のアプリケーション開発にお役立ていただければ幸いです。

注意点

本稿では、VRChat APIを使用するに際して、利用される方のVRChatの認証情報を使用します。
本来であれば、そういった機密性の高い情報は暗号化するなどして安全性を担保すべきですが、本稿はあくまでVRChat APIを使用するためのサンプルであり、暗号化といった内容は本稿の趣旨ではないため省いています。

また、クリエイターガイドラインには、

Do not request log-in information from users in any situation.
You should never ask for or store someone's VRChat credentials. This includes usernames, passwords, login or auth tokens, and/or session data.
(日本語訳)いかなる状況においても、ユーザーにログイン情報を要求しないでください。
VRChatの認証情報を尋ねたり、保存したりしないでください。これには、ユーザー名、パスワード、ログイントークン、認証トークン、セッションデータなどが含まれます。

と明記されております。すなわちガイドラインを遵守するには、ユーザーにアカウント情報の入力を求める機構を作ってはならず、自分で作ったアプリケーションに自分のアカウントをハードコートするしかない。となります。
(要は「外部サービスにアカウント丸ごと入れさせるなんて危なくて仕方がねーよ。そんなもん作るんじゃねーよ」と書いてあるわけで。まあ当然ですよね)

上記2点を踏まえたうえで、あくまで本稿で掲示されるコードはサンプルであり、エラー処理なども甘く、そのままの動作を想定したものではありません。VRChat APIならびにその認証情報を使用される場合は、必ずご自身作成のアプリケーションを、ご自身のアカウントに限り、ご自身の責任において実行していただく必要がある事を念頭に置いていただければ幸いです。

免責事項

本稿を参考にしたことによって生じる問題について、筆者は一切の責任を負いません。

環境

Windows11
Node.js v20.19.1 (Javascript実行環境)
vrchat v1.19.1 (VRChat APIライブラリ)
speakeasy v2.0.0 (後述する2fa認証用)
dotenv v16.5.0

準備

何はともあれNode.jsをインストールしていただく必要があります。
開発環境はv20.19.1で最新のLTSより一段古いバージョンですが、本稿執筆時点の最新LTS(v22.15.0)でも問題ないでしょう。
Node.js®をダウンロードする

インストール自体は特に問題ないと思います。

次にデスクトップなどに適当なフォルダを作り、アドレスバーにcmdと入力してEnterし、コマンドプロンプトを開きます。

次に必須モジュールをインストールします。コマンドプロンプト上で以下のコマンドを入力します。

npm install vrchat speakeasy dotenv

成功すると以下のようなメッセージが表示され、フォルダにモジュールがインストールされます。

本題

ここからソースコードの解説をしていきます。フォルダに適当な名前(ここではvrc_invite_test.jsとします)でファイルを作り、テキストエディタで開きます。
(テキストエディタ何使ったらいいのという方のために、Sublime Textをお勧めしておきます。)

GitHubのvrchatapiページ上にサンプルコードが掲示されていますので、これを利用します。ただしいくつか間違っている点があり、そのままでは動作しないため、少し改造します。
https://github.com/vrchatapi/vrchatapi-javascript/blob/main/example.js

初期化部分

vrc_invite_test.js
'use strict';

const fs = require('fs');
const vrchat = require('vrchat');
const readline = require('readline');
const speakeasy = require('speakeasy');
require('dotenv').config();

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const prompt = (query) => new Promise(resolve => rl.question(query, resolve));
const delay = (time) => new Promise(resolve => setTimeout(resolve, time));

class Vrc {
constructor(params)
{
  this.configuration = new vrchat.Configuration({
    username: params.username,
    password: params.password,
    baseOptions: {
      headers: { 'User-Agent': params.useragent }
    }
  });
  this.mfaSecret = params.mfaSecret;
  
  this.api = {
    authentication: new vrchat.AuthenticationApi(this.configuration),
    invite:         new vrchat.InviteApi(this.configuration),
    system:         new vrchat.SystemApi(this.configuration),
    users:          new vrchat.UsersApi(this.configuration),
  };
  this.isLogin = false;
}

上記コードの細かな解説

'use strict';

const fs = require('fs');
const vrchat = require('vrchat');
const readline = require('readline');
const speakeasy = require('speakeasy');
require('dotenv').config();

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const prompt = (query) => new Promise(resolve => rl.question(query, resolve));
const delay = (time) => new Promise(resolve => setTimeout(resolve, time));

各パッケージの読み込みと、後述するコマンドラインからの入力用の関数などを定義しています。

class Vrc {
constructor(params)
{
  this.configuration = new vrchat.Configuration({
    username: params.username,
    password: params.password,
    baseOptions: {
      headers: { 'User-Agent': params.useragent }
    }
  });
  this.mfaSecret = params.mfaSecret;
  
  this.api = {
    authentication: new vrchat.AuthenticationApi(this.configuration),
    invite:         new vrchat.InviteApi(this.configuration),
    system:         new vrchat.SystemApi(this.configuration),
    users:          new vrchat.UsersApi(this.configuration),
  },
  this.isLogin = false;
}

APIを利用するために、まずクラスを定義しています。サンプルですのでAPIのインターフェース等や関連関数はグローバルに置いても構いませんが、今後の利用も考えると、グローバル変数として置いておくより、クラス化した方が利便性がよいでしょう。

コンストラクタ内でvrchat.Configuration()を呼び出してコンフィギュレーションを作成します。コンフィギュレーションを各APIのインターフェースに設定し、APIの使用準備が整います。

GitHubにあるオリジナルではソースコードにID/Passwordを直接ハードコートしていますが、さすがに気が引けるため、パラメータとして受け取れるようにしておき、後ほどのインスタンス化時に.envファイルから読み込みます。

.envファイルの作成

.envファイル(ファイル名のない拡張子.envだけのファイル)を作成します。このファイルはサンプルコードと同じフォルダに置いておくと自動で読み込まれます。

.env
VRC_USERNAME="ユーザー名"
VRC_PASSWORD="パスワード"
VRC_MFA_SECRET=""
VRC_USERAGENT="applicationName/Version 連絡のつくメールアドレス"

VRC_USERNAME, VRC_PASSWORDにVRChatのID/Passwordをそれぞれ記述。

VRC_MFA_SECRETに関しては、二段階認証をTOTPで設定してある場合に、この部分にSECRETを設定しておくと、認証時に自動的に2FAコードが生成されベリファイが行われます。空の場合は2FAコードの入力が求められます。安全性を考えるなら空のままがよいでしょう。

VRC_USERAGENTに関しては、自身のアプリケーション名と連絡先メールアドレスをしっかり記述するようにとクリエイターガイドラインに明記されています。ちなみにa@example.comのような架空のメールアドレスを記述するとしっかり蹴られますので、ちゃんと連絡のつくメールアドレスを記述してください。

認証関数

ここから各種処理を行うクラスメンバ関数の定義に入ります。

vrc_invite_test.js
////////////////////////////////////////////////////
// VRChat ログイン処理
async login()
{
  if( this.isLogin )  return;
  
  console.log('VRChatにログインしています...');
  try{
    let currentUser = (await this.api.authentication.getCurrentUser()).data;
    this.isLogin = true;

    // 2-FA必要
    if( currentUser.requiresTwoFactorAuth ){
      
      // メール2FA
      if( currentUser.requiresTwoFactorAuth[0] === 'emailOtp' ){
        await this.api.authentication.verify2FAEmailCode({ code: await prompt('emailコードを入力してください: ') })
      }

      // TOTP
      else if( currentUser.requiresTwoFactorAuth[0] === 'totp' ){
        let code;
        if( this.mfaSecret ){
          code = speakeasy.totp({
            secret: this.mfaSecret,
            encoding: 'base32'
          });
        }
        else{
          code = await prompt('2FAコードを入力してください: ');
        }
        
        await this.api.authentication.verify2FA({ code });
      }
      
      currentUser = (await this.api.authentication.getCurrentUser()).data;
    }

    if( currentUser.id ){
      console.log(`ログインしました: ${currentUser.displayName}`);
    }
  }
  catch(err){
    console.log(`\t- ERROR! Status: ${err.response.status}, ${err.response.data.error.message}`);
    console.log(`ログインに失敗しました。`);
    await this.logout();
  }
}
async logout()
{
  if( !this.isLogin ) return;
  console.log(`ログアウトしています。`);
  
  await this.api.authentication.logout();
  this.isLogin = false;
  
  process.exit(0);
}

ログイン/ログアウト関数です。
authentication.getCurrentUser()は自分自身のユーザー情報を得る関数ですが、未ログイン状態であった場合は自動的にログインもしてくれます。
ただし二段階認証を設定してあった場合は別にベリファイする必要があります。

// メール2FA
if( currentUser.requiresTwoFactorAuth[0] === 'emailOtp' ){
  await this.api.authentication.verify2FAEmailCode({ code: await prompt('emailコードを入力してください: ') })
}

// TOTP
else if( currentUser.requiresTwoFactorAuth[0] === 'totp' ){
  let code;
  if( this.mfaSecret ){
    code = speakeasy.totp({
      secret: this.mfaSecret,
      encoding: 'base32'
    });
  }
  else{
    code = await prompt('2FAコードを入力してください: ');
  }
  
  await this.api.authentication.verify2FA({ code });
}

currentUser = (await this.api.authentication.getCurrentUser()).data;

メール2FAの場合はコードの入力を促すプロンプトが表示され、TOTPであった場合は、前述の.envファイルにVRC_MFA_SECRETが設定してあった場合は、speakeasyを使用して自動ベリファイします。設定されていなかった場合は自分自身で入力するよう、プロンプトを出力します。

catch(err){
  console.log(`\t- ERROR! Status: ${err.response.status}, ${err.response.data.error.message}`);
  console.log(`ログインに失敗しました。`);
  await this.logout();
}

VRChat APIのライブラリは内部でaxiosを使用しており、サーバーからStatus: 200以外が返された場合は例外をスローします。
全体をtry~catch句で囲んでおき、認証中に例外がスローされた場合は、ステータスコードとエラーメッセージを表示し、ログアウトします。

async logout()
{
  if( !this.isLogin ) return;
  console.log(`ログアウトしています。`);
  
  await this.api.authentication.logout();
  this.isLogin = false;
  
  process.exit(0);
}

ログアウト関数です。本サンプルの場合、ログアウトしてしまうともう何もできなくなってしまうため、強引にprocess.exit(0)してプロセスを終了していますが、普通はこんなことしません。

Invite関数を定義する

Invite用のメンバ関数を定義します。

vrc_invite_test.js
////////////////////////////////////////////////////
// インバイト送信処理
async sendInvites(instance, players)
{
  for( let i = 0; i < players.length; i++ ){
    const player = players[i];
    console.log(`\tインバイトを送っています: ${player.name} (${player.id})`);
    
    try{
      await this.api.invite.inviteUser(player.id, {
        instanceId: instance.id,
        messageSlot: 0  // Inviteを行う際に送るメッセージスロット番号
      });
    }

    catch(err){
      console.log(`\t- ERROR! Status: ${err.response.status}, ${err.response.data.error.message}`);
      if( await prompt('リトライしますか?(y/n): ') === 'y' ){
        i--;
        continue;
      }
    }

    // 短時間にAPIを叩きすぎると良くないので1秒のディレイ
    await delay(1000);
  }
  console.log('完了');
}
};

players配列の分、ループを回し、各ユーザーへInviteを送ります。

try{
  await this.api.invite.inviteUser(player.id, {
    instanceId: instance.id,
    messageSlot: 0  // Inviteを行う際に送るメッセージスロット番号
  });
}

InviteAPIを呼び出している関数です。Inviteに成功した場合は通知用IDなどが格納されたオブジェクトが返ってきますが、このサンプルでは使用しないため、戻り値は無視しています。

catch(err){
  console.log(`\t- ERROR! Status: ${err.response.status}, ${err.response.data.error.message}`);
  if( await prompt('リトライしますか?(y/n): ') === 'y' ){
    i--;
    continue;
  }
}

一方、エラーを起こした場合は例外がスローされるため、ステータスコードやエラーの内容からリトライをするかどうかプロンプトを表示して判断を促します。5xx系のエラーの場合は少し時間をおけば回復する可能性がありますが、4xx系はユーザーが見つからない、フレンドではない等のエラーですから、おそらく回復の見込みはないでしょう。
https://vrchatapi.github.io/docs/api/#post-/invite/-userId-
VRChat API Docs

// 短時間にAPIを叩きすぎると良くないので1秒のディレイ
await delay(1000);

最後に、複数ユーザーへInviteを送る場合、人数が多いと非常に高頻度でAPIにアクセスし続けることになります。サーバー側の負荷を考えるとあまりよろしくありませんので、アクセス毎に1秒間のディレイを入れ、アクセス頻度を調整します。1秒に1回程度のアクセスであれば常識的なものでしょう。
ここでクラスメンバ関数の定義はひとまず終了し、クラス自体の定義も終わります。

};

Inviteする相手と何処にInviteするかを決める

クラス定義も済んだため、いよいよ他のユーザーをInviteしたいところですが、その前に、「誰を」「何処へ」Inviteするかを決めなければなりません。

Inviteしたい相手を指定するには

Inviteするには、相手のユーザーIDが必要になります。Web上から相手のページを開くと、URL欄に表示されるusr_から始まるUUIDがユーザーIDです。例えば私のユーザーIDはusr_b35863ee-320b-429c-831f-716de4d6ec23です。

何処へInviteするか

Invite先を指定するにはインスタンスIDが必要になります。インスタンスIDは以下のような形式で
vrchatのログに出力されます。

2025.05.13 21:01:01 Debug      -  [Behaviour] Joining wrld_26d4f409-3f76-48d2-82cd-604d211552de:31685~private(usr_b35863ee-320b-429c-831f-716de4d6ec23)~region(jp)

以下がインスタンスIDです
wrld_26d4f409-3f76-48d2-82cd-604d211552de:31685~private(usr_b35863ee-320b-429c-831f-716de4d6ec23)~region(jp)
自分が今いるインスタンスに誰かをInviteしたい場合は、ログの最後の[Behaviour] JoiningからインスタンスIDを割り出す必要があります。

JSONデータの作成

利用シーンなどを考えると、例えば事前に抽選に応募し当選したユーザーをイベントに招待する、といった利用が考えられます。その場合Inviteしたいユーザーは複数人である場合があり、またユーザーIDの羅列とインスタンスIDは種類の異なるデータであるため、JSONデータとして管理するのがベターでしょう。
サンプルとして以下のようなJSON文字列を作成し、コマンドラインから入力するために改行を取り除いておきます。

{
  "worldId": "wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd",
  "worldName": "VRChat Home",
  "instanceId": "wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd:11731~private(usr_a80f76a6-1b36-4f53-8666-a697f2327a17)~region(jp)",
  "players": [
    {
      "id":"usr_b35863ee-320b-429c-831f-716de4d6ec23",
      "name": "おにく/Oniku"
    }
  ]
}
{"worldId":"wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd","worldName":"VRChat Home","instanceId": "wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd:11731~private(usr_a80f76a6-1b36-4f53-8666-a697f2327a17)~region(jp)","players": [{"id":"usr_b35863ee-320b-429c-831f-716de4d6ec23","name": "おにく/Oniku"}]}

worldIdworldNameについては、単に表示を行っているだけであり、作らなくても構いません。重要なのはinstanceIdで、これは自身がインスタンスオーナーであるなど、Inviteを行う権限のあるインスタンスのIDである必要があります。
また、players配列下のidについても、自身のフレンドであるユーザーのIDである必要があります。フレンドではないユーザーをInviteすることはできません。

JSON文字列の入力とパース

サンプルコードに戻り、JSON文字列の入力・パース部分を作っていきます。

vrc_invite_test.js
////////////////////////////////////////////////////
// 
async function inputJson(json)
{
  while( 1 ){
    try{
      const data = JSON.parse(json || await prompt('JSONコードを入力してください: '));
      
      console.log('プレイヤー -----------------------------------------------');
      data.players.forEach(player => {
        console.log(`\t${player.name} (${player.id})`);
      });
      console.log('---------------------------------------------------------');
      console.log(`上記当選者${data.players.length}名を以下のインスタンスに招待(Invite)します。`);
      
      const instance = splitInstanceId(data.instanceId);
      console.log(`\t${data.worldName}`);
      console.log(`\t#${instance.no}[${instance.region}] - ${instance.type}`);
      
      if( await prompt('よろしいですか?(y/n): ') === 'y' ){
        return {
          players: data.players,
          instance
        };
      }
    }
    catch(err){
      console.log(`JSONパースエラー`);
    }
    json = null;
  }
}
vrc_invite_test.js
////////////////////////////////////////////////////
// インスタンスID文字列から各要素を分離
function splitInstanceId(instanceId)
{
  let inst = instanceId.match(/:([a-zA-Z\d]+)~/) || instanceId.match(/:([a-zA-Z\d]+)$/);
  const instanceNo = inst ? inst[1] : null;
  
  let instanceType = 'Public';
  let instanceUserId, instanceGroupId;
  if( inst = instanceId.match(/friends\((usr_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)/) ){
    instanceType  = 'Friends';
    instanceUserId  = inst[1];
  }
  else if( inst = instanceId.match(/hidden\((usr_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)/) ){
    instanceType  = 'Friends+';
    instanceUserId  = inst[1];
  }
  else if( inst = instanceId.match(/private\((usr_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)/) ){
    instanceType  = 'Invite';
    if( instanceId.match(/canRequestInvite/) )
      instanceType += '+';
    instanceUserId  = inst[1];
  }
  else if( inst = instanceId.match(/group\((grp_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)/) ){
    if( instanceId.match(/groupAccessType\(members\)/) )      instanceType = 'Group';
    else if( instanceId.match(/groupAccessType\(plus\)/) )    instanceType = 'Group+';
    else if( instanceId.match(/groupAccessType\(public\)/) )  instanceType = 'Group Public';
    instanceGroupId = inst[1];
  }
  
  let region;
  if( inst = instanceId.match(/region\(([a-zA-Z]+)\)/) )
    region = inst[1];

  return {
    id: instanceId,
    type: instanceType,
    no: instanceNo,
    region,
    userId: instanceUserId,
    groupId: instanceGroupId
  };
}

inputJson()関数は引数で与えられたJSON文字列をパースし、確認のため表示も行います。引数がない場合はコマンドラインからJSON文字列を受け取ります。JSONとして解釈できない文字列であった場合は例外をスローするため、全体をtry~catch句で囲み、さらにやり直しできるようにwhile句で囲んであります。

splitInstanceId()関数はインスタンスIDからインスタンスの番号やインスタンスタイプ、オーナーのIDなどを分離して返す関数です。

余談ですがインスタンスIDの構造について興味のある方はコチラ。

インスタンスIDについて

インスタンスIDには、そのインスタンスがどのような種別のインスタンスであるかが示されており、チルダ(~)で分離して意味を知ることができます。例えばwrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd:11731~private(usr_a80f76a6-1b36-4f53-8666-a697f2327a17)~region(jp)というようなインスタンスIDである場合、

  • wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd:11731
  • private(usr_a80f76a6-1b36-4f53-8666-a697f2327a17)
  • region(jp)

上記3項目に分離できます。それぞれ

  • ワールドのID:固有番号(5桁の数値である場合が多いが可変長の英数字でも良いらしい)
  • オーナーがusr_a80f76a6-1b36-4f53-8666-a697f2327a17のprivate(Invite)インスタンス
  • 日本リージョン

となります。(ただしこれは3項目に固定されているわけではなく、2項目しかなかったり、4項目だったりと可変します。例えばPublicインスタンスでは2項目目は存在しません。)

2項目目のインスタンスタイプは一般的に言われているフレプラなどとは若干表記が異なっており、また1:1にもなっていません。以下のように対応します

  • friends(usr_xxx...) ユーザーusr_xxxのFriends
  • hidden(usr_xxx...) ユーザーusr_xxxのFriends+
  • private(usr_xxx...) ユーザーusr_xxxのInvite
    Invite+インスタンスの場合は直後にcanRequestInviteが付きます
  • group(grp_xxx...) グループgrp_xxxの以下のいずれかのインスタンス
    • groupAccessType(members) Group (いわゆるグルオン)
    • groupAccessType(plus) Group+
    • groupAccessType(public) Group Public

また、現在のインスタンスID形式はいずれUUID形式へ変更する予定だそうです。Changes to Instance APIs and Auto Creation

main関数

vrc_invite_test.js
////////////////////////////////////////////////////
// main処理
async function main()
{
  const vrc = new Vrc({
    username: process.env.VRC_USERNAME,
    password: process.env.VRC_PASSWORD,
    useragent:process.env.VRC_USERAGENT,
    mfaSecret:process.env.VRC_MFA_SECRET
  });
  let json = process.argv[2] ? fs.readFileSync(process.argv[2]) : null;

  while( 1 ){
    const data = await inputJson(json);
    
    await vrc.login();
    await vrc.sendInvites(data.instance, data.players);
    
    if( await prompt('別のインバイトを送信しますか?(y/n): ') !== 'y' ){
      break;
    }
    
    json = null;
  }
  
  await vrc.logout();
}
main();

上記すべての処理をまとめたメイン処理部分です。

まず、Vrcクラスをnewしてインスタンス化し、APIの使用準備をします。

引数が与えられて起動した場合はそれをJSONファイルであるとしてfs.readFileSync()で読み込み、inputJson()に渡します。

inputJson()は引数が与えられている場合はそれを、与えられていない場合はコマンドラインからJSON文字列を取得し、必要なデータをパースします。

データが揃い次第login()でログイン処理を行います。この関数はログインに失敗するとアプリケーションそのものを終了させるため、戻ってきたときはログインが二段階認証含めて成功していることを意味します。

後はsendInvites()で実際のInvite処理を行い、完了です。

vrc_invite_test.batを作る

毎回コマンドプロンプトを開くのも面倒のため、バッチファイルを作っておきます。バッチファイルを作っておくとダブルクリックするだけで開いてくれるようになります。またJSONファイルをD&Dすることでファイルパスを引数として与えることができます。

vrc_invite_test.bat
cd "%~dp0"
node vrc_invite_test.js %1
pause

Discussion