tea0s_multimuse-tracker/main.ts
2026-03-17 23:55:11 -04:00

2080 lines
69 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, MetadataCache, App, requestUrl, Modal, Editor, MarkdownView } 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)
}
/** 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
};
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
}
export default class MultimuseObsidian extends Plugin {
settings: MultimuseObsidianSettings;
pollIntervalId: number | null = null;
museCache: Map<string, MuseInfo[]> = new Map(); // user_id (as string) -> muses
recentlyCreatedFiles: Set<string> = new Set(); // Track recently created files to skip immediate checking
async onload() {
await this.loadSettings();
// Add settings tab
this.addSettingTab(new MultimuseObsidianSettingTab(this.app, this));
// Sync muses on startup (auto-fetch user ID from API key)
if (this.settings.apiKey) {
await this.getUserIdFromApiKey(); // Cache user ID
await this.syncMuses();
}
// Start polling if enabled
if (this.settings.enabled && this.settings.apiKey) {
this.startPolling();
}
// Add command to manually check now
this.addCommand({
id: 'check-discord-threads',
name: 'Check Discord Threads Now',
callback: () => {
this.checkAllThreads();
}
});
// Add command to toggle polling
this.addCommand({
id: 'toggle-polling',
name: 'Toggle Discord Polling',
callback: () => {
this.settings.enabled = !this.settings.enabled;
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
this.addCommand({
id: 'create-scene',
name: 'Create New Scene',
callback: () => {
this.createNewScene();
}
});
// Add command to sync from tracker
this.addCommand({
id: 'sync-from-tracker',
name: 'Sync from Tracker',
callback: () => {
this.syncFromTracker();
}
});
// 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();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
// 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() {
this.stopPolling(); // Clear any existing interval
if (!this.settings.ownerId) {
new Notice('Discord user ID not configured. Please set it in settings.');
return;
}
const intervalMs = this.settings.pollInterval * 60 * 1000;
this.pollIntervalId = window.setInterval(() => {
this.checkAllThreads();
}, intervalMs);
// Do an initial check
this.checkAllThreads();
}
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
}
/**
* 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;
}
/**
* 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: any, 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 = JSON.parse(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();
}
async syncMuses(): Promise<void> {
/**Sync muse names from bot API for all configured user IDs.*/
if (!this.settings.apiKey) {
return;
}
try {
// Collect all user IDs (deduplicated) - now from API key
const userIds = await this.getAllUserIds();
if (userIds.length === 0) {
return;
}
// Build query string - always use user_ids parameter for consistency
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) {
const data = JSON.parse(response.text);
const muses: MuseInfo[] = data.muses || [];
// Cache all muses for each user ID (API already returns all accessible muses for all provided user IDs)
// Since the API returns muses that are either owned by or shared with any of the user IDs,
// we cache all of them for each user ID so they're available when needed
// Keep user IDs as strings to avoid precision loss with large Discord IDs
for (const userId of userIds) {
this.museCache.set(String(userId), muses);
}
console.log(`[MultimuseObsidian] Synced ${muses.length} muse(s) for ${userIds.length} user(s)`);
} else {
if (!this.handleApiError(response, 'syncMuses')) {
console.error(`Failed to sync muses: ${response.status} - ${response.text}`);
}
}
} catch (error) {
console.error('Error syncing muses:', error);
}
}
async checkAllThreads() {
if (!this.settings.enabled || !this.settings.apiKey) {
return;
}
await this.checkAllThreadsViaBotApi();
}
async checkAllThreadsViaBotApi(): Promise<void> {
/**Check all scene files by querying the tracker API for linked scenes in batch.*/
if (!this.settings.apiKey) {
return;
}
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 linked scenes from the API
const linkedUrl = `${this.getBotApiUrl()}/api/v1/scenes/linked?user_id=${primaryUserId}`;
const linkedResponse = await requestUrl({
url: linkedUrl,
method: 'GET',
headers: this.getApiHeaders()
});
if (linkedResponse.status !== 200) {
if (!this.handleApiError(linkedResponse, 'checkAllThreadsViaBotApi')) {
console.error(`[MultimuseObsidian] Failed to fetch linked scenes: ${linkedResponse.status} - ${linkedResponse.text}`);
}
return;
}
const linkedData = JSON.parse(linkedResponse.text);
const linkedThreads = linkedData.linked_threads || [];
if (linkedThreads.length === 0) {
return;
}
// Create a map of scene_path -> thread info for quick lookup
const scenePathMap = new Map<string, any>();
for (const thread of linkedThreads) {
if (thread.scene_path) {
scenePathMap.set(thread.scene_path, thread);
}
}
// Get all scene files
const sceneFiles = this.getSceneFiles();
let updatedCount = 0;
// Process each scene file
for (const file of sceneFiles) {
try {
// Skip checking if this file was recently created by the plugin
if (this.recentlyCreatedFiles.has(file.path)) {
console.log(`[MultimuseObsidian] checkAllThreadsViaBotApi: Skipping recently created file: ${file.path}`);
continue;
}
const cache = this.app.metadataCache.getFileCache(file);
if (!cache || !cache.frontmatter) {
continue;
}
// Skip if scene is marked as inactive
const isActive = cache.frontmatter['Is Active?'];
if (isActive === false || isActive === 'false') {
continue;
}
// Check if this scene is in the linked threads
const threadInfo = scenePathMap.get(file.path);
if (!threadInfo) {
// Scene not linked, try querying by thread_id and characters
const link = cache.frontmatter['Link'];
if (!link) continue;
const characters = this.getCharacterNames(cache.frontmatter);
if (characters.length === 0) continue;
const threadId = this.extractThreadIdFromUrl(link);
if (!threadId) continue;
// Query this scene individually
const updated = await this.querySceneState(file);
if (updated) {
updatedCount++;
}
} else {
// Scene is linked - query its state
const characters = threadInfo.characters || [];
if (characters.length === 0) continue;
const charactersParam = Array.isArray(characters) ? characters.join(',') : characters;
// Use primary user ID for query
const primaryUserId = await this.getPrimaryUserId();
if (!primaryUserId) {
continue;
}
const queryUrl = `${this.getBotApiUrl()}/api/v1/scenes/query?thread_id=${threadInfo.thread_id}&characters=${encodeURIComponent(charactersParam)}&user_id=${primaryUserId}`;
const queryResponse = await requestUrl({
url: queryUrl,
method: 'GET',
headers: this.getApiHeaders()
});
if (queryResponse.status === 200) {
const queryData = JSON.parse(queryResponse.text);
if (queryData.tracked && queryData.state) {
const state = queryData.state;
const updated = await this.updateSceneFromState(file, cache, state);
if (updated) {
updatedCount++;
}
}
} else {
// Log auth errors but don't spam for every scene
if (queryResponse.status === 401) {
this.handleApiError(queryResponse, 'checkAllThreadsViaBotApi - query scene');
}
}
}
} 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);
}
}
async querySceneState(file: TFile): Promise<boolean> {
/**Query the tracker API for a specific scene's state and update frontmatter.*/
// 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);
if (!cache || !cache.frontmatter) {
return false;
}
// Skip if scene is marked as inactive
const isActive = cache.frontmatter['Is Active?'];
if (isActive === false || isActive === 'false') {
return false; // Skip inactive scenes
}
const link = cache.frontmatter['Link'];
if (!link) {
return false;
}
const characters = this.getCharacterNames(cache.frontmatter);
if (characters.length === 0) {
return false;
}
const threadId = this.extractThreadIdFromUrl(link);
if (!threadId) {
return false;
}
try {
// Query the tracker API for this specific scene
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}`;
const response = await requestUrl({
url: url,
method: 'GET',
headers: this.getApiHeaders()
});
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 = JSON.parse(response.text);
// 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: any, state: any): Promise<boolean> {
/**Update scene frontmatter from API state data.*/
let updated = 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 = cache.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 - scenes are auto-detected when queried, no registration needed.*/
// Only process files in the scenes folder
if (!file.path.startsWith(this.settings.scenesFolder + '/')) {
return;
}
// Only process if enabled and API key is set
if (!this.settings.apiKey || !this.settings.enabled) {
return;
}
// Skip checking if this file was recently created by the plugin
if (this.recentlyCreatedFiles.has(file.path)) {
console.log(`[MultimuseObsidian] Skipping check for recently created file: ${file.path}`);
return;
}
// Small delay to avoid checking during file creation
await new Promise(resolve => setTimeout(resolve, 1000));
const cache = this.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
// Sync Is Active? to the tracker so unchecking removes the scene from MultiMuse
await this.syncSceneActiveStatusToApi(file, cache);
// Sync Participants to the tracker so "your turn" / Replied? use the correct count
await this.syncSceneParticipantsToApi(file, cache);
}
// Query the scene state - this will auto-detect if it matches a tracked thread
try {
await this.querySceneState(file);
} catch (error) {
// Silently fail - don't spam errors for every file change
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.
*/
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;
const threadId = this.extractThreadIdFromUrl(String(link));
const raw = cache.frontmatter?.['Is Active?'];
const isActive = raw !== false && raw !== 'false';
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) {
if (!isActive) {
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 the scene's "Participants" frontmatter to the MultiMuse API.
* So the bot's "your turn" / Replied? logic uses the correct participant count.
*/
async syncSceneParticipantsToApi(file: TFile, cache: { frontmatter?: Record<string, unknown> }): Promise<void> {
const link = cache.frontmatter?.['Link'];
if (!link) return;
const primaryUserId = await this.getPrimaryUserId();
if (!primaryUserId) return;
const threadId = this.extractThreadIdFromUrl(String(link));
const raw = cache.frontmatter?.['Participants'];
const participants = typeof raw === 'number' && raw >= 1 ? raw : parseInt(String(raw || '2'), 10);
if (isNaN(participants) || participants < 1) return;
try {
const body: Record<string, unknown> = {
scene_path: file.path,
user_id: primaryUserId,
participants: participants
};
if (threadId) body.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)
});
if (response.status === 200) {
console.debug(`[MultimuseObsidian] Synced Participants=${participants} for ${file.path}`);
}
} catch (e) {
console.debug(`[MultimuseObsidian] Could not sync Participants 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;
}
/** 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 link = cache?.frontmatter?.['Link'];
if (!link || typeof link !== 'string') continue;
const threadId = this.extractThreadIdFromUrl(link);
if (threadId) map.set(threadId, file);
}
return map;
}
collectMarkdownFiles(fileOrFolder: any, files: TFile[]): void {
if (fileOrFolder instanceof TFile && fileOrFolder.extension === 'md') {
files.push(fileOrFolder);
} else if ('children' in fileOrFolder) {
for (const child of fileOrFolder.children) {
this.collectMarkdownFiles(child, files);
}
}
}
getCharacterNames(frontmatter: any): string[] {
const characters = frontmatter['Characters'];
if (!characters) {
return [];
}
// Handle both array and single value
if (Array.isArray(characters)) {
return characters.map(c => String(c).trim());
} else if (typeof characters === 'string') {
// Handle comma-separated string
return characters.split(',').map(c => c.trim()).filter(c => c.length > 0);
}
return [String(characters).trim()];
}
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: any): 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.settings.apiKey) {
new Notice('API key must be configured in settings.');
return;
}
// 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 = JSON.parse(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;
}
// 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];
// Small delay to ensure previous modal is fully closed
await new Promise(resolve => setTimeout(resolve, 100));
// 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;
}
// Small delay before opening location modal
await new Promise(resolve => setTimeout(resolve, 100));
// 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, any> = {
'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)
setTimeout(() => {
this.recentlyCreatedFiles.delete(filePath);
console.log(`[MultimuseObsidian] Removed ${filePath} from recently created files - will now be checked by polling`);
}, 60000);
// 8) Register with bot API and optionally create thread tracking
try {
// Register scene
// Log the extracted thread info for debugging
console.debug(`Registering 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 scene registration
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 requestUrl({
url: `${this.getBotApiUrl()}/api/v1/scenes/create`,
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify({
thread_id: threadInfo.threadId, // Send as string, let Python convert
user_id: primaryUserId, // Send as string
scene_path: createdFile.path,
characters: [selectedMuse.name],
participants: participants,
is_active: true,
guild_id: threadInfo.guildId || null // Send as string or null
})
});
console.debug(`Scene registration response: ${registerResponse.status} - ${registerResponse.text}`);
if (registerResponse.status === 200) {
// Scene registered successfully - proceed with optional operations
// Also create thread tracking (optional - don't fail if this errors)
// This may fail if bot can't access the thread, which is okay
try {
const trackBody: Record<string, unknown> = {
thread_id: threadInfo.threadId,
user_id: primaryUserId,
muse_name: selectedMuse.name,
participants: participants,
scene_path: createdFile.path,
guild_id: threadInfo.guildId || null
};
if (selectedMuse.muse_id) {
trackBody.muse_id = selectedMuse.muse_id;
}
const trackResponse = await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/threads/track`,
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify(trackBody)
});
if (trackResponse.status === 200) {
console.debug('Thread tracking created successfully');
} else {
console.error(`Thread tracking failed: ${trackResponse.status} - ${trackResponse.text}`);
}
} catch (trackError: any) {
// Thread tracking is optional - scene is already registered
// Only log if it's not a 400 (which means bot can't access thread - expected)
if (trackError?.status !== 400) {
console.error('Thread tracking error (non-fatal):', trackError);
}
}
// 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 - register scene');
new Notice('Scene created but failed to register with bot: Authentication failed. Check your API key.');
} else {
const errorText = registerResponse.text || 'Unknown error';
console.error(`Failed to register scene: ${registerResponse.status} - ${errorText}`);
new Notice(`Scene created but failed to register with bot: ${registerResponse.status}`);
}
} catch (error: any) {
// Log the full error for debugging
console.error('Error registering scene:', error);
const errorMessage = error?.message || error?.text || 'Unknown error';
console.error('Error details:', errorMessage);
// Only show error if scene registration itself failed
// Thread tracking errors are handled above and shouldn't reach here
if (error?.status === 400 && error?.message?.includes('threads/track')) {
// This is a thread tracking error that slipped through - ignore it
// Scene was already registered successfully
new Notice(`Scene created: ${sceneName}`);
await this.app.workspace.getLeaf(true).openFile(createdFile);
} else {
new Notice(`Scene created but failed to register with bot: ${errorMessage}`);
}
}
}
async syncFromTracker(): Promise<void> {
/**Sync scenes from bot tracker to Obsidian Base and create missing scene files.*/
if (!this.settings.apiKey) {
new Notice('API key must be configured in settings.');
return;
}
try {
// Get primary user ID for tracked threads query
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;
}
// Get tracked threads from bot
const response = await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/threads/tracked?user_id=${primaryUserId}`,
method: 'GET',
headers: this.getApiHeaders()
});
if (response.status !== 200) {
if (!this.handleApiError(response, 'syncFromTracker - fetch tracked threads')) {
console.error(`Failed to fetch tracked threads: ${response.status}`);
new Notice('Failed to fetch tracked threads from bot.');
}
return;
}
const data = JSON.parse(response.text);
const threads = data.threads || [];
if (threads.length === 0) {
new Notice('No tracked threads found.');
return;
}
// Cross-check: which thread IDs already have a scene in Obsidian (via Link property)?
const existingLinksByThreadId = this.getExistingSceneLinksByThreadId();
let createdCount = 0;
let updatedCount = 0;
for (const thread of threads) {
const threadId = String(thread.thread_id ?? '');
const museName = thread.muse_name;
const participants = thread.participants || 2;
const existingScenePath = thread.scene_path;
// 1) Scene already exists at the path the tracker knows about
if (existingScenePath) {
const existingFile = this.app.vault.getAbstractFileByPath(existingScenePath);
if (existingFile && existingFile instanceof TFile) {
if (this.settings.basePath) {
await this.updateBaseRecord(existingFile, thread);
}
updatedCount++;
continue;
}
}
// 2) No scene_path from tracker, but an Obsidian note already has this thread in its Link
const existingFileByLink = existingLinksByThreadId.get(threadId);
if (existingFileByLink) {
if (this.settings.basePath) {
await this.updateBaseRecord(existingFileByLink, thread);
}
updatedCount++;
continue;
}
// 3) Create new scene only when we have a scene_path from tracker and no existing note for this thread
// (If no scene_path, this thread isn't linked to Obsidian from the bot's side)
if (!existingScenePath) {
continue;
}
const guildId = thread.guild_id;
// Don't create URLs with guild_id=0 - that's invalid
const threadUrl = guildId && guildId !== '0' && guildId !== 0
? `https://discord.com/channels/${guildId}/${threadId}`
: null; // Can't create valid URL without guild_id
if (!threadUrl) {
console.debug(`Skipping thread ${threadId} - no valid guild_id`);
continue;
}
// Determine location (use scenes folder + default subfolder or prompt)
const location = await this.selectSceneLocation(`muse "${museName}"`);
if (!location) continue;
const sceneName = thread.thread_name || `${museName} - Thread ${threadId}`;
const filePath = `${location}/${sceneName}.md`;
const frontmatter: Record<string, any> = {
'Link': threadUrl,
'Characters': [museName],
'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 file = 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 30 seconds (enough time for the scene to be registered with the API)
setTimeout(() => {
this.recentlyCreatedFiles.delete(filePath);
}, 30000);
// Register with bot
// Use primary user ID for scene registration
const primaryUserId = await this.getPrimaryUserId();
if (!primaryUserId) {
console.error('Failed to get user ID from API key, skipping scene registration');
continue;
}
await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/scenes/create`,
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify({
thread_id: threadId, // Send as string to avoid precision loss
user_id: primaryUserId, // Send as string
scene_path: filePath,
characters: [museName],
participants: participants,
guild_id: guildId || null, // Include guild_id if available
})
});
// Add to Base if configured
if (this.settings.basePath) {
await this.addSceneToBase(file, frontmatter);
}
createdCount++;
}
new Notice(`Sync complete: ${createdCount} created, ${updatedCount} updated`);
} catch (error) {
console.error('Error syncing from tracker:', error);
new Notice('Error syncing from tracker. Check console for details.');
}
}
// ========= BASE INTEGRATION =========
async addSceneToBase(file: TFile, frontmatter: any): 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 = frontmatter['Link'] || '';
const participants = frontmatter['Participants'] || 2;
const replied = 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);
}
}
async updateBaseRecord(file: TFile, thread: any): Promise<void> {
/**Update existing Base record for a scene.*/
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
if (baseFile.extension === 'base') {
return;
}
// Only handle .md files
if (baseFile.extension !== 'md') {
return;
}
const baseContent = await this.app.vault.read(baseFile);
const sceneName = file.basename;
// Update the row for this scene
const lines = baseContent.split('\n');
const updatedLines = lines.map(line => {
if (line.includes(`| ${sceneName} |`)) {
const characters = [thread.muse_name];
const link = thread.guild_id && thread.guild_id !== '0'
? `https://discord.com/channels/${thread.guild_id}/${thread.thread_id}`
: `https://discord.com/channels/0/${thread.thread_id}`;
return `| ${sceneName} | ${characters.join(', ')} | ${link} | ${thread.participants || 2} | false |`;
}
return line;
});
await this.app.vault.modify(baseFile, updatedLines.join('\n'));
} catch (error) {
console.error('Error updating Base record:', 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 files = this.app.vault.getFiles();
const dirSet = new Set<string>();
for (const file of files) {
if (!file.path.startsWith(RP_ROOT + "/")) continue;
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}`;
}
showSuggester(items: string[], values: any[], title?: string): Promise<number | null> {
return new Promise((resolve) => {
// Create a modal with buttons
const modal = new (class extends Modal {
selectedIndex: number | null = null;
items: string[];
values: any[];
titleText: string;
constructor(app: App, items: string[], values: any[], titleText?: string) {
super(app);
this.items = items;
this.values = values;
this.titleText = titleText || 'Select an option';
// Set title in constructor to ensure it's set before modal opens
this.titleEl.textContent = this.titleText;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
// Ensure title is set (in case it was reset)
this.titleEl.textContent = 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', {
attr: {
style: 'margin-bottom: 20px; padding: 10px; background: var(--background-modifier-border); border-radius: 4px;'
}
});
infoEl.createEl('strong', {
text: `Creating scene file for: ${contextInfo}`,
attr: { style: 'color: var(--text-normal); font-size: 1.1em;' }
});
}
const descEl = contentEl.createEl('p', {
text: 'Choose where to create the scene file:',
attr: { style: 'margin-top: 10px; margin-bottom: 15px; color: var(--text-muted);' }
});
}
this.items.forEach((item, index) => {
const button = contentEl.createEl('button', {
text: item,
cls: 'mod-cta',
attr: { style: 'width: 100%; margin: 5px 0;' }
});
button.onclick = () => {
this.selectedIndex = index;
this.close();
};
});
}
onClose() {
resolve(this.selectedIndex);
}
})(this.app, items, values, title);
console.log(`[MultimuseObsidian] showSuggester: Opening modal with ${items.length} items, title: ${title || 'Select an option'}`);
// Use requestAnimationFrame to ensure modal opens after any previous modals are fully closed
requestAnimationFrame(() => {
modal.open();
});
});
}
showInputPrompt(prompt: string, defaultValue?: string): Promise<string | null> {
return new Promise((resolve) => {
// Use Obsidian's built-in modal for input
const modal = new (class extends Modal {
inputEl: HTMLInputElement;
value: string | null = null;
constructor(app: App, prompt: string, defaultValue?: string) {
super(app);
this.titleEl.textContent = prompt;
this.inputEl = this.contentEl.createEl('input', {
type: 'text',
value: defaultValue || ''
});
this.inputEl.style.width = '100%';
this.inputEl.onkeydown = (e) => {
if (e.key === 'Enter') {
this.value = this.inputEl.value;
this.close();
}
};
}
onOpen() {
this.inputEl.focus();
this.inputEl.select();
}
onClose() {
resolve(this.value);
}
})(this.app, prompt, defaultValue);
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);
if (!cache?.frontmatter) {
new Notice('No frontmatter. Add a Link property (Discord thread URL) to this note.');
return;
}
const link = cache.frontmatter['Link'];
if (!link) {
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: { id: string; username: string; display_name: string }[];
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 = JSON.parse(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 labels = members.map(m => {
const d = m.display_name || m.username;
return m.username !== d ? `${d} (@${m.username})` : d;
});
const idx = await this.showSuggester(labels, members, 'Insert @ mention choose user');
if (idx === null || idx < 0) return;
const chosen = members[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);
if (!cache || !cache.frontmatter) {
new Notice('File does not have frontmatter. Please add Link and Characters properties.');
return;
}
// Extract link and characters
const link = cache.frontmatter['Link'];
if (!link) {
new Notice('No Link property found in frontmatter. Please add a Discord thread URL.');
return;
}
const characters = this.getCharacterNames(cache.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;
}
// Get primary user ID
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;
}
// Select muse if multiple characters
let selectedMuse: string;
if (characters.length === 1) {
selectedMuse = characters[0];
} else {
// Show modal to select muse
const museIndex = await this.showSuggester(characters, characters);
if (museIndex === null || museIndex < 0) {
return; // User cancelled
}
selectedMuse = characters[museIndex];
}
// Verify muse exists and is accessible
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;
}
// Get muses to verify the selected muse is available
let muses: MuseInfo[] = [];
try {
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) {
const data = JSON.parse(response.text);
muses = data.muses || [];
} else {
if (!this.handleApiError(response, 'sendSelectionAsMuse - fetch muses')) {
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;
}
// Find matching muse (case-insensitive fuzzy) for validation and optional muse_id
const matchedMuse = muses.find(m => {
const museLower = m.name.toLowerCase().trim();
const selectedLower = selectedMuse.toLowerCase().trim();
return museLower === selectedLower || museLower.includes(selectedLower) || selectedLower.includes(museLower);
});
if (!matchedMuse) {
new Notice(`Muse "${selectedMuse}" not found or not accessible. Available muses: ${muses.map(m => m.name).join(', ')}`);
return;
}
// Build post body: use muse_id when available (alias-safe), keep muse_name for display/fallback
const postBody: Record<string, unknown> = {
thread_id: threadId,
muse_name: selectedMuse,
content: selection.trim(),
user_id: primaryUserId
};
if (matchedMuse.muse_id) {
postBody.muse_id = matchedMuse.muse_id;
}
// Post message via API
try {
const url = `${this.getBotApiUrl()}/api/v1/messages/post`;
const response = await requestUrl({
url: url,
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify(postBody)
});
if (response.status === 200) {
const data = JSON.parse(response.text);
new Notice(`Message sent as ${selectedMuse}!`);
} else {
if (!this.handleApiError(response, 'sendSelectionAsMuse - post message')) {
const errorData = JSON.parse(response.text);
new Notice(`Failed to send message: ${errorData.message || response.status}`);
}
}
} catch (error: any) {
console.error('Error sending message:', error);
const errorMessage = error?.message || error?.text || 'Unknown error';
new Notice(`Failed to send message: ${errorMessage}`);
}
}
}
class MultimuseObsidianSettingTab extends PluginSettingTab {
plugin: MultimuseObsidian;
constructor(app: App, plugin: MultimuseObsidian) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Multimuse Obsidian Settings' });
// 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")')
.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();
}));
// Scene Properties Tracking
containerEl.createEl('h3', { text: 'Scene Properties' });
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();
}));
// 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)
.setDisabled(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(() => {
this.plugin.checkAllThreads();
new Notice('Checking Discord threads...');
}));
// Info section
containerEl.createEl('hr');
const infoEl = containerEl.createEl('div');
infoEl.createEl('h3', { text: 'How It Works' });
infoEl.createEl('p', { text: 'This plugin queries the Multimuse API to check if your scene files match tracked threads and updates the "Replied?" and "Participants" fields.' });
infoEl.createEl('p', { text: '• Scenes are matched by Link (thread_id) and Characters properties' });
infoEl.createEl('p', { text: '• If scene matches a tracked thread: Updates Replied? only (true = you replied, false = need to reply). Participants is always editable in frontmatter.' });
infoEl.createEl('p', { text: '• If scene does not match: No updates (scene is not tracked)' });
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' });
}
}