From bc3aa10ab248fe9f13e1b369460781d0b2d1b3f5 Mon Sep 17 00:00:00 2001 From: Vassili Minaev Date: Sun, 10 May 2026 02:26:53 -0600 Subject: [PATCH] Initial scaffolding: settings + manifest push Foundry v12 ESM module that pushes the world's PC actor manifest to a companion Frappe app via webhook. Structure: - scripts/main.js: registers settings on init, exposes API on ready, wires createActor/deleteActor/periodic hooks. All push operations gated to game.users.activeGM to avoid duplicate pushes from co-GMs. - scripts/settings.js: Frappe URL, shared secret, world ID, periodic interval. Secret uses `secret: true` for masked input but is still readable by all connected clients (documented in setting hint). - scripts/api.js: bridgeFetch helper with X-Bridge-Secret/-World headers, pushManifest, testConnection. - scripts/constants.js: MODULE_ID. Public API exposed at globalThis.seitimeBridge: seitimeBridge.testConnection() // round-trip empty manifest seitimeBridge.pushManifest() // push current world manifest dnd5e is declared as a related system in module.json. Snapshot push and End Session macro come in subsequent commits. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 4 ++ module.json | 25 ++++++++++++ scripts/api.js | 97 ++++++++++++++++++++++++++++++++++++++++++++ scripts/constants.js | 1 + scripts/main.js | 56 +++++++++++++++++++++++++ scripts/settings.js | 52 ++++++++++++++++++++++++ 6 files changed, 235 insertions(+) create mode 100644 .gitignore create mode 100644 module.json create mode 100644 scripts/api.js create mode 100644 scripts/constants.js create mode 100644 scripts/main.js create mode 100644 scripts/settings.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2694e12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.swp +*.log +node_modules/ diff --git a/module.json b/module.json new file mode 100644 index 0000000..3e41e19 --- /dev/null +++ b/module.json @@ -0,0 +1,25 @@ +{ + "id": "seitime-bridge", + "title": "Seitime Bridge", + "description": "Pushes dnd5e character data from Foundry VTT to a Seitime Frappe site at session end. Companion to the st Frappe app.", + "version": "0.1.0", + "compatibility": { + "minimum": "12", + "verified": "12", + "maximum": "13" + }, + "authors": [ + { "name": "Vassili" } + ], + "esmodules": [ + "scripts/main.js" + ], + "url": "https://git.vassi.li/vassili/seitime-bridge", + "manifest": "https://git.vassi.li/vassili/seitime-bridge/raw/branch/main/module.json", + "download": "https://git.vassi.li/vassili/seitime-bridge/archive/main.zip", + "relationships": { + "systems": [ + { "id": "dnd5e", "type": "system" } + ] + } +} diff --git a/scripts/api.js b/scripts/api.js new file mode 100644 index 0000000..59c8fce --- /dev/null +++ b/scripts/api.js @@ -0,0 +1,97 @@ +/** + * Outbound HTTP to the Frappe `st.api.foundry.*` endpoints. + * + * All calls are POST with `X-Bridge-Secret` and `X-Bridge-World` headers. + * Bodies are JSON. Errors include the Frappe server message when present. + */ + +import { MODULE_ID } from "./constants.js"; + +function getConfig() { + const baseUrl = (game.settings.get(MODULE_ID, "frappeBaseUrl") || "").replace(/\/+$/, ""); + const secret = game.settings.get(MODULE_ID, "sharedSecret") || ""; + const worldId = game.settings.get(MODULE_ID, "worldId") || game.world?.id || ""; + return { baseUrl, secret, worldId }; +} + +async function bridgeFetch(method, body) { + const { baseUrl, secret, worldId } = getConfig(); + if (!baseUrl) throw new Error("Bridge not configured: set Frappe Base URL in module settings."); + if (!secret) throw new Error("Bridge not configured: set Shared Secret in module settings."); + if (!worldId) throw new Error("Bridge not configured: World ID could not be determined."); + + const url = `${baseUrl}/api/method/st.api.foundry.${method}`; + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Bridge-Secret": secret, + "X-Bridge-World": worldId, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + let detail = ""; + try { + const json = await res.json(); + detail = json._server_messages || json.exception || JSON.stringify(json); + } catch { + detail = await res.text(); + } + throw new Error(`Bridge ${method} failed: ${res.status} ${res.statusText} — ${detail}`); + } + return await res.json(); +} + +/** + * Produce a player display name for an actor by inspecting Foundry's + * ownership map. Returns the first non-GM user with OWNER permission, or + * null if none exists. The Frappe side reads this from `_player_name` + * during snapshot extraction (see `_actor_player_name` in foundry.py). + */ +function resolvePlayerName(actor) { + const ownership = actor.ownership || {}; + for (const [userId, level] of Object.entries(ownership)) { + if (userId === "default") continue; + if (level < CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER) continue; + const user = game.users.get(userId); + if (user && !user.isGM) return user.name; + } + return null; +} + +function buildManifest() { + return game.actors + .filter((a) => a.type === "character") + .map((a) => ({ + id: a.id, + name: a.name, + player: resolvePlayerName(a), + })); +} + +export async function pushManifest() { + const actors = buildManifest(); + console.log(`[${MODULE_ID}] pushing manifest with ${actors.length} actor(s)`); + const result = await bridgeFetch("receive_actor_index", { actors }); + console.log(`[${MODULE_ID}] manifest result:`, result); + return result; +} + +/** + * Round-trip an empty manifest push to verify URL, secret, world ID, and + * Frappe-side enablement. Surfaces the result in the Foundry UI. + */ +export async function testConnection() { + console.log(`[${MODULE_ID}] testing connection...`); + try { + const result = await bridgeFetch("receive_actor_index", { actors: [] }); + const received = result?.message?.received ?? 0; + ui.notifications.info(`Seitime Bridge OK — server accepted ${received} actor(s).`); + return result; + } catch (err) { + ui.notifications.error(`Seitime Bridge: ${err.message}`); + throw err; + } +} diff --git a/scripts/constants.js b/scripts/constants.js new file mode 100644 index 0000000..0bb0e38 --- /dev/null +++ b/scripts/constants.js @@ -0,0 +1 @@ +export const MODULE_ID = "seitime-bridge"; diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..a3f41e5 --- /dev/null +++ b/scripts/main.js @@ -0,0 +1,56 @@ +/** + * Entry point for seitime-bridge. Registers settings, wires hooks, and + * exposes the public API on `globalThis.seitimeBridge` for use from + * Foundry macros and the F12 console. + * + * All push operations are gated to the primary connected GM + * (`game.users.activeGM`) so that worlds with multiple GMs don't + * duplicate webhook calls. + */ + +import { MODULE_ID } from "./constants.js"; +import { registerSettings } from "./settings.js"; +import { pushManifest, testConnection } from "./api.js"; + +let manifestTimerId = null; + +function isPrimaryGM() { + return game.user.isGM && game.users.activeGM?.id === game.user.id; +} + +function safePushManifest(reason) { + if (!isPrimaryGM()) return; + pushManifest().catch((err) => { + console.warn(`[${MODULE_ID}] manifest push (${reason}) failed:`, err); + }); +} + +Hooks.once("init", () => { + registerSettings(); +}); + +Hooks.once("ready", () => { + const mod = game.modules.get(MODULE_ID); + const api = { pushManifest, testConnection }; + mod.api = api; + globalThis.seitimeBridge = api; + + console.log(`[${MODULE_ID}] ready. Call seitimeBridge.testConnection() to verify config.`); + + safePushManifest("ready"); + + const intervalMin = game.settings.get(MODULE_ID, "manifestSyncIntervalMinutes"); + if (intervalMin > 0) { + manifestTimerId = setInterval(() => safePushManifest("periodic"), intervalMin * 60 * 1000); + } +}); + +Hooks.on("createActor", (actor) => { + if (actor.type !== "character") return; + safePushManifest("createActor"); +}); + +Hooks.on("deleteActor", (actor) => { + if (actor.type !== "character") return; + safePushManifest("deleteActor"); +}); diff --git a/scripts/settings.js b/scripts/settings.js new file mode 100644 index 0000000..b40f6f7 --- /dev/null +++ b/scripts/settings.js @@ -0,0 +1,52 @@ +/** + * Module settings registration. + * + * Note on `sharedSecret`: Foundry stores world-scope settings in a place + * readable by all connected clients, not just GMs. A player with browser + * console access can read the value. This is acceptable here because the + * secret only authorizes pushing dnd5e actor data — which players already + * have access to through normal play — and the Frappe side enforces all + * other authorization. Rotate the secret if you stop trusting a player. + */ + +import { MODULE_ID } from "./constants.js"; + +export function registerSettings() { + game.settings.register(MODULE_ID, "frappeBaseUrl", { + name: "Frappe Base URL", + hint: "Base URL of your Seitime Frappe site, e.g. https://seitime.vassi.li (no trailing slash).", + scope: "world", + config: true, + type: String, + default: "", + }); + + game.settings.register(MODULE_ID, "sharedSecret", { + name: "Shared Secret", + hint: "Bridge secret — must match Foundry Settings → Shared Secret on the Frappe side. Readable by any connected client; rotate if compromised.", + scope: "world", + config: true, + type: String, + default: "", + secret: true, + }); + + game.settings.register(MODULE_ID, "worldId", { + name: "World ID", + hint: "Identifier sent in the X-Bridge-World header. If you've configured Allowed World IDs on the Frappe side, this must be one of them. Leave blank to use the current Foundry world's id.", + scope: "world", + config: true, + type: String, + default: "", + }); + + game.settings.register(MODULE_ID, "manifestSyncIntervalMinutes", { + name: "Manifest Sync Interval (minutes)", + hint: "How often to push the actor manifest while the world is open. 0 disables periodic sync; on/create/delete pushes still fire.", + scope: "world", + config: true, + type: Number, + default: 15, + range: { min: 0, max: 120, step: 1 }, + }); +}