メタバースから現実世界をハックする

Programming

この記事は 六間坂上 Advent Calendar 2024 25日目 の記事です。
メリークリスマス🎄!

インターネットからこんにちは

こんにちは、ここのえです。みなさん、年の瀬はいかがお過ごしでしょうか?

私はついこの前まで開催されていたバーチャルマーケット 2024 WinterをSSDがパンクするまで写真を撮りまくって満喫してきました。知らない方向けに簡単に説明すると、VR空間のコミケみたいな感じです。色んなクリエイターさんが3Dモデルとかギミックとか展示したりしてます。やっぱりバーチャルは最高っすねぇ~~~~!!!!!!(時速100キロおいどけクソバーチャルバーチャルVRVRVRVRメタバースメタバースぐわーーーーーーーー俺がバーチャルになってしまった(超常現象

やっぱうちのしなのちゃんが一番かわいい(異論は認める)

そんなこんなで最近はVRChatで作業したりする時間が多いので、やはりゴーグルを被りながらだと色々するのに不便です。そんな時に「これVR空間の中で完結すればいいのにな……」と思う事もしばしば。こうなってくるとすぐに”わるいこと“を考えはじめます。

メタバースから現実世界をハックすればいいのでは?

ということで、アドカレ最終日の記事は「メタバースから現実世界をハックする」です。VRChatの中から色んなものを操作してみようという試みです。

【準備①】Node.jsでOSCの受け取り体勢を整える

VRChat→外側の操作については、基本的にはアバターに仕込んだOSC以外に送信する方法がありません。実装の容易さと拡張性を考えるとワールドからOSCを使いたいところですが、今後「現行のSDK3でワールドでOSCを使用できるようにする機能の実装予定はある」、とドキュメントに書かれてから幾星霜が過ぎましたが……はい、今もありません。

VRC_OscButtonIn
🚧 Deprecated: This component is deprecated. It is not available in the latest VRChat SDK, and is either non-functional, ...

ワールドについては外部通信としてMIDIの「受信」はできますが、送信の機能はないです。

ということでOSCを仕込んでVRChatからWindowsに向けて送ることになります。ポートは 9001 が設定されているのでそれを使いましょう。受け取ったOSCの処理はNode.jsを使います。nodeには node-osc というOSC用のパッケージがあるので、それを使うと楽です。

node-osc
pyOSC inspired library for sending and receiving OSC messages. Latest version: 9.1.4, last published: 3 months ago. Star...

こんな感じでベースとなるプログラムを組んでいきます。Typescriptで書きました。

TypeScript
import { type ArgumentType, Server } from "node-osc";

type OSCHandler = {
	address: string;
	handler: (data: [string, ...ArgumentType[]]) => void;
};

export class VrcOscServer {
	public server: Server;
	private handlers: OSCHandler[] = [
		{
			address: "Osc_Address",
			handler: (data) => {
				// ここに処理
			},
		},
	];

	constructor() {
		this.server = new Server(9001, "0.0.0.0");
		for (const handler of this.handlers) {
			this.server.on(`/avatar/parameters/${handler.address}`, handler.handler);
		}
	}
}

今回は受信だけなのでServerだけ用意します。後からアクションを追加しやすいように、OSCHandler を用意して for ... of でイベントを登録するような設計にしました。これでハンドラを追加すればいくら増やしても安心です。

ちなみに私は型原理主義者であり、any 絶対許さないマン なので上記の感じで書いてます。受信したメッセージのデータはbool, int, float 等何が返ってくるか分からないので、ハンドラの型としては ([string, …ArgumentType[]]) => void で定義されています。

【準備②】VRChat側から送信できるようにする

VRChatからOSCを送信できるようにするには、アバターにパラメータを仕込む必要があります。アバターのパラメータは値が変わったタイミングで自動的にポート 9001/avatar/parameters/{name} のアドレスで送ることができます。

手で入れるのもそんなに難しくありませんが、今回のサンプルでは Modular Avatar を使用します。VRCをやっている人にはお馴染みの非破壊的なアバター用ツールです。これをUnityからいじっていくことになります。

モジュラーアバター | Modular Avatar
D&Dでアバター組み立て

VRCをやってないからすると「えっ!?Unity!?」と思われるかもしれませんが、VRChatのアバターアップロードは基本的にUnity経由でしかできません。VRChatをやってると日常茶飯事に使いすぎてみんなが使えるのが当たり前のツールみたいになっていきますが、一般的にはそんなことないです。これがエコーチェンバーってヤツですね。

以下、MAを使ったことがあるユーザには釈迦に説法のような話が続きます。どうでもよかったら飛ばしてください。

話を本題に戻して、Modular Avatarで追加する用のオブジェクトを作成していきます。Hierarchy上ははこんな感じになります。

VrcOsc メニューを追加し、その中のサブメニューとして各ボタンを実装していきます。ルート要素となる VrcOscControl オブジェクトに MA Menu Installer, MA Menu Group コンポーネントを追加します。

VrcOsc オブジェクト内には MA Menu Item コンポーネントを追加します。サンプルとして後述のCase.1のYoutubeコマンドを追加しました。

ここで操作するパラメータの名前として Osc_OpenYoutube を指定しましたが、まだパラメータ上に存在していないため、このままビルドするとエラーになってしまいます。再度ルートの VrcOscControl オブジェクトに戻り、MA Parameters コンポーネントを追加します。OSCで送られてくるときは、このパラメータ名が使われて /avatar/parameters/Osc_OpenYoutube のようなアドレスが飛んできます。

今回は数値は必要ないので、パラメータ型は Bool にしておきます。同様にしてアクションを増やす場合はパラメータを追加しましょう。

設定がうまくいっていれば、VRCのExpression Menuに対象のメニューが追加されているはずです。

【Case.1】Windowsへの干渉・Youtubeを開く

手始めにちょっとした便利ツールから作っていきます。VRCで一人でワールドを散策してる時にちょっと裏で動画を流したくなったり、友人とVRC内で動画を再生したくなり、YoutubeからURLを引っ張ってきたい時……ありますあります。VRC内からYoutube.com を一発で開いちゃいましょう。

Windowsは start [URL] の形式でコマンドを叩けば既定のブラウザで開けます。

PowerShell
start https://www.youtube.com

Nodeの場合は child_processexec で実行できます。Electronアプリケーションを作っているのであれば open パッケージを使った方がいいかもしれませんが、そもそもVRC前提ならWindows以外の環境は考慮しなくていいので、直で叩きます。

TypeScript
import { exec } from "node:child_process";

// ...

export class VrcOscServer {
	public server: Server;
	private handlers: OSCHandler[] = [
		{
			address: "Osc_OpenYoutube",
			handler: (data) => {
				if (data[1] === false) return;
				exec("start https://www.youtube.com");
			},
		},
	];
}

VRC Parameterは、bool, int, float の3タイプを送信します。Expression Menuのボタンを押下した際も例外ではなく、true が送信された後に false も自動的に送信されるため、if文で飛ばしています。

Good. QoLが上がる音がしました。

うーん、でもまだコンピュータの中をこねくり回してるだけなのであんまり現実世界感はないですね。はい、次行きましょう。

【Case.2】部屋の電気を消す

VR睡眠をしたくなる時、ありますよね。え、ないって?

……寝るタイミングで部屋の電気も消したくなりますが、そのタイミングでゴーグルを外したらなんか急に冷めますよね。ということで、VRChatをしたまま部屋の電気を消してみましょう。

たぶん一家に一台はAPIが叩けるスマートホームデバイスがあると思うのでそれを駆使します。うちはSwitchbotを使っているので、もう何でもやり放題です。なんですが、2022年ごろからAPIのバージョンが v1.1 に変更され、大きな変更が入っています。Githubに公式のドキュメントがあるのでこちらのExampleをベースに作っていきます。

GitHub - OpenWonderLabs/SwitchBotAPI: SwitchBot Open API Documents
SwitchBot Open API Documents. Contribute to OpenWonderLabs/SwitchBotAPI development by creating an account on GitHub.

複数台の照明を一気にコントロールすることも想定して、On/Offは予め シーン で設定しておきます。シーンのIDが分からないとコマンドを発行できないので、一旦 /v1.1/scenes にリクエストを送って確認してみます。

TypeScript
import * as crypto from "node:crypto";
import * as https from "node:https";
import { v4 as uuid } from "uuid";

if (
	process.env.SWITCHBOT_TOKEN == null ||
	process.env.SWITCHBOT_SECRET == null
) {
	console.log("SwitchbotのToken, Secretの環境変数がありません");
	process.exit(1);
}

const token = process.env.SWITCHBOT_TOKEN;
const secret = process.env.SWITCHBOT_SECRET;
const t = Date.now();
const nonce = uuid();
const data = token + t + nonce;
const sign = crypto.createHmac("sha256", secret).update(data).digest("base64");

const options = {
	hostname: "api.switch-bot.com",
	port: 443,
	path: "/v1.1/scenes",
	method: "GET",
	headers: {
		Authorization: token,
		sign: sign,
		nonce: nonce,
		t: t,
	},
};

const req = https.request(options, (res) => {
	res.on("data", (d) => {
		const jsonObj = JSON.parse(d.toString());
		console.log(JSON.stringify(jsonObj, null, 2));
	});
});

req.on("error", (error) => {
	console.error(error);
});

req.end();

囧ウワアアァァァアアァアァァアアァ~ナンデ~

Switchbot API v1.1は認証関係がクソ面倒になりました。まあスマートホームデバイスなので、セキュリティは厳しければ厳しいほどいいといえば……それはそうなんですが……。詳細の説明は色んな人が記事にしているのでここでは避けますが、v1.0に比べての大きな違いは token + t + nonce をベースにして署名の発行が必要です。併せてヘッダーについても Authorization, sign, nonce, t の4種を送るように変更になっています。

うまく通せればこんな感じのレスポンスが返ってきます。レスポンスは Buffer で返ってくるので、見やすいように String に変換してから JSON.stringify() して整形して表示しています。

JSON
{
  "statusCode": 100,
  "body": [
    {
      "sceneId": "ここにScene ID",
      "sceneName": "照明OFF"
    },
    {
      "sceneId": "ここにScene ID",
      "sceneName": "照明ON"
    }
  ],
  "message": "success"
}

IDが特定出来たらそれを使ってシーンを実行します。POST /v1.1/scenes/{sceneId}/execute で実行できます。

GitHub - OpenWonderLabs/SwitchBotAPI: SwitchBot Open API Documents
SwitchBot Open API Documents. Contribute to OpenWonderLabs/SwitchBotAPI development by creating an account on GitHub.

こんな感じになりました。

SwitchbotAPIRequest.ts
import crypto from "node:crypto";
import https from "node:https";
import { v4 as uuid } from "uuid";

export function switchbotAPIRequest(method: string, path: string) {
	if (
		process.env.SWITCHBOT_TOKEN == null ||
		process.env.SWITCHBOT_SECRET == null
	) {
		console.log("SwitchbotのToken, Secretの環境変数がありません");
		return;
	}

	const token = process.env.SWITCHBOT_TOKEN;
	const secret = process.env.SWITCHBOT_SECRET;
	const t = Date.now();
	const nonce = uuid();
	const data = token + t + nonce;
	const sign = crypto
		.createHmac("sha256", secret)
		.update(data)
		.digest("base64");

	const options = {
		hostname: "api.switch-bot.com",
		port: 443,
		path: path,
		method: method,
		headers: {
			Authorization: token,
			sign: sign,
			nonce: nonce,
			t: t,
		},
	};

	const req = https.request(options, (res) => {
		res.on("data", (d) => {
			const jsonObj = JSON.parse(d.toString());
			console.log(JSON.stringify(jsonObj, null, 2));
		});
	});

	req.on("error", (error) => {
		console.error(error);
	});

	req.end();
}
TypeScript
import { exec } from "node:child_process";
import { type ArgumentType, Server } from "node-osc";
import { switchbotAPIRequest } from "./SwitchbotAPIRequest";

// ...

export class VrcOscServer {
	public server: Server;
	private handlers: OSCHandler[] = [
    // ...
		{
			address: "Osc_LightOff",
			handler: (data) => {
				if (data[1] === false) return;
				const sceneId = process.env.SWITCHBOT_SCENE_LIGHT_OFF;
				if (sceneId == null) {
					console.error("照明OFFのScene IDが指定されていません。");
					return;
				}

				switchbotAPIRequest("POST", `/v1.1/scenes/${sceneId}/execute`);
			},
		},
	];

	// ...
}

これでSwitchbotがコントロールできるということは、実質家電はほぼ全てVRChatから操作できるということです。エアコンの温度調整とか実装するとより快適になりそう。

【Case.3】iPhoneのショートカットを実行する

家電が操作できるようになったので、ほとんどのものは既に問題なく実行できます。ちょうど上記コードを書いた時点でお昼になりました。昼食には わずかなアクションで素早く安全に喫食可能なフルマネージド統合サービス を使用したくなりました。はい、カップ麺を食べようと思います。

デスクでお湯を沸かしてiPhoneのアラームをセット……あっ、ゴーグルをつけたままなのでFace IDが使えない!ロック解除するのも見えないし……。

彡(゚)(゚)「せや!VRChatからアラームをリモート実行したろ!

おことわり
現実空間が見えない状態でお湯を取り扱うのは、危険行為なのでやめましょう。非推奨です。

iPhoneで実行すればApple Watchも自動的にアラーム通知が行くので、万が一ワールド内で爆音で上映会をしていても振動で気づけます。iPhoneのショートカット機能にはオートメーション機能が実装されており、メールの送り元・件名をトリガーにしてショートカットを実行することができます。今回はこれを使います。

予めショートカットのアクションに、Ramen Timer(3 Minites) を追加しておきます。今回はどん〇衛やカレー〇シ(5分)の事は考慮しません。

オートメーションも設定しておきます。件名が「Osc_Ramen」のメールが特定のアドレスから送信されたとき、ショートカットを実行するようにしておきます。

実装側はメールを送るだけなので大した手間ではありません。nodemailer を使って送ります。今回はiCloudを使いました。

TypeScript
import mailer from "nodemailer";

export function sendMail(subject: string) {
	if (
		process.env.ICLOUD_USER == null ||
		process.env.ICLOUD_APP_PASSWORD == null
	) {
		console.log("メール送信に必要な環境変数が見つかりません");
		return;
	}

	const transporter = mailer.createTransport({
		host: "smtp.mail.me.com",
		port: 587,
		auth: {
			user: process.env.ICLOUD_USER,
			pass: process.env.ICLOUD_APP_PASSWORD,
		},
		tls: {
			rejectUnauthorized: false,
		},
	});

	const options = {
		from: process.env.ICLOUD_USER,
		to: process.env.ICLOUD_USER,
		subject: subject,
	};

	transporter.sendMail(options, (err, res) => {
		if (err) {
			console.error(err);
		}
		transporter.close();
	});
}

ハンドラに追加して呼び出せるようにします。

TypeScript
import { exec } from "node:child_process";
import { type ArgumentType, Server } from "node-osc";
import { switchbotAPIRequest } from "./SwitchbotAPIRequest";

// ...

export class VrcOscServer {
	public server: Server;
	private handlers: OSCHandler[] = [
    // ...
		{
			address: "Osc_Ramen",
			handler: (data) => {
				if (data[1] === false) return;
				sendMail("Osc_Ramen");
			},
		},
	];

	// ...
}

という事でiPhoneをリモートで操作する事に成功しました。前述のSwitchbotも直接APIを叩くのが面倒なのであれば、iPhone経由でショートカットで操作するという技も使えます。まあメール使ったゴリ押し技なので最適解ではないのは事実なんですけどね…

ちなみにWindows 11のアラーム機能を使って鳴らすことも考えたのですが、URIスキームが存在しないので外部から操作することができません。ショートカットに近いアプリケーションとしてPower Automate for Desktopも存在しますが、無償版だと外部からの操作ができません。うーん不便。

ラーメンタイマーの冗談はさておき、応用法としてお勧めなのがポモドーロタイマーの実装です。VRCで喋りながら作業を行う場面で重宝します。アバター側のメニューをToggleにしてTrue/Falseを受信できるようにしておき、ポモドーロタイマーがOnの時は25分/5分のアラームを起動し続けるようにすればOKです。

……実はこの記事、結局VRChatというより Node.jsこねくり回してるだけでは?(気づき)

おわりに

2024年を振り返ってみると、辛辣なコメントを残すロボカスが出てきたり、祠を壊したり、月曜がもう近かったり、色々ありましたが皆さんはどんな1年になったでしょうか。たぶん鹿乃子のこちゃんの事はもう頭の片隅にもないでしょう。たまには思い出してあげてね。\ヌン/

個人的には3Dモデル作ってイベント出展したりデザイン回りやってたりWebのフロントエンドとか色々やってたりしてたのですが、その辺についてはそのうち投稿したいなと思っています。

そしてアドカレに参加してくださったみなさん、ありがとうございました!この場を借りて感謝申し上げます。師走の忙しい時期でネタ出しや執筆時間の捻出が難しい中、たくさんの方に参加していただき嬉しい限りです。来年もまたやれたらいいな……と思ってます。

本年もありがとうございました!今年も残りわずかですが、みなさんよいお年を!

エビ揉んでくれてありがとう

参考

FirebaseのSMTP設定でGmailとiCloudからメールを送信できるようにしてみた - Qiita
FirebaseのSMTP設定でGmail・iCloudからメールを送信する方法恐らくFirebaseに限らず、どのSMTP設定もこれで送信できると思います。一応、nodemailerで試したと…
SMTP transport :: Nodemailer
Nodemailer is a module for Node.js to send emails

コメント

タイトルとURLをコピーしました