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 <noreply@anthropic.com>
This commit is contained in:
commit
bc3aa10ab2
6 changed files with 235 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.DS_Store
|
||||
*.swp
|
||||
*.log
|
||||
node_modules/
|
||||
25
module.json
Normal file
25
module.json
Normal file
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
97
scripts/api.js
Normal file
97
scripts/api.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
1
scripts/constants.js
Normal file
1
scripts/constants.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const MODULE_ID = "seitime-bridge";
|
||||
56
scripts/main.js
Normal file
56
scripts/main.js
Normal file
|
|
@ -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");
|
||||
});
|
||||
52
scripts/settings.js
Normal file
52
scripts/settings.js
Normal file
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue