Last active
May 9, 2026 11:00
-
-
Save hi94740/b954be984639ff5246db9e69eb9f7622 to your computer and use it in GitHub Desktop.
Open VRChat user in Home Assistant. Intended to use with the VRChat integration for Home Assistant.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Open VRChat user in Home Assistant | |
| // @author hi94740 | |
| // @version 1.0.0 | |
| // @description Intended to use with the VRChat integration for Home Assistant. | |
| // @match https://vrchat.com/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=vrchat.com | |
| // @require https://cdn.jsdelivr.net/npm/lodash@4.18.1/lodash.min.js | |
| // @connect homeassistant.local | |
| // @connect localhost | |
| // @connect * | |
| // @grant GM.xmlHttpRequest | |
| // ==/UserScript== | |
| try { | |
| ;(async () => { | |
| "use strict" | |
| const storageKey = "homeassistant" | |
| const getConfig = defaultValues => { | |
| const valuesJson = localStorage[storageKey] | |
| if (!valuesJson) return defaultValues | |
| try { | |
| return { ...defaultValues, ...JSON.parse(valuesJson) } | |
| } catch { | |
| return defaultValues | |
| } | |
| } | |
| const setConfig = values => { | |
| localStorage[storageKey] = JSON.stringify(values) | |
| } | |
| const config = getConfig({ | |
| token: "", | |
| baseUrl: "http://homeassistant.local:8123", | |
| alternativeBaseUrl: "", | |
| openInApp: false | |
| }) | |
| const assertPrompt = (message, defaultValue, errorMessage) => { | |
| const value = prompt(message, defaultValue) ?? defaultValue | |
| if (!value) throw new Error(errorMessage) | |
| return value | |
| } | |
| const configure = () => { | |
| config.baseUrl = assertPrompt( | |
| "Home Assistant URL", | |
| config.baseUrl, | |
| "Home Assistant URL is required to link VRChat user to Home Assistant page!" | |
| ) | |
| config.alternativeBaseUrl = | |
| prompt("Alternative Home Assistant URL (Optional)", config.alternativeBaseUrl) ?? | |
| config.alternativeBaseUrl | |
| config.token = assertPrompt( | |
| "Home Assistant access token", | |
| config.token, | |
| "Home Assistant access token is required to link VRChat user to Home Assistant page!" | |
| ) | |
| config.openInApp = confirm("Open in app?") | |
| setConfig(config) | |
| } | |
| if (!config.token) configure() | |
| const createMutationObserver = cb => { | |
| try { | |
| cb() | |
| } catch (e) { | |
| console.warn(e) | |
| } finally { | |
| const observer = new MutationObserver(_.debounce(cb, 500)) | |
| observer.observe(document.body, { | |
| attributes: true, | |
| childList: true, | |
| subtree: true | |
| }) | |
| return observer | |
| } | |
| } | |
| createMutationObserver(() => { | |
| if (document.querySelector(".ha-settings")) return | |
| const sortByButton = document.querySelector( | |
| 'button[aria-label="Expand Options"]' | |
| )?.parentElement | |
| if (sortByButton) { | |
| const haSettingsButton = sortByButton.cloneNode(true) | |
| haSettingsButton.classList.add("ha-settings") | |
| haSettingsButton.style.marginLeft = "8px" | |
| haSettingsButton.querySelector("button").innerText = "⚙ HA" | |
| haSettingsButton.addEventListener("click", () => { | |
| try { | |
| configure() | |
| } catch (e) { | |
| alert(e) | |
| throw e | |
| } | |
| location.reload() | |
| }) | |
| sortByButton.after(haSettingsButton) | |
| } | |
| }) | |
| const getUserId = s => s.match(/usr_(\w|-)+/)?.[0] | |
| const currentUserId = getUserId( | |
| ( | |
| await new Promise(res => { | |
| const observer = createMutationObserver(() => { | |
| const a = document.querySelector("div.leftbar a") | |
| if (a) { | |
| setTimeout(() => observer.disconnect()) | |
| res(a) | |
| } | |
| }) | |
| }) | |
| ).href | |
| ) | |
| let baseUrl | |
| const userIdMap = await Promise.any( | |
| [config.baseUrl, config.alternativeBaseUrl].map(async u => { | |
| if (!u) throw new Error("Home Assistant URL not provided.") | |
| const res = await GM.xmlHttpRequest({ | |
| url: new URL("/api/template", u), | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${config.token}`, | |
| "Content-Type": "application/json" | |
| }, | |
| data: JSON.stringify({ | |
| template: `{% set ns = namespace(m={}) %} | |
| {% for id in integration_entities("vrchat") %} | |
| {% if state_attr(id, "friend_of") and state_attr(id, "friend_of") == ${JSON.stringify(currentUserId)}%} | |
| {% set ns.m = combine(ns.m, {state_attr(id, "id"): device_id(id)}) %} | |
| {% endif %} | |
| {% endfor %} | |
| {{ ns.m | to_json }}` | |
| }) | |
| }) | |
| if (res.status !== 200) | |
| throw new Error("Error fetching user ids from Home Assistant.") | |
| const map = JSON.parse(res.responseText) | |
| if (!baseUrl) baseUrl = u | |
| return map | |
| }) | |
| ) | |
| const navigateBaseUrl = config.openInApp | |
| ? "homeassistant://navigate" | |
| : baseUrl | |
| const deviceUrl = id => | |
| new URL(`/config/devices/device/${id}`, navigateBaseUrl) | |
| const target = config.openInApp ? "_self" : "_blank" | |
| createMutationObserver(() => { | |
| document | |
| .querySelectorAll('div[aria-label="User Card"]') | |
| .forEach(userCard => { | |
| if (userCard.querySelector(".ha-link")) return | |
| const id = getUserId(userCard.querySelector("a").href) | |
| const el = document.createElement("a") | |
| el.innerText = "HA" | |
| el.className = "ha-link" | |
| el.target = target | |
| el.href = deviceUrl(userIdMap[id]) | |
| userCard.querySelector('div[aria-label="User Info"]').after(el) | |
| }) | |
| const shareButton = document.querySelector( | |
| 'svg[data-icon="share-from-square"]' | |
| )?.parentElement?.parentElement | |
| if (shareButton) { | |
| const haButton = shareButton.cloneNode(true) | |
| haButton.querySelector("button").innerText = "HA" | |
| haButton.addEventListener("click", () => | |
| open(deviceUrl(userIdMap[getUserId(location.pathname)]), target) | |
| ) | |
| shareButton.after(haButton) | |
| shareButton.remove() | |
| } | |
| }) | |
| })() | |
| } catch (e) { | |
| alert(e) | |
| throw e | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment