tea0s_multimuse-tracker/main.ts
Tea e433d52f15 1.12.2
# MultiMuse Obsidian Tracker 1.12.2

### Alphabetical pickers

- Muse lists (create scene, cached API fetch)
- Send as Muse character picker
- Insert @ mention member list

### API updates

- Create New Scene now registers via `POST /api/v1/scenes/create` (full characters + participants + scene path) instead of bare `threads/track`
- Check threads still uses `scenes/query`, but now passes `participants` from frontmatter so turn math uses your vault count
- `threads/track` kept only as a fallback when no scene path exists

### Removed Sync from Tracker

- Command removed — use Check Discord Threads Now / polling instead

### Turn tracking improvements

- Scene queries include `Participants` from frontmatter on every poll
- Optimistic `Replied? = true` after a successful Send as Muse

### Obsidian as source of truth toggle (Settings → Scene Properties):
- When on: edits to `Characters` or `Participants` push to MultiMuse via `scenes/create`, keyed by the thread id from `Link`
2026-06-13 15:34:27 -04:00

2703 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Plugin, PluginSettingTab, Setting, Notice, TFile, TFolder, TAbstractFile, App, requestUrl, Modal, Editor, MarkdownView, CachedMetadata, RequestUrlResponse, Platform, Scope } from 'obsidian';
interface MultimuseObsidianSettings {
botApiUrl: string; // Bot HTTP API URL (hidden from user UI for security)
pollInterval: number; // in minutes
scenesFolder: string;
basePath: string; // Obsidian Base file path (e.g., "RP Scenes/Roleplay Tracker.base")
ownerId: string; // DEPRECATED: Auto-synced from API key, kept for backward compatibility
userIds: string; // DEPRECATED: Auto-synced from API key, kept for backward compatibility
enabled: boolean;
apiKey: string; // API key for authentication (Bearer token)
cachedUserId: string; // Cached user ID from API key (auto-populated)
trackRoleplay: boolean; // Whether to add Roleplay property from folder path
trackIsActive: boolean; // Whether to add Is Active? property (defaulting to true)
/** When enabled, Characters + Participants frontmatter push to the API (keyed by Link thread id). */
obsidianSourceOfTruth: boolean;
}
/** Canonical API base URL (no trailing slash). Old IP:port configs are migrated to this on load. */
const MULTIMUSE_API_BASE = 'https://api.multimuse.app';
const DEFAULT_SETTINGS: MultimuseObsidianSettings = {
botApiUrl: MULTIMUSE_API_BASE,
pollInterval: 15,
scenesFolder: 'RP Scenes',
basePath: '',
ownerId: '', // Deprecated - auto-synced from API key
userIds: '', // Deprecated - auto-synced from API key
enabled: true,
apiKey: '',
cachedUserId: '', // Auto-populated from API key
trackRoleplay: true, // Default: extract Roleplay from folder path
trackIsActive: true, // Default: add Is Active? property
obsidianSourceOfTruth: false,
};
interface MuseInfo {
name: string;
trigger: string;
tags: string;
owner_id: number;
is_shared: boolean;
muse_id?: string | null; // Optional: for API calls (alias-safe); display always uses name
}
type FrontmatterData = Record<string, unknown>;
type FrontmatterValue = string | number | boolean | string[];
interface AuthMeResponse {
user_id?: string | number;
}
interface MusesListResponse {
muses?: MuseInfo[];
}
interface SceneState {
replied?: boolean | string | null;
is_from_character?: boolean | string | null;
timestamp?: string | null;
your_last_post?: string | null;
posted_since_count?: number | null;
}
interface SceneQueryResponse {
tracked?: boolean;
state?: SceneState | null;
}
interface TrackedThread {
thread_id: string | number;
muse_name?: string;
muse_names?: string[];
participants?: number | string;
scene_path?: string;
scene_paths?: string[];
guild_id?: string | number | null;
thread_name?: string;
}
interface TrackedThreadsResponse {
threads?: TrackedThread[];
}
interface GuildMember {
id: string;
username: string;
display_name: string;
}
interface GuildMembersResponse {
members?: GuildMember[];
}
interface ApiErrorBody {
message?: string;
}
interface MuseWrappersResolveResponse {
header?: string;
footer?: string;
muse_id?: string | null;
}
const DISCORD_MESSAGE_BUDGET = 2000;
/** Max wait for an in-flight poll to yield before posting (posts must not block on long polls). */
const POLL_YIELD_MS = 600;
/** Debounce vault modify/create handlers so autosave does not stack scene API calls. */
const SCENE_CHANGE_DEBOUNCE_MS = 2500;
/** Longer debounce when pushing metadata as source of truth (reduces Discord-side work). */
const SCENE_METADATA_DEBOUNCE_MS = 8000;
/** Minimum gap between scenes/query polls for the same file (avoids Discord history scans). */
const SCENE_QUERY_COOLDOWN_MS = 45000;
/** Pause between scenes during batch poll. */
const POLL_SCENE_DELAY_MS = 500;
/** Mirrors MultiMuse core/post_wrappers.compose_chunk_for_send (single-chunk Send as Muse). */
function composeChunkForSend(
chunk: string,
headerBody: string,
footerBody: string,
budget = DISCORD_MESSAGE_BUDGET
): string {
const museHeader = headerBody || '';
const museFooter = footerBody || '';
const reserved = museHeader.length + museFooter.length;
const icBudget = Math.max(0, budget - reserved);
const icSlice = chunk ? chunk.slice(0, icBudget) : '';
const parts: string[] = [];
if (museHeader) {
parts.push(museHeader);
}
if (icSlice) {
if (parts.length > 0 && !parts[parts.length - 1].endsWith('\n') && !icSlice.startsWith('\n')) {
parts.push('\n');
}
parts.push(icSlice);
} else if (parts.length === 0) {
parts.push('');
}
if (museFooter) {
const bodySoFar = parts.join('');
if (bodySoFar && !bodySoFar.endsWith('\n')) {
parts.push('\n');
}
parts.push(museFooter);
}
return parts.join('');
}
function canPreapplyWrappers(ic: string, header: string, footer: string): boolean {
const composed = composeChunkForSend(ic, header, footer);
return composed.length <= DISCORD_MESSAGE_BUDGET;
}
function parseJson<T>(text: string): T {
return JSON.parse(text) as T;
}
function getErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
const details = error as { message?: unknown; text?: unknown };
if (typeof details.message === 'string') return details.message;
if (typeof details.text === 'string') return details.text;
}
return 'Unknown error';
}
function frontmatterValueToString(value: unknown, fallback = ''): string {
if (value === null || value === undefined) return fallback;
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return fallback;
}
function sortNamesAlphabetically(names: string[]): string[] {
return [...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
function sortMusesAlphabetically(muses: MuseInfo[]): MuseInfo[] {
return [...muses].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
function metadataFingerprint(characters: string[], participants: number): string {
return `${sortNamesAlphabetically(characters).join('\x1f')}|${participants}`;
}
export default class MultimuseObsidian extends Plugin {
settings: MultimuseObsidianSettings;
pollIntervalId: number | null = null;
/** Bumped to cancel an in-flight poll when the user posts as muse (frees API for POST). */
pollGeneration = 0;
isPollRunning = false;
/** Resolves when the current poll batch finishes (Send as Muse only yields briefly). */
pollRunPromise: Promise<void> | null = null;
/** Serializes poll GETs so they do not stack many concurrent calls to the API host. */
private pollGetChain: Promise<void> = Promise.resolve();
/** Per-path debounce timers for vault scene modify/create (avoids API pile-up while editing). */
private sceneChangeDebounceTimers = new Map<string, number>();
/** Per-path cooldown: last scenes/query time (reduces Discord API load on the bot). */
private sceneQueryCooldownUntil = new Map<string, number>();
museCache: Map<string, MuseInfo[]> = new Map(); // user_id (as string) -> muses
recentlyCreatedFiles: Set<string> = new Set(); // Track recently created files to skip immediate checking
// Cache of last-seen "Is Active?" value per scene path so we only sync when the user actually toggles it.
// This prevents Obsidian from resurrecting scenes that StageHand or the bot have already ended/removed.
sceneActiveCache: Map<string, boolean> = new Map();
/** Last Characters+Participants fingerprint pushed per scene path (avoids API spam). */
sceneMetadataSyncCache: Map<string, string> = new Map();
/** True while the Create New Scene UI flow is running (blocks vault handlers from touching other notes). */
sceneCreationInProgress = false;
/** Swallows Enter between wizard modals so it cannot reach the editor. */
sceneCreationKeymapScope: Scope | null = null;
async onload() {
await this.loadSettings();
// Add settings tab
this.addSettingTab(new MultimuseObsidianSettingTab(this.app, this));
// Warm auth + muse cache in background so onload does not block the editor
if (this.settings.apiKey) {
void this.getUserIdFromApiKey().then(() => void this.syncMuses());
}
// Start polling if enabled (first poll deferred so startup stays responsive)
if (this.settings.enabled && this.settings.apiKey) {
this.startPolling({ deferInitialCheck: true });
}
// Add command to manually check now
this.addCommand({
id: 'check-discord-threads',
name: 'Check Discord Threads Now',
callback: () => {
void this.checkAllThreads({ force: true });
}
});
// Add command to toggle polling
this.addCommand({
id: 'toggle-polling',
name: 'Toggle Discord Polling',
callback: () => {
this.settings.enabled = !this.settings.enabled;
void this.saveSettings();
if (this.settings.enabled) {
this.startPolling();
new Notice('Discord polling enabled');
} else {
this.stopPolling();
new Notice('Discord polling disabled');
}
}
});
// Add command to create new scene (icon for mobile toolbar; ribbon on desktop)
this.addCommand({
id: 'create-scene',
name: 'Create New Scene',
icon: 'file-plus',
callback: () => {
void this.createNewScene();
}
});
if (!Platform.isMobile) {
this.addRibbonIcon('file-plus', 'Create New Scene', () => {
void this.createNewScene();
});
}
// First-time vault layout: scenes folder + Base (.base or .md) from settings
this.addCommand({
id: 'initialize-multimuse-workspace',
name: 'Initialize MultiMuse workspace',
callback: () => {
void this.initializeMultimuseWorkspace();
}
});
// Add command to insert Discord @ mention (guild members from Link property)
this.addCommand({
id: 'insert-mention',
name: 'Insert @ mention',
callback: async () => {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
new Notice('Open a scene note (with Link in frontmatter) and try again.');
return;
}
await this.insertMentionAtCursor(view);
}
});
// Watch for scene file creation/modification to check state
this.registerEvent(
this.app.vault.on('modify', async (file) => {
if (file instanceof TFile && file.extension === 'md') {
await this.handleSceneFileChange(file);
}
})
);
this.registerEvent(
this.app.vault.on('create', async (file) => {
if (file instanceof TFile && file.extension === 'md') {
await this.handleSceneFileChange(file);
}
})
);
// Add context menu items for scene editor
this.registerEvent(
this.app.workspace.on('editor-menu', (menu, editor, view) => {
menu.addItem((item) => {
item.setTitle('Insert @ mention')
.setIcon('at-sign')
.onClick(async () => {
if (view instanceof MarkdownView) {
await this.insertMentionAtCursor(view);
} else {
new Notice('This feature requires a markdown view.');
}
});
});
menu.addItem((item) => {
item.setTitle('Send as Muse')
.setIcon('message-square')
.onClick(async () => {
// Type guard: ensure view is MarkdownView, not MarkdownFileInfo
if (view instanceof MarkdownView) {
await this.sendSelectionAsMuse(editor, view);
} else {
new Notice('This feature requires a markdown view.');
}
});
});
})
);
}
onunload() {
this.stopPolling();
for (const timerId of this.sceneChangeDebounceTimers.values()) {
window.clearTimeout(timerId);
}
this.sceneChangeDebounceTimers.clear();
}
async loadSettings() {
const loaded = await this.loadData() as unknown;
const savedSettings = loaded && typeof loaded === 'object'
? loaded as Partial<MultimuseObsidianSettings>
: {};
this.settings = Object.assign({}, DEFAULT_SETTINGS, savedSettings);
// Ensure botApiUrl always uses default if empty or not set
if (!this.settings.botApiUrl || this.settings.botApiUrl.trim() === '') {
this.settings.botApiUrl = DEFAULT_SETTINGS.botApiUrl;
} else {
// Migrate old URLs to api.multimuse.app (non-breaking update after server/hostname change)
const u = this.settings.botApiUrl.trim();
const isOldUrl =
u.includes(':9056') ||
u === 'http://216.201.73.233:9056' ||
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/i.test(u);
if (isOldUrl && u !== MULTIMUSE_API_BASE) {
this.settings.botApiUrl = MULTIMUSE_API_BASE;
await this.saveSettings();
}
}
}
async saveSettings() {
await this.saveData(this.settings);
}
startPolling(opts?: { deferInitialCheck?: boolean }) {
this.stopPolling();
const intervalMs = this.settings.pollInterval * 60 * 1000;
this.pollIntervalId = window.setInterval(() => {
void this.checkAllThreads();
}, intervalMs);
if (!opts?.deferInitialCheck) {
void this.checkAllThreads();
} else {
window.setTimeout((): void => {
void this.checkAllThreads();
}, 45_000);
}
}
/** Serialize background GETs so they do not stack many concurrent calls to the API host. */
private enqueuePollGet<T>(fn: () => Promise<T>): Promise<T> {
const run = this.pollGetChain.then(() => fn());
this.pollGetChain = run.then((): void => undefined, (): void => undefined);
return run;
}
/** Cancel in-flight poll work and drop queued poll GETs so Send as Muse can proceed. */
private prioritizePostOverPolling(): void {
this.pollGeneration++;
this.pollGetChain = Promise.resolve();
}
/** Brief yield for the current poll loop to exit; never block posts on a full poll batch. */
private async yieldPollSlot(maxWaitMs = POLL_YIELD_MS): Promise<void> {
this.prioritizePostOverPolling();
if (!this.pollRunPromise) {
return;
}
await Promise.race([
this.pollRunPromise.catch((): void => undefined),
new Promise<void>((resolve) => window.setTimeout(resolve, maxWaitMs)),
]);
}
private async apiPostJson(
path: string,
body: Record<string, unknown>
): Promise<RequestUrlResponse> {
return await requestUrl({
url: `${this.getBotApiUrl()}${path}`,
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify({ ...body, fast: true }),
throw: false
});
}
stopPolling() {
if (this.pollIntervalId !== null) {
window.clearInterval(this.pollIntervalId);
this.pollIntervalId = null;
}
}
/**
* Get the bot API URL, falling back to default if not set.
* @returns Bot API URL string
*/
getBotApiUrl(): string {
const base = !this.settings.botApiUrl || this.settings.botApiUrl.trim() === ''
? DEFAULT_SETTINGS.botApiUrl
: this.settings.botApiUrl.trim();
return base.replace(/\/+$/, ''); // no trailing slash
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
/**
* Get headers for API requests, including Authorization header if API key is set.
* @returns Headers object for requestUrl
*/
getApiHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Add Authorization header if API key is configured
if (this.settings.apiKey && this.settings.apiKey.trim() !== '') {
headers['Authorization'] = `Bearer ${this.settings.apiKey.trim()}`;
}
return headers;
}
async registerScene(params: {
threadId: string;
userId: string;
scenePath: string;
characters: string[];
participants: number;
guildId?: string | null;
isActive?: boolean;
}): Promise<RequestUrlResponse> {
const characters = sortNamesAlphabetically(params.characters);
const body: Record<string, unknown> = {
thread_id: params.threadId,
user_id: params.userId,
scene_path: params.scenePath,
characters,
participants: params.participants,
is_active: params.isActive !== false,
};
if (params.guildId) {
body.guild_id = params.guildId;
}
return await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/scenes/create`,
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify(body),
throw: false,
});
}
/** @deprecated Use registerScene — kept as alias for internal callers migrating off threads/track-only flows. */
async trackThread(params: {
threadId: string;
userId: string;
museName: string;
participants: number;
scenePath?: string;
guildId?: string | null;
characters?: string[];
}): Promise<RequestUrlResponse> {
if (!params.scenePath) {
const body: Record<string, unknown> = {
thread_id: params.threadId,
user_id: params.userId,
muse_name: params.museName,
participants: params.participants,
};
if (params.guildId) body.guild_id = params.guildId;
return await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/threads/track`,
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify(body),
throw: false,
});
}
return this.registerScene({
threadId: params.threadId,
userId: params.userId,
scenePath: params.scenePath,
characters: params.characters?.length ? params.characters : [params.museName],
participants: params.participants,
guildId: params.guildId,
});
}
getFrontmatter(cache: CachedMetadata | null): FrontmatterData | null {
if (!cache?.frontmatter) {
return null;
}
return cache.frontmatter as FrontmatterData;
}
/**
* Handle API response errors, especially authentication errors.
* @param response The response object from requestUrl
* @param context Context string for logging
* @returns true if error was handled, false otherwise
*/
handleApiError(response: RequestUrlResponse, context: string): boolean {
if (response.status === 401) {
const errorMsg = 'API authentication failed. Please check your API key in settings.';
console.error(`[MultimuseObsidian] ${context}: ${errorMsg}`);
new Notice(errorMsg);
return true;
}
return false;
}
/**
* Get user ID from API key (cached or fetched fresh).
* @returns User ID string, or null if not available
*/
async getUserIdFromApiKey(): Promise<string | null> {
// If we have a cached user ID and API key is set, use it
if (this.settings.cachedUserId && this.settings.apiKey) {
return this.settings.cachedUserId;
}
// If no API key, can't get user ID
if (!this.settings.apiKey || this.settings.apiKey.trim() === '') {
return null;
}
// Fetch user ID from API
try {
const url = `${this.getBotApiUrl()}/api/v1/auth/me`;
const response = await requestUrl({
url: url,
method: 'GET',
headers: this.getApiHeaders()
});
if (response.status === 200) {
const data = parseJson<AuthMeResponse>(response.text);
const userId = data.user_id;
if (userId) {
// Cache the user ID
this.settings.cachedUserId = String(userId);
// Also update deprecated fields for backward compatibility
this.settings.ownerId = String(userId);
this.settings.userIds = '';
await this.saveSettings();
return this.settings.cachedUserId;
}
} else {
if (!this.handleApiError(response, 'getUserIdFromApiKey')) {
console.error(`[MultimuseObsidian] Failed to get user ID from API: ${response.status}`);
}
}
} catch (error) {
console.error('[MultimuseObsidian] Error fetching user ID from API key:', error);
}
return null;
}
/**
* Collect all configured user IDs (now just from API key).
* @returns Array of user ID numbers
*/
async getAllUserIds(): Promise<string[]> {
const userId = await this.getUserIdFromApiKey();
if (userId) {
return [userId];
}
// Fallback to old settings for backward compatibility
const userIdSet = new Set<string>();
if (this.settings.ownerId) {
const ownerIds = this.settings.ownerId.split(',').map(id => id.trim()).filter(id => id.length > 0);
ownerIds.forEach(id => userIdSet.add(id));
}
if (this.settings.userIds) {
const additionalIds = this.settings.userIds.split(',').map(id => id.trim()).filter(id => id.length > 0);
additionalIds.forEach(id => userIdSet.add(id));
}
return Array.from(userIdSet);
}
/**
* Get the primary user ID (from API key).
* @returns Primary user ID as string, or null if not configured
*/
async getPrimaryUserId(): Promise<string | null> {
return await this.getUserIdFromApiKey();
}
/**
* Fetch muse list for user IDs. Uses in-memory cache when available unless forceRefresh.
* backgroundRefresh: return cache immediately and syncMuses() in the background.
*/
async getMusesForUserIds(
userIds: string[],
opts?: { forceRefresh?: boolean; backgroundRefresh?: boolean }
): Promise<MuseInfo[]> {
const primaryId = userIds[0];
const cached = primaryId ? this.museCache.get(primaryId) : undefined;
if (!opts?.forceRefresh && cached && cached.length > 0) {
if (opts?.backgroundRefresh) {
void this.syncMuses();
}
return cached;
}
return this.fetchMusesListFromApi(userIds);
}
findMuseMatch(muses: MuseInfo[], selectedMuse: string): MuseInfo | undefined {
const selectedLower = selectedMuse.toLowerCase().trim();
return muses.find(m => {
const museLower = m.name.toLowerCase().trim();
return museLower === selectedLower
|| museLower.includes(selectedLower)
|| selectedLower.includes(museLower);
});
}
async resolveMuseWrappers(
threadId: string,
userId: string,
muse: MuseInfo,
museName: string
): Promise<{ header: string; footer: string }> {
const params = new URLSearchParams({
thread_id: threadId,
user_id: userId,
});
if (muse.muse_id) {
params.set('muse_id', muse.muse_id);
}
if (museName) {
params.set('muse_name', museName);
}
try {
const response = await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/muses/wrappers/resolve?${params.toString()}`,
method: 'GET',
headers: this.getApiHeaders(),
throw: false,
});
if (response.status !== 200) {
return { header: '', footer: '' };
}
const data = parseJson<MuseWrappersResolveResponse>(response.text);
return {
header: (data.header || '').trim() ? data.header! : '',
footer: (data.footer || '').trim() ? data.footer! : '',
};
} catch (error) {
console.debug('[MultimuseObsidian] resolveMuseWrappers:', error);
return { header: '', footer: '' };
}
}
async fetchMusesListFromApi(userIds: string[]): Promise<MuseInfo[]> {
if (!this.settings.apiKey || userIds.length === 0) {
return [];
}
const queryParam = `user_ids=${userIds.join(',')}`;
const url = `${this.getBotApiUrl()}/api/v1/muses/list?${queryParam}`;
const response = await requestUrl({
url: url,
method: 'GET',
headers: this.getApiHeaders()
});
if (response.status !== 200) {
if (!this.handleApiError(response, 'fetchMusesListFromApi')) {
console.error(`Failed to fetch muses: ${response.status} - ${response.text}`);
}
return [];
}
const data = parseJson<MusesListResponse>(response.text);
const muses: MuseInfo[] = sortMusesAlphabetically(data.muses || []);
for (const userId of userIds) {
this.museCache.set(String(userId), muses);
}
return muses;
}
async syncMuses(): Promise<void> {
/**Sync muse names from bot API for all configured user IDs.*/
if (!this.settings.apiKey) {
return;
}
try {
const userIds = await this.getAllUserIds();
if (userIds.length === 0) {
return;
}
const muses = await this.fetchMusesListFromApi(userIds);
if (muses.length > 0) {
console.log(`[MultimuseObsidian] Synced ${muses.length} muse(s) for ${userIds.length} user(s)`);
}
} catch (error) {
console.error('Error syncing muses:', error);
}
}
async checkAllThreads(opts?: { force?: boolean }) {
if (!this.settings.enabled || !this.settings.apiKey) {
return;
}
const run = this.checkAllThreadsViaBotApi(opts);
this.pollRunPromise = run;
try {
await run;
} finally {
if (this.pollRunPromise === run) {
this.pollRunPromise = null;
}
}
}
async checkAllThreadsViaBotApi(opts?: { force?: boolean }): Promise<void> {
/**Poll tracked thread paths from the API, then active vault scenes not in the tracker map.*/
if (!this.settings.apiKey) {
return;
}
const generation = this.pollGeneration;
this.isPollRunning = true;
try {
// Get primary user ID for linked scenes query
const primaryUserIdStr = await this.getPrimaryUserId();
if (!primaryUserIdStr) {
return;
}
const primaryUserId = parseInt(primaryUserIdStr);
if (isNaN(primaryUserId)) {
return;
}
// Get all Discord-side tracked threads from the current API.
const trackedUrl = `${this.getBotApiUrl()}/api/v1/threads/tracked?user_id=${primaryUserId}`;
const trackedResponse = await this.enqueuePollGet(() => requestUrl({
url: trackedUrl,
method: 'GET',
headers: this.getApiHeaders(),
throw: false
}));
if (trackedResponse.status !== 200) {
if (!this.handleApiError(trackedResponse, 'checkAllThreadsViaBotApi')) {
console.error(`[MultimuseObsidian] Failed to fetch tracked threads: ${trackedResponse.status} - ${trackedResponse.text}`);
}
return;
}
const trackedData = parseJson<TrackedThreadsResponse>(trackedResponse.text);
const trackedThreads = trackedData.threads || [];
const scenePathMap = this.buildScenePathMap(trackedThreads);
let updatedCount = 0;
// Phase 1 (API-first): one scene at a time so Send as Muse can jump the queue sooner.
for (const [scenePath, threadInfo] of scenePathMap) {
if (generation !== this.pollGeneration) {
break;
}
try {
if (this.recentlyCreatedFiles.has(scenePath)) {
continue;
}
const abstract = this.app.vault.getAbstractFileByPath(scenePath);
if (!(abstract instanceof TFile) || abstract.extension !== 'md') {
continue;
}
const file = abstract;
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter || !this.isSceneMarkedActive(frontmatter)) {
continue;
}
if (this.getCharacterNames(frontmatter).length === 0) {
continue;
}
const updated = await this.queryTrackedSceneByThreadId(
file,
threadInfo.thread_id,
primaryUserIdStr,
'checkAllThreadsViaBotApi',
opts
);
if (updated) {
updatedCount++;
}
if (generation !== this.pollGeneration) {
break;
}
await this.sleep(POLL_SCENE_DELAY_MS);
} catch (error) {
console.error(`Error checking tracked path ${scenePath}:`, error);
}
}
// Phase 2 (orphan fallback): active vault scenes with Link not in tracker path map.
if (generation === this.pollGeneration) {
for (const file of this.getActiveSceneFiles()) {
if (generation !== this.pollGeneration) {
break;
}
try {
if (scenePathMap.has(file.path)) {
continue;
}
if (this.recentlyCreatedFiles.has(file.path)) {
console.log(`[MultimuseObsidian] checkAllThreadsViaBotApi: Skipping recently created file: ${file.path}`);
continue;
}
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter) {
continue;
}
const link = frontmatter['Link'];
if (typeof link !== 'string') {
continue;
}
if (this.getCharacterNames(frontmatter).length === 0) {
continue;
}
if (!this.extractThreadIdFromUrl(link)) {
continue;
}
const updated = await this.querySceneState(file, opts);
if (updated) {
updatedCount++;
}
if (generation !== this.pollGeneration) {
break;
}
await this.sleep(POLL_SCENE_DELAY_MS);
} catch (error) {
console.error(`Error checking ${file.path}:`, error);
}
}
}
if (updatedCount > 0) {
new Notice(`Updated ${updatedCount} scene file(s)`);
}
} catch (error) {
console.error(`[MultimuseObsidian] Error checking all threads:`, error);
} finally {
if (generation === this.pollGeneration) {
this.isPollRunning = false;
}
}
}
buildScenePathMap(trackedThreads: TrackedThread[]): Map<string, TrackedThread> {
const scenePathMap = new Map<string, TrackedThread>();
for (const thread of trackedThreads) {
if (thread.scene_path) {
scenePathMap.set(thread.scene_path, thread);
}
for (const scenePath of thread.scene_paths || []) {
scenePathMap.set(scenePath, thread);
}
}
return scenePathMap;
}
async queryTrackedSceneByThreadId(
file: TFile,
threadId: string | number,
userId: string,
errorContext: string,
opts?: { force?: boolean }
): Promise<boolean> {
if (!opts?.force) {
const now = Date.now();
const cooldownUntil = this.sceneQueryCooldownUntil.get(file.path) ?? 0;
if (now < cooldownUntil) {
return false;
}
this.sceneQueryCooldownUntil.set(file.path, now + SCENE_QUERY_COOLDOWN_MS);
}
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
if (!cache || !frontmatter) {
return false;
}
const characters = this.getSortedCharacterNames(frontmatter);
if (characters.length === 0) {
return false;
}
const charactersParam = characters.join(',');
const participants = this.parseParticipantsFromFrontmatter(frontmatter);
const queryUrl = `${this.getBotApiUrl()}/api/v1/scenes/query?thread_id=${threadId}&characters=${encodeURIComponent(charactersParam)}&user_id=${userId}&participants=${participants}`;
const queryResponse = await this.enqueuePollGet(() => requestUrl({
url: queryUrl,
method: 'GET',
headers: this.getApiHeaders(),
throw: false
}));
if (queryResponse.status !== 200) {
if (queryResponse.status === 401) {
this.handleApiError(queryResponse, `${errorContext} - query scene`);
}
return false;
}
const queryData = parseJson<SceneQueryResponse>(queryResponse.text);
if (!queryData.tracked || !queryData.state) {
return false;
}
const state = queryData.state;
const repliedValue = state.replied ?? state.is_from_character;
if (repliedValue === undefined || repliedValue === null) {
return false;
}
const normalizedState = { ...state, replied: repliedValue };
return await this.updateSceneFromState(file, cache, normalizedState);
}
async querySceneState(file: TFile, opts?: { force?: boolean; skipMetadataSync?: boolean }): Promise<boolean> {
/**Query the tracker API for a specific scene's state and update frontmatter.*/
if (this.sceneCreationInProgress) {
return false;
}
if (!opts?.force) {
const now = Date.now();
const cooldownUntil = this.sceneQueryCooldownUntil.get(file.path) ?? 0;
if (now < cooldownUntil) {
return false;
}
this.sceneQueryCooldownUntil.set(file.path, now + SCENE_QUERY_COOLDOWN_MS);
}
// Skip checking if this file was recently created by the plugin
if (this.recentlyCreatedFiles.has(file.path)) {
console.log(`[MultimuseObsidian] querySceneState: Skipping check for recently created file: ${file.path}`);
return false;
}
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
if (!cache || !frontmatter) {
return false;
}
if (!this.isSceneMarkedActive(frontmatter)) {
return false;
}
const link = frontmatter['Link'];
if (typeof link !== 'string') {
return false;
}
const characters = this.getSortedCharacterNames(frontmatter);
if (characters.length === 0) {
return false;
}
const threadId = this.extractThreadIdFromUrl(link);
if (!threadId) {
return false;
}
if (this.settings.obsidianSourceOfTruth && !opts?.skipMetadataSync) {
await this.syncSceneMetadataToApi(file, { frontmatter });
}
try {
const charactersParam = characters.join(',');
// Use primary user ID for query
const primaryUserId = await this.getPrimaryUserId();
if (!primaryUserId) {
return false;
}
const url = `${this.getBotApiUrl()}/api/v1/scenes/query?thread_id=${threadId}&characters=${encodeURIComponent(charactersParam)}&user_id=${primaryUserId}&participants=${this.parseParticipantsFromFrontmatter(frontmatter)}`;
const response = await this.enqueuePollGet(() => requestUrl({
url: url,
method: 'GET',
headers: this.getApiHeaders(),
throw: false
}));
if (response.status !== 200) {
if (!this.handleApiError(response, `querySceneState for ${file.path}`)) {
console.error(`[MultimuseObsidian] API error for ${file.path}: ${response.status} - ${response.text}`);
}
return false;
}
const data = parseJson<SceneQueryResponse>(response.text);
if ((!data.tracked || !data.state) && this.settings.obsidianSourceOfTruth && !opts?.skipMetadataSync) {
await this.syncSceneMetadataToApi(file, { frontmatter });
const retryResponse = await this.enqueuePollGet(() => requestUrl({
url: url,
method: 'GET',
headers: this.getApiHeaders(),
throw: false,
}));
if (retryResponse.status === 200) {
const retryData = parseJson<SceneQueryResponse>(retryResponse.text);
if (retryData.tracked && retryData.state) {
const retryReplied = retryData.state.replied ?? retryData.state.is_from_character;
if (retryReplied !== undefined && retryReplied !== null) {
return await this.updateSceneFromState(file, cache, { ...retryData.state, replied: retryReplied });
}
}
}
}
// If scene is not tracked, don't update anything
if (!data.tracked || !data.state) {
console.log(`[MultimuseObsidian] Scene ${file.basename} is not tracked (tracked: ${data.tracked}, has state: ${!!data.state}) - skipping update`);
return false;
}
const state = data.state;
// API may return 'replied' and/or 'is_from_character' (same meaning)
const repliedValue = state.replied ?? state.is_from_character;
console.log(`[MultimuseObsidian] Scene ${file.basename} is tracked. State:`, JSON.stringify(state), 'repliedValue=', repliedValue);
// Only update if we have a definite replied value (replied or is_from_character)
if (repliedValue === undefined || repliedValue === null) {
console.log(`[MultimuseObsidian] Scene ${file.basename} has undefined/null replied value - skipping update`);
return false;
}
// Normalize state so updateSceneFromState sees a single field
const normalizedState = { ...state, replied: repliedValue };
return await this.updateSceneFromState(file, cache, normalizedState);
} catch (error) {
console.error(`[MultimuseObsidian] Error querying scene state for ${file.path}:`, error);
return false;
}
}
async updateSceneFromState(file: TFile, cache: CachedMetadata, state: SceneState): Promise<boolean> {
/**Update scene frontmatter from API state data.*/
let updated = false;
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter) {
return false;
}
// Use replied or is_from_character (API may send either)
const repliedRaw = state.replied ?? state.is_from_character;
if (repliedRaw === undefined || repliedRaw === null) {
console.log(`[MultimuseObsidian] ${file.basename}: state.replied/is_from_character is undefined/null - skipping update`);
return false;
}
// Check if the state looks suspicious (e.g., bot couldn't access channel)
// If timestamp is null and your_last_post is null, it might indicate the bot couldn't read the channel
// In this case, don't update the Replied? field to avoid incorrect updates
if (state.timestamp === null && state.your_last_post === null && state.posted_since_count === 0) {
console.log(`[MultimuseObsidian] ${file.basename}: State appears invalid (timestamp and your_last_post are null) - likely bot can't access channel. Skipping update to prevent incorrect "Replied?" value.`);
return false;
}
// Update Replied? field - normalize boolean values for comparison
const currentRepliedRaw = frontmatter['Replied?'];
// Handle both boolean and string values
const currentReplied = currentRepliedRaw === true || currentRepliedRaw === 'true' || currentRepliedRaw === 'True';
// true = you've replied (no need to reply), false = need to reply
const shouldBeReplied = repliedRaw === true || repliedRaw === 'true';
console.log(`[MultimuseObsidian] ${file.basename}: Current Replied?=${currentReplied}, API replied=${repliedRaw}, shouldBeReplied=${shouldBeReplied}`);
// Always apply API state so Replied? unchecks when someone replies back (your turn again)
if (currentReplied !== shouldBeReplied) {
console.log(`[MultimuseObsidian] ${file.basename}: Updated Replied? to ${shouldBeReplied}`);
await this.updateFrontmatter(file, 'Replied?', shouldBeReplied);
updated = true;
}
// Do NOT overwrite Participants from API/thread state. The plugin uses thread tracker
// for reply state only; Participants is always user-editable in frontmatter so the user
// can set it regardless of whether the scene is linked to a tracked thread.
return updated;
}
async handleSceneFileChange(file: TFile): Promise<void> {
/**Handle scene file creation/modification - debounced so autosave does not flood the API.*/
if (this.sceneCreationInProgress) {
return;
}
if (!file.path.startsWith(this.settings.scenesFolder + '/')) {
return;
}
if (!this.settings.apiKey || !this.settings.enabled) {
return;
}
if (this.recentlyCreatedFiles.has(file.path)) {
console.log(`[MultimuseObsidian] Skipping check for recently created file: ${file.path}`);
return;
}
const existing = this.sceneChangeDebounceTimers.get(file.path);
if (existing !== undefined) {
window.clearTimeout(existing);
}
const debounceMs = this.settings.obsidianSourceOfTruth
? SCENE_METADATA_DEBOUNCE_MS
: SCENE_CHANGE_DEBOUNCE_MS;
const timerId = window.setTimeout(() => {
this.sceneChangeDebounceTimers.delete(file.path);
void this.processSceneFileChange(file);
}, debounceMs);
this.sceneChangeDebounceTimers.set(file.path, timerId);
}
private async processSceneFileChange(file: TFile): Promise<void> {
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter) {
return;
}
await this.syncSceneActiveStatusToApi(file, cache);
if (!this.isSceneMarkedActive(frontmatter)) {
this.clearSceneSyncCaches(file.path);
return;
}
const prevFingerprint = this.sceneMetadataSyncCache.get(file.path);
await this.syncSceneMetadataToApi(file, cache);
const metadataPushed = this.sceneMetadataSyncCache.get(file.path) !== prevFingerprint
&& this.sceneMetadataSyncCache.has(file.path);
// Metadata push already hit the bot; skip an immediate query (reduces Discord API load).
if (metadataPushed) {
return;
}
const now = Date.now();
const cooldownUntil = this.sceneQueryCooldownUntil.get(file.path) ?? 0;
if (now < cooldownUntil) {
return;
}
this.sceneQueryCooldownUntil.set(file.path, now + SCENE_QUERY_COOLDOWN_MS);
try {
await this.querySceneState(file, { skipMetadataSync: true });
} catch (error) {
console.debug(`Error checking scene ${file.path}:`, error);
}
}
/**
* Sync the scene's "Is Active?" frontmatter to the MultiMuse API.
* When unchecked, the bot sets is_active=0 so the scene is removed from the tracker.
*
* IMPORTANT: The bot/database is the source of truth for is_active. Obsidian should only
* push changes when the user actually toggles "Is Active?" in the note. To avoid
* resurrecting scenes that were ended from StageHand or `/scene end`, this method
* compares the current frontmatter value against a cached last-seen value and only
* calls the API when it has changed.
*/
async syncSceneActiveStatusToApi(file: TFile, cache: { frontmatter?: Record<string, unknown> }): Promise<void> {
const link = cache.frontmatter?.['Link'];
if (!link) return; // No Link = not a tracked scene
const primaryUserId = await this.getPrimaryUserId();
if (!primaryUserId) return;
if (typeof link !== 'string') return;
const threadId = this.extractThreadIdFromUrl(link);
const raw = cache.frontmatter?.['Is Active?'];
const isActive = raw !== false && raw !== 'false';
// Only sync when the value has actually changed in this Obsidian session.
// - First time we see a file, record the value but do NOT push to the API.
// - Subsequent changes (true -> false, false -> true) are treated as user intent.
const prev = this.sceneActiveCache.get(file.path);
if (prev === undefined) {
this.sceneActiveCache.set(file.path, isActive);
// Do not call the API on first sight of a file; this avoids overwriting
// scenes that were ended or deactivated from StageHand while Obsidian was closed.
return;
}
if (prev === isActive) {
// No change in "Is Active?" — nothing to sync.
return;
}
try {
const body: Record<string, unknown> = {
scene_path: file.path,
user_id: primaryUserId,
is_active: isActive
};
if (threadId) body.thread_id = threadId;
const response = await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/scenes/update-active`,
method: 'POST',
headers: { ...this.getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (response.status === 200) {
this.sceneActiveCache.set(file.path, isActive);
if (!isActive) {
this.clearSceneSyncCaches(file.path);
console.log(`[MultimuseObsidian] Synced Is Active?=false for ${file.path} - removed from tracker`);
}
}
} catch (e) {
console.debug(`[MultimuseObsidian] Could not sync Is Active? for ${file.path}:`, e);
}
}
/**
* Sync Characters and/or Participants frontmatter to MultiMuse.
* When obsidianSourceOfTruth is on, pushes both via scenes/create (keyed by Link thread id).
* Otherwise only Participants changes are pushed (legacy behaviour).
*/
async syncSceneMetadataToApi(file: TFile, cache: { frontmatter?: Record<string, unknown> }): Promise<void> {
const link = cache.frontmatter?.['Link'];
if (!link || typeof link !== 'string') return;
const frontmatter = cache.frontmatter as FrontmatterData;
if (!this.isSceneMarkedActive(frontmatter)) {
return;
}
const primaryUserId = await this.getPrimaryUserId();
if (!primaryUserId) return;
const characters = this.getSortedCharacterNames(frontmatter);
if (characters.length === 0) return;
const participants = this.parseParticipantsFromFrontmatter(frontmatter);
const fingerprint = metadataFingerprint(characters, participants);
if (this.sceneMetadataSyncCache.get(file.path) === fingerprint) {
return;
}
const threadId = this.extractThreadIdFromUrl(link);
if (!threadId) return;
const threadInfo = this.extractThreadInfoFromUrl(link);
try {
if (this.settings.obsidianSourceOfTruth) {
const response = await this.registerScene({
threadId,
userId: primaryUserId,
scenePath: file.path,
characters,
participants,
guildId: threadInfo?.guildId ?? null,
isActive: true,
});
if (response.status === 200) {
this.sceneMetadataSyncCache.set(file.path, fingerprint);
console.debug(`[MultimuseObsidian] Synced scene metadata (source of truth) for ${file.path}`);
}
return;
}
const body: Record<string, unknown> = {
scene_path: file.path,
user_id: primaryUserId,
participants,
thread_id: threadId,
};
const response = await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/scenes/update-participants`,
method: 'POST',
headers: { ...this.getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
throw: false,
});
if (response.status === 200) {
this.sceneMetadataSyncCache.set(file.path, fingerprint);
console.debug(`[MultimuseObsidian] Synced Participants=${participants} for ${file.path}`);
}
} catch (e) {
console.debug(`[MultimuseObsidian] Could not sync scene metadata for ${file.path}:`, e);
}
}
getSceneFiles(): TFile[] {
const folder = this.app.vault.getAbstractFileByPath(this.settings.scenesFolder);
if (!folder) {
return [];
}
const files: TFile[] = [];
this.collectMarkdownFiles(folder, files);
return files;
}
/** True when the scene should be polled/synced. Explicit Is Active? = false always skips API work. */
isSceneMarkedActive(frontmatter: FrontmatterData): boolean {
const raw = frontmatter['Is Active?'];
return raw !== false && raw !== 'false';
}
private clearSceneSyncCaches(filePath: string): void {
this.sceneMetadataSyncCache.delete(filePath);
this.sceneQueryCooldownUntil.delete(filePath);
const debounceTimer = this.sceneChangeDebounceTimers.get(filePath);
if (debounceTimer !== undefined) {
window.clearTimeout(debounceTimer);
this.sceneChangeDebounceTimers.delete(filePath);
}
}
/** Scene markdown files under the scenes folder with Is Active? true (or untracked when toggle is off). */
getActiveSceneFiles(): TFile[] {
const active: TFile[] = [];
for (const file of this.getSceneFiles()) {
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter || !this.isSceneMarkedActive(frontmatter)) {
continue;
}
active.push(file);
}
return active;
}
/** Build a map of thread_id (from Link property) -> TFile for all scene files that have a valid Link. */
getExistingSceneLinksByThreadId(): Map<string, TFile> {
const map = new Map<string, TFile>();
for (const file of this.getSceneFiles()) {
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
const link = frontmatter?.['Link'];
if (!link || typeof link !== 'string') continue;
const threadId = this.extractThreadIdFromUrl(link);
if (threadId) map.set(threadId, file);
}
return map;
}
collectMarkdownFiles(fileOrFolder: TAbstractFile, files: TFile[]): void {
if (fileOrFolder instanceof TFile && fileOrFolder.extension === 'md') {
files.push(fileOrFolder);
} else if (fileOrFolder instanceof TFolder) {
for (const child of fileOrFolder.children) {
this.collectMarkdownFiles(child, files);
}
}
}
getCharacterNames(frontmatter: FrontmatterData): string[] {
const characters = frontmatter['Characters'];
if (!characters) {
return [];
}
// Handle both array and single value
if (Array.isArray(characters)) {
return characters
.map(c => frontmatterValueToString(c).trim())
.filter(c => c.length > 0);
} else if (typeof characters === 'string') {
// Handle comma-separated string
return characters.split(',').map(c => c.trim()).filter(c => c.length > 0);
} else if (typeof characters === 'number' || typeof characters === 'boolean') {
return [frontmatterValueToString(characters).trim()];
}
return [];
}
getSortedCharacterNames(frontmatter: FrontmatterData): string[] {
return sortNamesAlphabetically(this.getCharacterNames(frontmatter));
}
parseParticipantsFromFrontmatter(frontmatter: FrontmatterData): number {
const raw = frontmatter['Participants'];
const participants = typeof raw === 'number' && raw >= 1
? raw
: typeof raw === 'string'
? parseInt(raw, 10)
: 2;
if (isNaN(participants) || participants < 1) {
return 2;
}
return Math.min(participants, 99);
}
extractThreadIdFromUrl(url: string): string | null {
if (!url || typeof url !== 'string') {
return null;
}
// Discord URL formats:
// Thread URL: https://discord.com/channels/GUILD_ID/CHANNEL_ID/THREAD_ID (3 IDs - extract last)
// Channel/Thread URL: https://discord.com/channels/GUILD_ID/THREAD_ID (2 IDs - extract second)
// Also handle canary.discord.com
const match3 = url.match(/discord(?:app)?(?:canary)?\.com\/channels\/\d+\/\d+\/(\d+)/);
if (match3) {
return match3[1]; // Thread URL with 3 IDs
}
const match2 = url.match(/discord(?:app)?(?:canary)?\.com\/channels\/\d+\/(\d+)/);
if (match2) {
return match2[1]; // Channel/Thread URL with 2 IDs (thread ID is the channel ID)
}
return null;
}
async updateFrontmatter(file: TFile, key: string, value: FrontmatterValue): Promise<void> {
const content = await this.app.vault.read(file);
// Parse frontmatter
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
const match = content.match(frontmatterRegex);
if (!match) {
console.error(`[MultimuseObsidian] No frontmatter found in ${file.path}`);
return;
}
let frontmatterText = match[1];
const body = content.slice(match[0].length);
// Escape special regex characters in the key
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Format value for YAML (handle booleans, strings, etc.)
let formattedValue: string;
if (typeof value === 'boolean') {
formattedValue = value.toString(); // "true" or "false"
} else if (typeof value === 'string') {
formattedValue = value;
} else {
formattedValue = String(value);
}
// Update the key-value pair - match the key at the start of a line
// Handle both single-line and multi-line values
// Match any value after the colon (including true/false, "true"/"false", etc.)
const keyRegex = new RegExp(`^${escapedKey}:\\s*(.+)$`, 'gm');
const keyMatch = frontmatterText.match(keyRegex);
if (keyMatch) {
// Replace ALL occurrences of the key (in case there are duplicates)
// Always update to the new value
frontmatterText = frontmatterText.replace(keyRegex, `${key}: ${formattedValue}`);
} else {
// Add new key-value pair at the end
frontmatterText += `\n${key}: ${formattedValue}`;
}
// Reconstruct file content
const newContent = `---\n${frontmatterText}\n---\n${body}`;
await this.app.vault.modify(file, newContent);
}
// ========= NEW COMMAND METHODS =========
async createNewScene(): Promise<void> {
/**Create a new scene with muse selection, thread link, location, name, and participants.*/
if (this.sceneCreationInProgress) {
return;
}
if (!this.settings.apiKey) {
new Notice('API key must be configured in settings.');
return;
}
this.sceneCreationInProgress = true;
this.sceneCreationKeymapScope = new Scope(this.app.scope);
this.sceneCreationKeymapScope.register([], 'Enter', (evt) => {
evt.preventDefault();
return false;
});
this.app.keymap.pushScope(this.sceneCreationKeymapScope);
try {
// Flush the open note so Properties does not merge new-scene fields into it during the modal flow.
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView?.file) {
await activeView.save();
}
await this.runCreateNewSceneFlow();
} finally {
if (this.sceneCreationKeymapScope) {
this.app.keymap.popScope(this.sceneCreationKeymapScope);
this.sceneCreationKeymapScope = null;
}
this.sceneCreationInProgress = false;
}
}
async runCreateNewSceneFlow(): Promise<void> {
// 1) Get muses from bot API for all configured user IDs
let muses: MuseInfo[] = [];
try {
// Collect all user IDs (deduplicated) - now from API key
const userIds = await this.getAllUserIds();
if (userIds.length === 0) {
new Notice('Failed to get user ID from API key. Please check your API key in settings.');
return;
}
// Always use user_ids parameter for consistency with API
const queryParam = `user_ids=${userIds.join(',')}`;
const url = `${this.getBotApiUrl()}/api/v1/muses/list?${queryParam}`;
console.log(`[MultimuseObsidian] Fetching muses for ${userIds.length} user(s): ${userIds.join(', ')}`);
console.log(`[MultimuseObsidian] API URL: ${url}`);
const response = await requestUrl({
url: url,
method: 'GET',
headers: this.getApiHeaders()
});
if (response.status === 200) {
const data = parseJson<MusesListResponse>(response.text);
muses = data.muses || [];
console.log(`[MultimuseObsidian] Found ${muses.length} muse(s) from ${userIds.length} user(s)`);
if (muses.length > 0) {
const ownerIds = muses.map(m => m.owner_id);
const uniqueOwners = [...new Set(ownerIds)];
console.log(`[MultimuseObsidian] Muses from ${uniqueOwners.length} owner(s): ${uniqueOwners.join(', ')}`);
console.log(`[MultimuseObsidian] Muse names: ${muses.map(m => m.name).join(', ')}`);
}
} else {
if (!this.handleApiError(response, 'createNewScene - fetch muses')) {
console.error(`[MultimuseObsidian] API error: ${response.status} - ${response.text}`);
new Notice(`Failed to fetch muses: ${response.status}`);
}
return;
}
} catch (error) {
console.error('Error fetching muses:', error);
new Notice('Failed to fetch muses from bot API. Check your API URL and connection.');
return;
}
if (muses.length === 0) {
new Notice('No muses found. Make sure you have muses created in Discord.');
return;
}
muses.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
// 2) Select muse
const museOptions = muses.map(m => m.name);
const selectedMuseIndex = await this.showSuggester(museOptions, museOptions, 'Select a muse');
if (selectedMuseIndex === null || selectedMuseIndex < 0) return;
const selectedMuse = muses[selectedMuseIndex];
// 3) Get Discord thread/channel link
const threadUrl = await this.showInputPrompt('Enter Discord thread/channel URL');
if (!threadUrl) return;
const threadInfo = this.extractThreadInfoFromUrl(threadUrl);
if (!threadInfo) {
new Notice('Invalid Discord URL format.');
return;
}
// 4) Get location (RP folder) - pass muse name for context
console.log(`[MultimuseObsidian] createNewScene: About to select location for muse "${selectedMuse.name}"`);
const location = await this.selectSceneLocation(`muse "${selectedMuse.name}"`);
console.log(`[MultimuseObsidian] createNewScene: Selected location: ${location || 'null (cancelled)'}`);
if (!location) return;
// 5) Get scene name
const sceneName = await this.showInputPrompt('Enter scene name', `${selectedMuse.name} - Scene`);
if (!sceneName) return;
// 6) Get participants
const participantsStr = await this.showInputPrompt('Number of participants (default: 2)', '2');
const participants = parseInt(participantsStr) || 2;
// 7) Create scene file
const filePath = `${location}/${sceneName}.md`;
const frontmatter: Record<string, FrontmatterValue> = {
'Link': threadUrl,
'Characters': [selectedMuse.name],
'Participants': participants,
'Replied?': false,
'Created': new Date().toISOString().split('T')[0],
};
// Add Roleplay property if enabled
if (this.settings.trackRoleplay) {
const roleplay = this.extractRoleplayFromPath(location);
if (roleplay) {
frontmatter['Roleplay'] = roleplay;
}
}
// Add Is Active? property if enabled
if (this.settings.trackIsActive) {
frontmatter['Is Active?'] = true;
}
const frontmatterLines = ['---'];
for (const [key, value] of Object.entries(frontmatter)) {
if (Array.isArray(value)) {
frontmatterLines.push(`${key}:`);
for (const item of value) {
frontmatterLines.push(` - ${item}`);
}
} else {
frontmatterLines.push(`${key}: ${value}`);
}
}
frontmatterLines.push('---');
frontmatterLines.push('');
const content = frontmatterLines.join('\n');
// Ensure all folders in the path exist (create recursively)
const segs = location.split("/").filter(Boolean);
let currentPath = segs[0];
// Ensure root folder exists
let abs = this.app.vault.getAbstractFileByPath(currentPath);
if (!abs) {
await this.app.vault.createFolder(currentPath);
}
// Create nested folders
for (let i = 1; i < segs.length; i++) {
currentPath += "/" + segs[i];
const folder = this.app.vault.getAbstractFileByPath(currentPath);
if (!folder) {
await this.app.vault.createFolder(currentPath);
}
}
// Create file
const createdFile = await this.app.vault.create(filePath, content);
// Mark this file as recently created to skip immediate checking
this.recentlyCreatedFiles.add(filePath);
// Remove from the set after 60 seconds (enough time for the scene to be registered and settled with the API)
window.setTimeout(() => {
this.recentlyCreatedFiles.delete(filePath);
console.log(`[MultimuseObsidian] Removed ${filePath} from recently created files - will now be checked by polling`);
}, 60000);
// 8) Link the vault scene to the current Discord-side thread tracker.
try {
console.debug(`Tracking scene: threadId=${threadInfo.threadId}, guildId=${threadInfo.guildId}, channelId=${threadInfo.channelId}, url=${threadUrl}`);
// Convert IDs to strings to avoid JavaScript number precision loss
// Discord IDs are larger than Number.MAX_SAFE_INTEGER, so we send them as strings
// Use primary user ID for thread tracking
const primaryUserId = await this.getPrimaryUserId();
if (!primaryUserId) {
new Notice('Failed to get user ID from API key. Please check your API key in settings.');
return;
}
const registerResponse = await this.registerScene({
threadId: threadInfo.threadId,
userId: primaryUserId,
scenePath: createdFile.path,
characters: [selectedMuse.name],
participants: participants,
guildId: threadInfo.guildId || null,
isActive: true,
});
console.debug(`Scene registration response: ${registerResponse.status} - ${registerResponse.text}`);
if (registerResponse.status === 200) {
this.sceneMetadataSyncCache.set(
createdFile.path,
metadataFingerprint([selectedMuse.name], participants)
);
// Add to Base if configured
try {
if (this.settings.basePath) {
await this.addSceneToBase(createdFile, frontmatter);
}
} catch (baseError) {
console.error('Error adding to Base (non-fatal):', baseError);
}
new Notice(`Scene created: ${sceneName}`);
await this.app.workspace.getLeaf(true).openFile(createdFile);
} else if (registerResponse.status === 401) {
// Authentication error - show helpful message
this.handleApiError(registerResponse, 'createNewScene - track thread');
new Notice('Scene created but failed to track with bot: Authentication failed. Check your API key.');
} else {
const errorText = registerResponse.text || 'Unknown error';
console.error(`Failed to track thread: ${registerResponse.status} - ${errorText}`);
new Notice(`Scene created but failed to track with bot: ${registerResponse.status}`);
}
} catch (error) {
// Log the full error for debugging
console.error('Error tracking thread:', error);
const errorMessage = getErrorMessage(error);
console.error('Error details:', errorMessage);
new Notice(`Scene created but failed to track with bot: ${errorMessage}`);
}
}
// ========= BASE INTEGRATION =========
/** Ensure each segment of `folderPath` exists under the vault root. */
async ensureFolderPathExists(folderPath: string): Promise<void> {
const normalized = folderPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalized) return;
const parts = normalized.split('/').filter((p) => p.length > 0);
let acc = '';
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
const existing = this.app.vault.getAbstractFileByPath(acc);
if (!existing) {
await this.app.vault.createFolder(acc);
}
}
}
/**
* YAML for a native Obsidian Base listing scene notes under the scenes folder,
* with columns aligned to MultiMuse frontmatter and plugin toggles.
*/
buildSceneBaseYaml(scenesFolder: string): string {
const folderLit = JSON.stringify(scenesFolder);
const lines: string[] = [
'# MultiMuse Tracker — generated Base (safe to edit in Obsidian)',
'filters:',
' and:',
' - file.ext == "md"',
` - file.inFolder(${folderLit})`,
];
if (this.settings.trackIsActive) {
lines.push(' - \'note["Is Active?"] == true\'');
}
lines.push(
'properties:',
' file.name:',
' displayName: Scene',
' file.path:',
' displayName: Path',
' Link:',
' displayName: Link',
' Characters:',
' displayName: Characters',
);
if (this.settings.trackRoleplay) {
lines.push(' Roleplay:', ' displayName: Roleplay');
}
lines.push(
' Participants:',
' displayName: Participants',
" 'Replied?':",
' displayName: Replied?',
);
if (this.settings.trackIsActive) {
lines.push(" 'Is Active?':", ' displayName: Is Active?');
}
lines.push(
' Created:',
' displayName: Created',
'views:',
' - type: table',
' name: Roleplay Tracker',
' order:',
' - file.name',
' - Link',
' - Characters',
);
if (this.settings.trackRoleplay) {
lines.push(' - Roleplay');
}
lines.push(
' - Participants',
" - 'Replied?'",
);
if (this.settings.trackIsActive) {
lines.push(" - 'Is Active?'");
}
lines.push(' - Created');
return lines.join('\n') + '\n';
}
/** Starter markdown table compatible with this plugin's markdown Base integration. */
buildMarkdownTrackerStub(): string {
return [
'# MultiMuse scene tracker',
'',
'Rows below are appended when you create or sync scenes if **Obsidian Base Path** points to this file.',
'',
'| Scene | Characters | Link | Participants | Replied? |',
'|-------|------------|------|--------------|----------|',
'',
].join('\n');
}
/**
* Create the configured scenes folder (if missing), then a new Base or markdown tracker file.
* If **Obsidian Base Path** is empty, creates `<Scenes Folder>/Roleplay Tracker.base` and saves that path.
*/
async initializeMultimuseWorkspace(): Promise<void> {
const scenesFolder = this.settings.scenesFolder
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '');
if (!scenesFolder) {
new Notice('Set Scenes Folder in Multimuse Tracker settings first.');
return;
}
const configuredBase = this.settings.basePath
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '');
let targetBasePath: string;
if (!configuredBase) {
targetBasePath = `${scenesFolder}/Roleplay Tracker.base`;
} else {
targetBasePath = configuredBase;
}
const ext = (targetBasePath.split('.').pop() || '').toLowerCase();
if (ext !== 'base' && ext !== 'md') {
new Notice('Obsidian Base Path must end in .base or .md, or leave it empty to create Roleplay Tracker.base under your scenes folder.');
return;
}
try {
await this.ensureFolderPathExists(scenesFolder);
const existing = this.app.vault.getAbstractFileByPath(targetBasePath);
if (existing) {
new Notice(`Already exists: ${targetBasePath}. Remove it or change Obsidian Base Path in settings, then run again.`);
return;
}
const parent = targetBasePath.includes('/')
? targetBasePath.slice(0, targetBasePath.lastIndexOf('/'))
: '';
if (parent) {
await this.ensureFolderPathExists(parent);
}
const body = ext === 'base'
? this.buildSceneBaseYaml(scenesFolder)
: this.buildMarkdownTrackerStub();
await this.app.vault.create(targetBasePath, body);
this.settings.basePath = targetBasePath;
await this.saveSettings();
new Notice(
ext === 'base'
? `Created Base and set path: ${targetBasePath}`
: `Created markdown tracker and set path: ${targetBasePath}`,
);
} catch (error) {
console.error('[MultimuseObsidian] initializeMultimuseWorkspace:', error);
new Notice(`Could not initialize workspace: ${getErrorMessage(error)}`);
}
}
async addSceneToBase(file: TFile, frontmatter: FrontmatterData): Promise<void> {
/**Add scene to Obsidian Base with characters as variables.*/
if (!this.settings.basePath) return;
try {
const baseFile = this.app.vault.getAbstractFileByPath(this.settings.basePath);
if (!baseFile || !(baseFile instanceof TFile)) {
return;
}
// Skip .base files - they use a special format that we shouldn't modify directly
// Base plugin should handle its own format
if (baseFile.extension === 'base') {
console.log(`[MultimuseObsidian] Skipping Base integration for .base file - use Base plugin UI to add records`);
return;
}
// Only handle .md files with markdown tables
if (baseFile.extension !== 'md') {
return;
}
// Read Base file
const baseContent = await this.app.vault.read(baseFile);
// Extract characters from frontmatter
const characters = this.getCharacterNames(frontmatter);
const link = frontmatterValueToString(frontmatter['Link']);
const participants = frontmatterValueToString(frontmatter['Participants'], '2');
const replied = frontmatterValueToString(frontmatter['Replied?'], 'false');
// Check if scene already exists in table
if (baseContent.includes(`| ${file.basename} |`)) {
// Scene already exists, skip
return;
}
// Add record as markdown table row
const recordLine = `| ${file.basename} | ${characters.join(', ')} | ${link} | ${participants} | ${replied} |\n`;
// Check if Base has table structure
if (baseContent.includes('|')) {
// Append to existing table
await this.app.vault.modify(baseFile, baseContent + recordLine);
} else {
// Create table structure
const tableHeader = '| Scene | Characters | Link | Participants | Replied? |\n|-------|------------|------|--------------|----------|\n';
await this.app.vault.modify(baseFile, tableHeader + recordLine);
}
} catch (error) {
console.error('Error adding scene to Base:', error);
}
}
// ========= HELPER METHODS =========
extractThreadInfoFromUrl(url: string): { threadId: string; guildId: string | null; channelId?: string } | null {
// Discord URL formats:
// Thread in channel: https://discord.com/channels/GUILD_ID/CHANNEL_ID/THREAD_ID
// Standalone thread: https://discord.com/channels/GUILD_ID/THREAD_ID (thread ID = channel ID)
const match3 = url.match(/discord(?:app)?(?:canary)?\.com\/channels\/(\d+)\/(\d+)\/(\d+)/);
if (match3) {
// 3-part URL: GUILD_ID/CHANNEL_ID/THREAD_ID - thread ID is the last one
return { guildId: match3[1], channelId: match3[2], threadId: match3[3] };
}
const match2 = url.match(/discord(?:app)?(?:canary)?\.com\/channels\/(\d+)\/(\d+)/);
if (match2) {
// 2-part URL: GUILD_ID/THREAD_ID - could be a standalone thread or channel
// In this case, the thread ID is the same as the channel ID
return { guildId: match2[1], threadId: match2[2] };
}
return null;
}
/**
* Extract the Roleplay property from a folder path.
* Roleplay is the first folder under the scenes folder.
* @param folderPath Full folder path (e.g., "RP Scenes/For The Greeks/Twin Flames")
* @returns Roleplay name (e.g., "For The Greeks") or null if path is invalid
*/
extractRoleplayFromPath(folderPath: string): string | null {
const RP_ROOT = this.settings.scenesFolder;
if (!folderPath.startsWith(RP_ROOT + "/")) {
return null;
}
// Remove RP_ROOT prefix and split
const relPath = folderPath.slice(RP_ROOT.length + 1);
const parts = relPath.split("/").filter(p => p.length > 0);
// First part is the Roleplay
return parts.length > 0 ? parts[0] : null;
}
async selectSceneLocation(context?: string): Promise<string | null> {
/**Select or create scene location folder.
* @param context Optional context string (e.g., muse name) to display in the prompt
*/
const RP_ROOT = this.settings.scenesFolder;
const dirSet = new Set<string>();
// Only scan the configured scenes folder (not vault.getFiles()).
for (const file of this.getSceneFiles()) {
const parts = file.path.split("/");
parts.pop();
if (parts.length >= 2) {
for (let i = 2; i <= parts.length; i++) {
const dirPath = parts.slice(0, i).join("/");
dirSet.add(dirPath);
}
}
}
let options = Array.from(dirSet)
.map((fullPath) => fullPath.slice(RP_ROOT.length + 1))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
// Always add the option to create a new folder path
options.push("+ New folder path…");
// Build title with context if provided
const suggesterTitle = context
? `Select location for ${context}`
: 'Select scene location';
console.log(`[MultimuseObsidian] selectSceneLocation: Showing ${options.length} options with title: ${suggesterTitle}`);
const choiceIndex = await this.showSuggester(options, options, suggesterTitle);
if (choiceIndex === null) {
console.log(`[MultimuseObsidian] selectSceneLocation: User cancelled or no selection`);
return null;
}
const choice = options[choiceIndex];
if (!choice) return null;
let relPath: string;
if (choice === "+ New folder path…") {
const promptMsg = context
? `Folder under "${RP_ROOT}" for ${context} (e.g. For the Greeks/Twin Flames)`
: `Folder under "${RP_ROOT}" (e.g. For the Greeks/Twin Flames)`;
const input = await this.showInputPrompt(promptMsg);
if (!input) return null;
relPath = input.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
} else {
relPath = choice;
}
return `${RP_ROOT}/${relPath}`;
}
/**
* Keep wizard keystrokes on the modal so Enter does not reach the markdown editor behind it.
*/
isolateWizardModal(modal: Modal, onEnter?: () => void): void {
modal.shouldRestoreSelection = false;
modal.modalEl.setAttr('tabindex', '-1');
modal.scope.register([], 'Enter', (evt) => {
onEnter?.();
evt.preventDefault();
return false;
});
const trapEnter = (evt: KeyboardEvent) => {
if (evt.key === 'Enter') {
evt.preventDefault();
evt.stopPropagation();
}
};
modal.modalEl.addEventListener('keydown', trapEnter, { capture: true });
modal.containerEl.addEventListener('keydown', trapEnter, { capture: true });
}
showSuggester<T>(items: string[], _values: T[], title?: string): Promise<number | null> {
return new Promise((resolve) => {
const plugin = this;
const modal = new (class extends Modal {
selectedIndex: number | null = null;
items: string[];
titleText: string;
constructor(app: App, items: string[], titleText?: string) {
super(app);
this.items = items;
this.titleText = titleText || 'Select an option';
}
onOpen() {
plugin.isolateWizardModal(this);
const { contentEl } = this;
contentEl.empty();
this.setTitle(this.titleText);
// If title contains context (e.g., "for muse X"), extract and display prominently
if (this.titleText.includes('for')) {
// Extract the muse name from the title
const match = this.titleText.match(/for (.+)$/);
if (match) {
const contextInfo = match[1];
const infoEl = contentEl.createEl('div', {
cls: 'multimuse-scene-context'
});
infoEl.createEl('strong', {
text: `Creating scene file for: ${contextInfo}`,
cls: 'multimuse-scene-context-title'
});
}
contentEl.createEl('p', {
text: 'Choose where to create the scene file:',
cls: 'multimuse-scene-location-desc'
});
}
let firstButton: HTMLButtonElement | null = null;
this.items.forEach((item, index) => {
const button = contentEl.createEl('button', {
text: item,
cls: ['mod-cta', 'multimuse-suggester-button']
});
if (!firstButton) {
firstButton = button;
}
button.onclick = () => {
this.selectedIndex = index;
this.close();
};
});
window.requestAnimationFrame(() => {
(firstButton ?? this.modalEl).focus();
});
}
onClose() {
resolve(this.selectedIndex);
}
})(this.app, items, title);
modal.open();
});
}
showInputPrompt(prompt: string, defaultValue?: string): Promise<string | null> {
return new Promise((resolve) => {
const plugin = this;
const modal = new (class extends Modal {
inputEl!: HTMLInputElement;
value: string | null = null;
cancelled = false;
constructor(app: App) {
super(app);
}
confirm(): void {
const trimmed = this.inputEl.value.trim();
this.value = trimmed || null;
this.close();
}
onOpen() {
plugin.isolateWizardModal(this, () => this.confirm());
this.setTitle(prompt);
const { contentEl } = this;
contentEl.empty();
this.inputEl = contentEl.createEl('input', {
type: 'text',
cls: 'multimuse-input',
});
this.inputEl.value = defaultValue || '';
new Setting(contentEl)
.addButton((btn) => btn
.setButtonText('Continue')
.setCta()
.onClick(() => this.confirm()))
.addButton((btn) => btn
.setButtonText('Cancel')
.onClick(() => {
this.cancelled = true;
this.close();
}));
window.requestAnimationFrame(() => {
this.inputEl.focus();
this.inputEl.select();
});
}
onClose() {
resolve(this.cancelled ? null : this.value);
}
})(this.app);
modal.open();
});
}
/**
* Fetch guild members from the bot API using guild_id from the current note's Link property,
* then insert a Discord mention <@userId> at the cursor (or replace selection).
*/
async insertMentionAtCursor(view: MarkdownView): Promise<void> {
const file = view.file;
if (!file) {
new Notice('No active file.');
return;
}
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter) {
new Notice('No frontmatter. Add a Link property (Discord thread URL) to this note.');
return;
}
const link = frontmatter['Link'];
if (typeof link !== 'string') {
new Notice('No Link property. Add the Discord thread URL to frontmatter to use @ mentions.');
return;
}
const threadInfo = this.extractThreadInfoFromUrl(link);
if (!threadInfo?.guildId) {
new Notice('Link is not a valid Discord channel URL (could not get server ID).');
return;
}
if (!this.settings.apiKey) {
new Notice('API key required in plugin settings to fetch server members.');
return;
}
let members: GuildMember[];
try {
const url = `${this.getBotApiUrl()}/api/v1/guilds/${threadInfo.guildId}/members`;
const response = await requestUrl({
url,
method: 'GET',
headers: this.getApiHeaders()
});
if (response.status !== 200) {
if (!this.handleApiError(response, 'insertMention - guild members')) {
new Notice('Could not load server members. Check API and that the bot is in the server.');
}
return;
}
const data = parseJson<GuildMembersResponse>(response.text);
members = data.members || [];
} catch (e) {
console.error('[MultimuseObsidian] insertMention fetch error:', e);
new Notice('Failed to fetch server members. Check connection and API key.');
return;
}
if (members.length === 0) {
new Notice('No members returned for this server. Bot may need Server Members intent.');
return;
}
const sortedMembers = [...members].sort((a, b) => {
const aName = a.display_name || a.username;
const bName = b.display_name || b.username;
return aName.localeCompare(bName, undefined, { sensitivity: 'base' });
});
const labels = sortedMembers.map(m => {
const d = m.display_name || m.username;
return m.username !== d ? `${d} (@${m.username})` : d;
});
const idx = await this.showSuggester(labels, sortedMembers, 'Insert @ mention choose user');
if (idx === null || idx < 0) return;
const chosen = sortedMembers[idx];
const mention = `<@${chosen.id}>`;
const editor = view.editor;
const sel = editor.getSelection();
if (sel) {
editor.replaceSelection(mention);
} else {
editor.replaceRange(mention, editor.getCursor());
}
new Notice(`Inserted @${chosen.display_name || chosen.username}`);
}
async sendSelectionAsMuse(editor: Editor, view: MarkdownView): Promise<void> {
/**Send selected text as a muse to Discord thread from frontmatter properties.*/
// Get selected text
const selection = editor.getSelection();
if (!selection || selection.trim().length === 0) {
new Notice('No text selected. Please select text to send as muse.');
return;
}
// Get active file
const file = view.file;
if (!file) {
new Notice('No active file found.');
return;
}
// Get frontmatter
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter) {
new Notice('File does not have frontmatter. Please add Link and Characters properties.');
return;
}
// Extract link and characters
const link = frontmatter?.['Link'];
if (typeof link !== 'string') {
new Notice('No Link property found in frontmatter. Please add a Discord thread URL.');
return;
}
const characters = this.getSortedCharacterNames(frontmatter);
if (characters.length === 0) {
new Notice('No Characters property found in frontmatter. Please add at least one character name.');
return;
}
// Extract thread ID from link
const threadId = this.extractThreadIdFromUrl(link);
if (!threadId) {
new Notice('Invalid Discord URL format in Link property.');
return;
}
// Select muse if multiple characters
let selectedMuse: string;
if (characters.length === 1) {
selectedMuse = characters[0];
} else {
const museIndex = await this.showSuggester(characters, characters);
if (museIndex === null || museIndex < 0) {
return;
}
selectedMuse = characters[museIndex];
}
new Notice(`Sending as ${selectedMuse}`);
const primaryUserId = this.settings.cachedUserId || await this.getPrimaryUserId();
if (!primaryUserId) {
new Notice('Failed to get user ID from API key. Please check your API key in settings.');
return;
}
const postBody: Record<string, unknown> = {
thread_id: threadId,
muse_name: selectedMuse,
content: selection.trim(),
user_id: primaryUserId,
};
void this.deliverPostAsMuse(selectedMuse, primaryUserId, threadId, postBody, file);
}
private async applyMuseWrappersToPost(
postBody: Record<string, unknown>,
threadId: string,
primaryUserId: string,
matchedMuse: MuseInfo,
selectedMuse: string
): Promise<void> {
const icRaw = typeof postBody.content === 'string' ? postBody.content : '';
const { header, footer } = await this.resolveMuseWrappers(
threadId,
primaryUserId,
matchedMuse,
selectedMuse
);
if ((header || footer) && canPreapplyWrappers(icRaw, header, footer)) {
postBody.content = composeChunkForSend(icRaw, header, footer);
postBody.wrappers_preapplied = true;
}
}
private async deliverPostAsMuse(
selectedMuse: string,
primaryUserId: string,
threadId: string,
postBody: Record<string, unknown>,
sceneFile?: TFile
): Promise<void> {
try {
await this.yieldPollSlot();
let muses = this.museCache.get(primaryUserId) ?? [];
let matchedMuse = this.findMuseMatch(muses, selectedMuse);
if (!matchedMuse?.muse_id) {
muses = await this.getMusesForUserIds([primaryUserId], { forceRefresh: true });
matchedMuse = this.findMuseMatch(muses, selectedMuse);
}
if (matchedMuse?.muse_id) {
postBody.muse_id = matchedMuse.muse_id;
}
if (matchedMuse) {
await this.applyMuseWrappersToPost(
postBody,
threadId,
primaryUserId,
matchedMuse,
selectedMuse
);
}
let response = await this.apiPostJson('/api/v1/messages/post', postBody);
if (response.status === 403 && !matchedMuse) {
muses = await this.getMusesForUserIds([primaryUserId], { forceRefresh: true });
matchedMuse = this.findMuseMatch(muses, selectedMuse);
if (matchedMuse) {
if (matchedMuse.muse_id) {
postBody.muse_id = matchedMuse.muse_id;
}
if (!postBody.wrappers_preapplied) {
await this.applyMuseWrappersToPost(
postBody,
threadId,
primaryUserId,
matchedMuse,
selectedMuse
);
}
response = await this.apiPostJson('/api/v1/messages/post', postBody);
} else if (muses.length > 0) {
new Notice(`Muse "${selectedMuse}" not found. Available: ${muses.map(m => m.name).join(', ')}`);
return;
}
}
if (response.status === 200 || response.status === 202) {
new Notice(`Message sent as ${selectedMuse}!`);
if (sceneFile) {
await this.updateFrontmatter(sceneFile, 'Replied?', true);
}
void this.syncMuses();
} else if (!this.handleApiError(response, 'sendSelectionAsMuse - post message')) {
const errorData = parseJson<ApiErrorBody>(response.text);
new Notice(`Failed to send message: ${errorData.message || response.status}`);
}
} catch (error) {
console.error('Error sending message:', error);
new Notice(`Failed to send message: ${getErrorMessage(error)}`);
}
}
}
class MultimuseObsidianSettingTab extends PluginSettingTab {
plugin: MultimuseObsidian;
constructor(app: App, plugin: MultimuseObsidian) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Polling and Paths')
.setHeading();
// Enable/Disable toggle
new Setting(containerEl)
.setName('Enable Polling')
.setDesc('Automatically check Discord threads for new replies')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enabled)
.onChange(async (value) => {
this.plugin.settings.enabled = value;
await this.plugin.saveSettings();
if (value) {
this.plugin.startPolling();
} else {
this.plugin.stopPolling();
}
}));
// Poll Interval
new Setting(containerEl)
.setName('Poll Interval (minutes)')
.setDesc('How often to check for new replies')
.addSlider(slider => slider
.setLimits(5, 60, 5)
.setValue(this.plugin.settings.pollInterval)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.pollInterval = value;
await this.plugin.saveSettings();
if (this.plugin.settings.enabled) {
this.plugin.stopPolling();
this.plugin.startPolling();
}
}));
// Scenes Folder
new Setting(containerEl)
.setName('Scenes Folder')
.setDesc('Folder containing your scene files')
.addText(text => text
.setPlaceholder('RP Scenes')
.setValue(this.plugin.settings.scenesFolder)
.onChange(async (value) => {
this.plugin.settings.scenesFolder = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Obsidian Base Path')
.setDesc('Path to your Obsidian Base file (e.g., "RP Scenes/Roleplay Tracker.base" or "RP Scenes/Tracker.md"). Leave empty and use **Initialize workspace** below to create `<Scenes Folder>/Roleplay Tracker.base`.')
.addText(text => text
.setPlaceholder('RP Scenes/Roleplay Tracker.base')
.setValue(this.plugin.settings.basePath)
.onChange(async (value) => {
this.plugin.settings.basePath = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Initialize workspace')
.setDesc('Create your scenes folder and tracker base from the paths above (or use **Initialize MultiMuse workspace** in the command palette).')
.addButton(button => button
.setButtonText('Initialize')
.setCta()
.onClick(() => {
void this.plugin.initializeMultimuseWorkspace();
}));
// Scene Properties Tracking
new Setting(containerEl)
.setName('Scene Properties')
.setHeading();
new Setting(containerEl)
.setName('Track Roleplay Property')
.setDesc(`Automatically add "Roleplay" property to scene files based on the selected folder (e.g., "For The Greeks" from "${this.plugin.settings.scenesFolder}/For The Greeks/Twin Flames")`)
.addToggle(toggle => toggle
.setValue(this.plugin.settings.trackRoleplay)
.onChange(async (value) => {
this.plugin.settings.trackRoleplay = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Track Is Active? Property')
.setDesc('Automatically add "Is Active?" property to new scene files (defaults to true)')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.trackIsActive)
.onChange(async (value) => {
this.plugin.settings.trackIsActive = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Obsidian as source of truth')
.setDesc('When enabled, edits to Characters and Participants in scene frontmatter are pushed to MultiMuse using the thread id from Link. Use this when you adjust muses or participant counts in Obsidian instead of Discord.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.obsidianSourceOfTruth)
.onChange(async (value) => {
this.plugin.settings.obsidianSourceOfTruth = value;
this.plugin.sceneMetadataSyncCache.clear();
await this.plugin.saveSettings();
}));
// Bot API URL - Hidden from user for security (uses hardcoded default)
// Removed from settings UI to prevent exposing server IP address
// API Key (required for authentication)
new Setting(containerEl)
.setName('API Key')
.setDesc('Your Multimuse API key for authentication. Generate one using /api generate in Discord DMs with the bot. Your user ID will be automatically detected from the API key.')
.addText(text => {
text.setPlaceholder('mm_...')
.setValue(this.plugin.settings.apiKey || '')
.inputEl.type = 'password';
text.onChange(async (value) => {
this.plugin.settings.apiKey = value.trim();
// Clear cached user ID when API key changes
this.plugin.settings.cachedUserId = '';
await this.plugin.saveSettings();
// Auto-fetch user ID from API key
if (value.trim()) {
const userId = await this.plugin.getUserIdFromApiKey();
if (userId) {
new Notice(`User ID detected: ${userId}`);
await this.plugin.syncMuses();
if (this.plugin.settings.enabled) {
this.plugin.stopPolling();
this.plugin.startPolling();
}
} else {
new Notice('Failed to get user ID from API key. Please check your API key.');
}
}
});
});
// Show cached user ID (read-only, for information)
if (this.plugin.settings.cachedUserId) {
new Setting(containerEl)
.setName('Detected User ID')
.setDesc(`Your Discord user ID (automatically detected from API key): ${this.plugin.settings.cachedUserId}`)
.addText(text => {
text.setValue(this.plugin.settings.cachedUserId);
text.inputEl.disabled = true;
});
}
// Sync Muses button
new Setting(containerEl)
.setName('Sync Muses')
.setDesc('Manually sync muse names from bot API')
.addButton(button => button
.setButtonText('Sync Now')
.setCta()
.onClick(async () => {
await this.plugin.syncMuses();
new Notice('Muses synced!');
}));
// Manual check button
new Setting(containerEl)
.setName('Manual Check')
.setDesc('Check Discord threads now')
.addButton(button => button
.setButtonText('Check Now')
.setCta()
.onClick(() => {
void this.plugin.checkAllThreads({ force: true });
new Notice('Checking Discord threads...');
}));
// Info section
containerEl.createEl('hr');
const infoEl = containerEl.createEl('div');
new Setting(infoEl)
.setName('How It Works')
.setHeading();
infoEl.createEl('p', { text: 'This plugin queries the Multimuse API to check if your scene files match tracked threads and updates the "Replied?" field.' });
infoEl.createEl('p', { text: '• Scenes are matched by Link (thread id) and Characters properties' });
infoEl.createEl('p', { text: '• Use **Check Discord Threads Now** (or polling) to refresh Replied? — true = you replied, false = your turn' });
infoEl.createEl('p', { text: '• Enable **Obsidian as source of truth** to push Characters and Participants changes from frontmatter to the tracker' });
infoEl.createEl('p', { text: '• Make sure your scene files have a "Link" field (Discord thread URL) and "Characters" field (array) in frontmatter' });
infoEl.createEl('p', { text: '• Uses Multimuse API - requires an API key for authentication' });
infoEl.createEl('p', { text: '• Generate an API key using /api generate in Discord DMs with the bot' });
infoEl.createEl('p', { text: '• Scenes are auto-detected when queried - no manual registration needed' });
}
}