tea0s_multimuse-tracker/main.ts

2176 lines
71 KiB
TypeScript
Raw Normal View History

2026-05-12 20:28:45 +00:00
import { Plugin, PluginSettingTab, Setting, Notice, TFile, TFolder, TAbstractFile, App, requestUrl, Modal, Editor, MarkdownView, CachedMetadata, RequestUrlResponse } from 'obsidian';
2025-12-10 02:25:53 +00:00
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)
2025-12-10 23:28:48 +00:00
trackRoleplay: boolean; // Whether to add Roleplay property from folder path
trackIsActive: boolean; // Whether to add Is Active? property (defaulting to true)
2025-12-10 02:25:53 +00:00
}
2026-03-18 03:55:11 +00:00
/** 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';
2025-12-10 02:25:53 +00:00
const DEFAULT_SETTINGS: MultimuseObsidianSettings = {
2026-03-18 03:55:11 +00:00
botApiUrl: MULTIMUSE_API_BASE,
2025-12-10 02:25:53 +00:00
pollInterval: 15,
scenesFolder: 'RP Scenes',
basePath: '',
ownerId: '', // Deprecated - auto-synced from API key
userIds: '', // Deprecated - auto-synced from API key
enabled: true,
apiKey: '',
2025-12-10 23:28:48 +00:00
cachedUserId: '', // Auto-populated from API key
trackRoleplay: true, // Default: extract Roleplay from folder path
trackIsActive: true // Default: add Is Active? property
2025-12-10 02:25:53 +00:00
};
interface MuseInfo {
name: string;
trigger: string;
tags: string;
owner_id: number;
is_shared: boolean;
2026-03-18 03:55:11 +00:00
muse_id?: string | null; // Optional: for API calls (alias-safe); display always uses name
2025-12-10 02:25:53 +00:00
}
2026-05-12 20:28:45 +00:00
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;
}
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 getErrorStatus(error: unknown): number | undefined {
if (error && typeof error === 'object') {
const details = error as { status?: unknown };
if (typeof details.status === 'number') return details.status;
}
return undefined;
}
2025-12-10 02:25:53 +00:00
export default class MultimuseObsidian extends Plugin {
settings: MultimuseObsidianSettings;
pollIntervalId: number | null = null;
museCache: Map<string, MuseInfo[]> = new Map(); // user_id (as string) -> muses
2025-12-10 23:28:48 +00:00
recentlyCreatedFiles: Set<string> = new Set(); // Track recently created files to skip immediate checking
2026-04-04 18:46:30 +00:00
// 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();
2025-12-10 02:25:53 +00:00
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: () => {
2026-05-12 20:28:45 +00:00
void this.checkAllThreads();
2025-12-10 02:25:53 +00:00
}
});
// Add command to toggle polling
this.addCommand({
id: 'toggle-polling',
name: 'Toggle Discord Polling',
callback: () => {
this.settings.enabled = !this.settings.enabled;
2026-05-12 20:28:45 +00:00
void this.saveSettings();
2025-12-10 02:25:53 +00:00
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: () => {
2026-05-12 20:28:45 +00:00
void this.createNewScene();
2025-12-10 02:25:53 +00:00
}
});
// Add command to sync from tracker
this.addCommand({
id: 'sync-from-tracker',
name: 'Sync from Tracker',
callback: () => {
2026-05-12 20:28:45 +00:00
void this.syncFromTracker();
2025-12-10 02:25:53 +00:00
}
});
2026-03-18 03:55:11 +00:00
// 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);
}
});
2025-12-10 02:25:53 +00:00
// 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);
}
})
);
2026-03-18 03:55:11 +00:00
// Add context menu items for scene editor
2025-12-10 02:25:53 +00:00
this.registerEvent(
this.app.workspace.on('editor-menu', (menu, editor, view) => {
2026-03-18 03:55:11 +00:00
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.');
}
});
});
2025-12-10 02:25:53 +00:00
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;
2026-03-18 03:55:11 +00:00
} 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();
}
2025-12-10 02:25:53 +00:00
}
}
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(() => {
2026-05-12 20:28:45 +00:00
void this.checkAllThreads();
2025-12-10 02:25:53 +00:00
}, intervalMs);
// Do an initial check
2026-05-12 20:28:45 +00:00
void this.checkAllThreads();
2025-12-10 02:25:53 +00:00
}
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 {
2026-03-18 03:55:11 +00:00
const base = !this.settings.botApiUrl || this.settings.botApiUrl.trim() === ''
? DEFAULT_SETTINGS.botApiUrl
: this.settings.botApiUrl.trim();
return base.replace(/\/+$/, ''); // no trailing slash
2025-12-10 02:25:53 +00:00
}
/**
* 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;
}
2026-05-12 20:28:45 +00:00
async trackThread(params: {
threadId: string;
userId: string;
museName: string;
participants: number;
scenePath?: string;
guildId?: string | null;
characters?: string[];
}): Promise<RequestUrlResponse> {
const body: Record<string, unknown> = {
thread_id: params.threadId,
user_id: params.userId,
muse_name: params.museName,
participants: params.participants
};
if (params.scenePath) body.scene_path = params.scenePath;
if (params.guildId) body.guild_id = params.guildId;
if (params.characters && params.characters.length > 0) body.characters = params.characters;
return await requestUrl({
url: `${this.getBotApiUrl()}/api/v1/threads/track`,
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify(body)
});
}
getFrontmatter(cache: CachedMetadata | null): FrontmatterData | null {
if (!cache?.frontmatter) {
return null;
}
return cache.frontmatter as FrontmatterData;
}
2025-12-10 02:25:53 +00:00
/**
* 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
*/
2026-05-12 20:28:45 +00:00
handleApiError(response: RequestUrlResponse, context: string): boolean {
2025-12-10 02:25:53 +00:00
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) {
2026-05-12 20:28:45 +00:00
const data = parseJson<AuthMeResponse>(response.text);
2025-12-10 02:25:53 +00:00
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) {
2026-05-12 20:28:45 +00:00
const data = parseJson<MusesListResponse>(response.text);
2025-12-10 02:25:53 +00:00
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> {
2026-05-12 20:28:45 +00:00
/**Check all scene files against current thread_tracking state.*/
2025-12-10 02:25:53 +00:00
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;
}
2026-05-12 20:28:45 +00:00
// Get all Discord-side tracked threads from the current API.
const trackedUrl = `${this.getBotApiUrl()}/api/v1/threads/tracked?user_id=${primaryUserId}`;
const trackedResponse = await requestUrl({
url: trackedUrl,
2025-12-10 02:25:53 +00:00
method: 'GET',
headers: this.getApiHeaders()
});
2026-05-12 20:28:45 +00:00
if (trackedResponse.status !== 200) {
if (!this.handleApiError(trackedResponse, 'checkAllThreadsViaBotApi')) {
console.error(`[MultimuseObsidian] Failed to fetch tracked threads: ${trackedResponse.status} - ${trackedResponse.text}`);
2025-12-10 02:25:53 +00:00
}
return;
}
2026-05-12 20:28:45 +00:00
const trackedData = parseJson<TrackedThreadsResponse>(trackedResponse.text);
const trackedThreads = trackedData.threads || [];
2025-12-10 02:25:53 +00:00
2026-05-12 20:28:45 +00:00
if (trackedThreads.length === 0) {
2025-12-10 02:25:53 +00:00
return;
}
2026-05-12 20:28:45 +00:00
// Create a map of scene_path -> thread info for quick lookup.
const scenePathMap = new Map<string, TrackedThread>();
for (const thread of trackedThreads) {
2025-12-10 02:25:53 +00:00
if (thread.scene_path) {
scenePathMap.set(thread.scene_path, thread);
}
2026-05-12 20:28:45 +00:00
for (const scenePath of thread.scene_paths || []) {
scenePathMap.set(scenePath, thread);
}
2025-12-10 02:25:53 +00:00
}
// Get all scene files
const sceneFiles = this.getSceneFiles();
let updatedCount = 0;
// Process each scene file
for (const file of sceneFiles) {
try {
2025-12-10 23:28:48 +00:00
// 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;
}
2025-12-10 02:25:53 +00:00
const cache = this.app.metadataCache.getFileCache(file);
2026-05-12 20:28:45 +00:00
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter) {
2025-12-10 02:25:53 +00:00
continue;
}
// Skip if scene is marked as inactive
2026-05-12 20:28:45 +00:00
const isActive = frontmatter['Is Active?'];
2025-12-10 02:25:53 +00:00
if (isActive === false || isActive === 'false') {
continue;
}
// Check if this scene is in the linked threads
const threadInfo = scenePathMap.get(file.path);
if (!threadInfo) {
2026-05-12 20:28:45 +00:00
// Scene not linked in thread_tracking, try querying by thread_id and characters.
const link = frontmatter['Link'];
if (typeof link !== 'string') continue;
2025-12-10 02:25:53 +00:00
2026-05-12 20:28:45 +00:00
const characters = this.getCharacterNames(frontmatter);
2025-12-10 02:25:53 +00:00
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
2026-05-12 20:28:45 +00:00
const characters = this.getCharacterNames(frontmatter);
2025-12-10 02:25:53 +00:00
if (characters.length === 0) continue;
2026-05-12 20:28:45 +00:00
const charactersParam = characters.join(',');
2025-12-10 02:25:53 +00:00
// 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) {
2026-05-12 20:28:45 +00:00
const queryData = parseJson<SceneQueryResponse>(queryResponse.text);
2025-12-10 02:25:53 +00:00
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.*/
2025-12-10 23:28:48 +00:00
// 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;
}
2025-12-10 02:25:53 +00:00
const cache = this.app.metadataCache.getFileCache(file);
2026-05-12 20:28:45 +00:00
const frontmatter = this.getFrontmatter(cache);
if (!cache || !frontmatter) {
2025-12-10 02:25:53 +00:00
return false;
}
// Skip if scene is marked as inactive
2026-05-12 20:28:45 +00:00
const isActive = frontmatter['Is Active?'];
2025-12-10 02:25:53 +00:00
if (isActive === false || isActive === 'false') {
return false; // Skip inactive scenes
}
2026-05-12 20:28:45 +00:00
const link = frontmatter['Link'];
if (typeof link !== 'string') {
2025-12-10 02:25:53 +00:00
return false;
}
2026-05-12 20:28:45 +00:00
const characters = this.getCharacterNames(frontmatter);
2025-12-10 02:25:53 +00:00
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;
}
2026-05-12 20:28:45 +00:00
const data = parseJson<SceneQueryResponse>(response.text);
2025-12-10 02:25:53 +00:00
// If scene is not tracked, don't update anything
if (!data.tracked || !data.state) {
2025-12-10 23:28:48 +00:00
console.log(`[MultimuseObsidian] Scene ${file.basename} is not tracked (tracked: ${data.tracked}, has state: ${!!data.state}) - skipping update`);
2025-12-10 02:25:53 +00:00
return false;
}
const state = data.state;
2026-03-18 03:55:11 +00:00
// 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);
2025-12-10 23:28:48 +00:00
2026-03-18 03:55:11 +00:00
// Only update if we have a definite replied value (replied or is_from_character)
if (repliedValue === undefined || repliedValue === null) {
2025-12-10 23:28:48 +00:00
console.log(`[MultimuseObsidian] Scene ${file.basename} has undefined/null replied value - skipping update`);
return false;
}
2026-03-18 03:55:11 +00:00
// Normalize state so updateSceneFromState sees a single field
const normalizedState = { ...state, replied: repliedValue };
return await this.updateSceneFromState(file, cache, normalizedState);
2025-12-10 02:25:53 +00:00
} catch (error) {
console.error(`[MultimuseObsidian] Error querying scene state for ${file.path}:`, error);
return false;
}
}
2026-05-12 20:28:45 +00:00
async updateSceneFromState(file: TFile, cache: CachedMetadata, state: SceneState): Promise<boolean> {
2025-12-10 02:25:53 +00:00
/**Update scene frontmatter from API state data.*/
let updated = false;
2026-05-12 20:28:45 +00:00
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter) {
return false;
}
2025-12-10 02:25:53 +00:00
2026-03-18 03:55:11 +00:00
// 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`);
2025-12-10 23:28:48 +00:00
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;
}
2025-12-10 02:25:53 +00:00
// Update Replied? field - normalize boolean values for comparison
2026-05-12 20:28:45 +00:00
const currentRepliedRaw = frontmatter['Replied?'];
2025-12-10 02:25:53 +00:00
// Handle both boolean and string values
const currentReplied = currentRepliedRaw === true || currentRepliedRaw === 'true' || currentRepliedRaw === 'True';
2026-03-18 03:55:11 +00:00
// true = you've replied (no need to reply), false = need to reply
const shouldBeReplied = repliedRaw === true || repliedRaw === 'true';
2025-12-10 02:25:53 +00:00
2026-03-18 03:55:11 +00:00
console.log(`[MultimuseObsidian] ${file.basename}: Current Replied?=${currentReplied}, API replied=${repliedRaw}, shouldBeReplied=${shouldBeReplied}`);
2025-12-10 23:28:48 +00:00
2026-03-18 03:55:11 +00:00
// Always apply API state so Replied? unchecks when someone replies back (your turn again)
2025-12-10 02:25:53 +00:00
if (currentReplied !== shouldBeReplied) {
console.log(`[MultimuseObsidian] ${file.basename}: Updated Replied? to ${shouldBeReplied}`);
await this.updateFrontmatter(file, 'Replied?', shouldBeReplied);
updated = true;
}
2026-03-18 03:55:11 +00:00
// 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.
2025-12-10 02:25:53 +00:00
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;
}
2025-12-10 23:28:48 +00:00
// 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;
}
2025-12-10 02:25:53 +00:00
// Small delay to avoid checking during file creation
2026-05-12 20:28:45 +00:00
await new Promise(resolve => window.setTimeout(resolve, 1000));
2025-12-10 02:25:53 +00:00
2026-03-18 03:55:11 +00:00
const cache = this.app.metadataCache.getFileCache(file);
2026-05-12 20:28:45 +00:00
if (this.getFrontmatter(cache)) {
2026-03-18 03:55:11 +00:00
// 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);
}
2025-12-10 02:25:53 +00:00
// 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);
}
}
2026-03-18 03:55:11 +00:00
/**
* 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.
2026-04-04 18:46:30 +00:00
*
* 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.
2026-03-18 03:55:11 +00:00
*/
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;
2026-05-12 20:28:45 +00:00
if (typeof link !== 'string') return;
const threadId = this.extractThreadIdFromUrl(link);
2026-03-18 03:55:11 +00:00
const raw = cache.frontmatter?.['Is Active?'];
const isActive = raw !== false && raw !== 'false';
2026-04-04 18:46:30 +00:00
// 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;
}
2026-03-18 03:55:11 +00:00
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) {
2026-04-04 18:46:30 +00:00
this.sceneActiveCache.set(file.path, isActive);
2026-03-18 03:55:11 +00:00
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;
2026-05-12 20:28:45 +00:00
if (typeof link !== 'string') return;
const threadId = this.extractThreadIdFromUrl(link);
2026-03-18 03:55:11 +00:00
const raw = cache.frontmatter?.['Participants'];
2026-05-12 20:28:45 +00:00
const participants = typeof raw === 'number' && raw >= 1
? raw
: typeof raw === 'string'
? parseInt(raw, 10)
: 2;
2026-03-18 03:55:11 +00:00
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);
}
}
2025-12-10 02:25:53 +00:00
getSceneFiles(): TFile[] {
const folder = this.app.vault.getAbstractFileByPath(this.settings.scenesFolder);
if (!folder) {
return [];
}
const files: TFile[] = [];
this.collectMarkdownFiles(folder, files);
return files;
}
2026-03-18 03:55:11 +00:00
/** 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;
}
2026-05-12 20:28:45 +00:00
collectMarkdownFiles(fileOrFolder: TAbstractFile, files: TFile[]): void {
2025-12-10 02:25:53 +00:00
if (fileOrFolder instanceof TFile && fileOrFolder.extension === 'md') {
files.push(fileOrFolder);
2026-05-12 20:28:45 +00:00
} else if (fileOrFolder instanceof TFolder) {
2025-12-10 02:25:53 +00:00
for (const child of fileOrFolder.children) {
this.collectMarkdownFiles(child, files);
}
}
}
2026-05-12 20:28:45 +00:00
getCharacterNames(frontmatter: FrontmatterData): string[] {
2025-12-10 02:25:53 +00:00
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;
}
2026-05-12 20:28:45 +00:00
async updateFrontmatter(file: TFile, key: string, value: FrontmatterValue): Promise<void> {
2025-12-10 02:25:53 +00:00
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) {
2026-05-12 20:28:45 +00:00
const data = parseJson<MusesListResponse>(response.text);
2025-12-10 02:25:53 +00:00
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);
2025-12-10 23:28:48 +00:00
const selectedMuseIndex = await this.showSuggester(museOptions, museOptions, 'Select a muse');
2025-12-10 02:25:53 +00:00
if (selectedMuseIndex === null || selectedMuseIndex < 0) return;
const selectedMuse = muses[selectedMuseIndex];
2025-12-10 23:28:48 +00:00
// Small delay to ensure previous modal is fully closed
2026-05-12 20:28:45 +00:00
await new Promise(resolve => window.setTimeout(resolve, 100));
2025-12-10 23:28:48 +00:00
2025-12-10 02:25:53 +00:00
// 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;
}
2025-12-10 23:28:48 +00:00
// Small delay before opening location modal
2026-05-12 20:28:45 +00:00
await new Promise(resolve => window.setTimeout(resolve, 100));
2025-12-10 23:28:48 +00:00
// 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)'}`);
2025-12-10 02:25:53 +00:00
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`;
2026-05-12 20:28:45 +00:00
const frontmatter: Record<string, FrontmatterValue> = {
2025-12-10 02:25:53 +00:00
'Link': threadUrl,
'Characters': [selectedMuse.name],
'Participants': participants,
'Replied?': false,
'Created': new Date().toISOString().split('T')[0],
};
2025-12-10 23:28:48 +00:00
// 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;
}
2025-12-10 02:25:53 +00:00
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');
2025-12-10 23:28:48 +00:00
// 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);
}
2025-12-10 02:25:53 +00:00
}
// Create file
const createdFile = await this.app.vault.create(filePath, content);
2025-12-10 23:28:48 +00:00
// 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)
2026-05-12 20:28:45 +00:00
window.setTimeout(() => {
2025-12-10 23:28:48 +00:00
this.recentlyCreatedFiles.delete(filePath);
console.log(`[MultimuseObsidian] Removed ${filePath} from recently created files - will now be checked by polling`);
}, 60000);
2025-12-10 02:25:53 +00:00
2026-05-12 20:28:45 +00:00
// 8) Link the vault scene to the current Discord-side thread tracker.
2025-12-10 02:25:53 +00:00
try {
2026-05-12 20:28:45 +00:00
console.debug(`Tracking scene: threadId=${threadInfo.threadId}, guildId=${threadInfo.guildId}, channelId=${threadInfo.channelId}, url=${threadUrl}`);
2025-12-10 02:25:53 +00:00
// 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
2026-05-12 20:28:45 +00:00
// Use primary user ID for thread tracking
2025-12-10 02:25:53 +00:00
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;
}
2026-05-12 20:28:45 +00:00
const registerResponse = await this.trackThread({
threadId: threadInfo.threadId,
userId: primaryUserId,
museName: selectedMuse.name,
participants: participants,
scenePath: createdFile.path,
guildId: threadInfo.guildId || null,
characters: [selectedMuse.name]
2025-12-10 02:25:53 +00:00
});
2026-05-12 20:28:45 +00:00
console.debug(`Thread tracking response: ${registerResponse.status} - ${registerResponse.text}`);
2025-12-10 02:25:53 +00:00
if (registerResponse.status === 200) {
// 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
2026-05-12 20:28:45 +00:00
this.handleApiError(registerResponse, 'createNewScene - track thread');
new Notice('Scene created but failed to track with bot: Authentication failed. Check your API key.');
2025-12-10 02:25:53 +00:00
} else {
const errorText = registerResponse.text || 'Unknown error';
2026-05-12 20:28:45 +00:00
console.error(`Failed to track thread: ${registerResponse.status} - ${errorText}`);
new Notice(`Scene created but failed to track with bot: ${registerResponse.status}`);
2025-12-10 02:25:53 +00:00
}
2026-05-12 20:28:45 +00:00
} catch (error) {
2025-12-10 02:25:53 +00:00
// Log the full error for debugging
2026-05-12 20:28:45 +00:00
console.error('Error tracking thread:', error);
const errorMessage = getErrorMessage(error);
2025-12-10 02:25:53 +00:00
console.error('Error details:', errorMessage);
2026-05-12 20:28:45 +00:00
new Notice(`Scene created but failed to track with bot: ${errorMessage}`);
2025-12-10 02:25:53 +00:00
}
}
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;
}
2026-05-12 20:28:45 +00:00
const data = parseJson<TrackedThreadsResponse>(response.text);
2025-12-10 02:25:53 +00:00
const threads = data.threads || [];
if (threads.length === 0) {
new Notice('No tracked threads found.');
return;
}
2026-03-18 03:55:11 +00:00
// Cross-check: which thread IDs already have a scene in Obsidian (via Link property)?
const existingLinksByThreadId = this.getExistingSceneLinksByThreadId();
2025-12-10 02:25:53 +00:00
let createdCount = 0;
let updatedCount = 0;
for (const thread of threads) {
2026-03-18 03:55:11 +00:00
const threadId = String(thread.thread_id ?? '');
2026-05-12 20:28:45 +00:00
const museName = thread.muse_name || 'Muse';
2025-12-10 02:25:53 +00:00
const participants = thread.participants || 2;
const existingScenePath = thread.scene_path;
2026-03-18 03:55:11 +00:00
// 1) Scene already exists at the path the tracker knows about
2025-12-10 02:25:53 +00:00
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;
}
}
2026-03-18 03:55:11 +00:00
// 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)
2025-12-10 02:25:53 +00:00
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`;
2026-05-12 20:28:45 +00:00
const frontmatter: Record<string, FrontmatterValue> = {
2025-12-10 02:25:53 +00:00
'Link': threadUrl,
'Characters': [museName],
'Participants': participants,
'Replied?': false,
'Created': new Date().toISOString().split('T')[0],
};
2025-12-10 23:28:48 +00:00
// 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;
}
2025-12-10 02:25:53 +00:00
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');
2025-12-10 23:28:48 +00:00
// 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);
}
2025-12-10 02:25:53 +00:00
}
// Create file
const file = await this.app.vault.create(filePath, content);
2025-12-10 23:28:48 +00:00
// 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)
2026-05-12 20:28:45 +00:00
window.setTimeout(() => {
2025-12-10 23:28:48 +00:00
this.recentlyCreatedFiles.delete(filePath);
}, 30000);
2025-12-10 02:25:53 +00:00
2026-05-12 20:28:45 +00:00
// Link the created note back to the current Discord-side thread tracker.
2025-12-10 02:25:53 +00:00
const primaryUserId = await this.getPrimaryUserId();
if (!primaryUserId) {
2026-05-12 20:28:45 +00:00
console.error('Failed to get user ID from API key, skipping thread tracking update');
2025-12-10 02:25:53 +00:00
continue;
}
2026-05-12 20:28:45 +00:00
await this.trackThread({
threadId: threadId,
userId: primaryUserId,
museName: museName,
participants: Number(participants) || 2,
scenePath: filePath,
guildId: guildId ? String(guildId) : null,
characters: [museName]
2025-12-10 02:25:53 +00:00
});
// 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 =========
2026-05-12 20:28:45 +00:00
async addSceneToBase(file: TFile, frontmatter: FrontmatterData): Promise<void> {
2025-12-10 02:25:53 +00:00
/**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);
}
}
2026-05-12 20:28:45 +00:00
async updateBaseRecord(file: TFile, thread: TrackedThread): Promise<void> {
2025-12-10 02:25:53 +00:00
/**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} |`)) {
2026-05-12 20:28:45 +00:00
const characters = [thread.muse_name || 'Muse'];
2025-12-10 02:25:53 +00:00
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;
}
2025-12-10 23:28:48 +00:00
/**
* 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;
}
2025-12-10 02:25:53 +00:00
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" }));
2025-12-10 23:28:48 +00:00
// Always add the option to create a new folder path
2025-12-10 02:25:53 +00:00
options.push("+ New folder path…");
// Build title with context if provided
const suggesterTitle = context
? `Select location for ${context}`
: 'Select scene location';
2025-12-10 23:28:48 +00:00
console.log(`[MultimuseObsidian] selectSceneLocation: Showing ${options.length} options with title: ${suggesterTitle}`);
2025-12-10 02:25:53 +00:00
const choiceIndex = await this.showSuggester(options, options, suggesterTitle);
2025-12-10 23:28:48 +00:00
if (choiceIndex === null) {
console.log(`[MultimuseObsidian] selectSceneLocation: User cancelled or no selection`);
return null;
}
2025-12-10 02:25:53 +00:00
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}`;
}
2026-05-12 20:28:45 +00:00
showSuggester<T>(items: string[], _values: T[], title?: string): Promise<number | null> {
2025-12-10 02:25:53 +00:00
return new Promise((resolve) => {
2025-12-10 02:58:46 +00:00
// Create a modal with buttons
2025-12-10 02:25:53 +00:00
const modal = new (class extends Modal {
selectedIndex: number | null = null;
items: string[];
titleText: string;
2026-05-12 20:28:45 +00:00
constructor(app: App, items: string[], titleText?: string) {
2025-12-10 02:25:53 +00:00
super(app);
this.items = items;
this.titleText = titleText || 'Select an option';
2025-12-10 02:58:46 +00:00
// Set title in constructor to ensure it's set before modal opens
this.titleEl.textContent = this.titleText;
2025-12-10 02:25:53 +00:00
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
2025-12-10 02:58:46 +00:00
// 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', {
2026-05-12 20:28:45 +00:00
cls: 'multimuse-scene-context'
2025-12-10 02:58:46 +00:00
});
infoEl.createEl('strong', {
text: `Creating scene file for: ${contextInfo}`,
2026-05-12 20:28:45 +00:00
cls: 'multimuse-scene-context-title'
2025-12-10 02:58:46 +00:00
});
}
2026-05-12 20:28:45 +00:00
contentEl.createEl('p', {
2025-12-10 02:58:46 +00:00
text: 'Choose where to create the scene file:',
2026-05-12 20:28:45 +00:00
cls: 'multimuse-scene-location-desc'
2025-12-10 02:58:46 +00:00
});
}
2025-12-10 02:25:53 +00:00
this.items.forEach((item, index) => {
const button = contentEl.createEl('button', {
text: item,
2026-05-12 20:28:45 +00:00
cls: ['mod-cta', 'multimuse-suggester-button']
2025-12-10 02:25:53 +00:00
});
button.onclick = () => {
this.selectedIndex = index;
this.close();
};
});
}
onClose() {
resolve(this.selectedIndex);
}
2026-05-12 20:28:45 +00:00
})(this.app, items, title);
2025-12-10 02:25:53 +00:00
2025-12-10 23:28:48 +00:00
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
2026-05-12 20:28:45 +00:00
window.requestAnimationFrame(() => {
2025-12-10 23:28:48 +00:00
modal.open();
});
2025-12-10 02:25:53 +00:00
});
}
showInputPrompt(prompt: string, defaultValue?: string): Promise<string | null> {
return new Promise((resolve) => {
2025-12-10 02:58:46 +00:00
// Use Obsidian's built-in modal for input
2025-12-10 02:25:53 +00:00
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',
2026-05-12 20:28:45 +00:00
value: defaultValue || '',
cls: 'multimuse-input'
2025-12-10 02:25:53 +00:00
});
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();
});
}
2026-03-18 03:55:11 +00:00
/**
* 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;
}
2026-05-12 20:28:45 +00:00
const frontmatter = this.getFrontmatter(cache);
const link = frontmatter?.['Link'];
if (typeof link !== 'string') {
2026-03-18 03:55:11 +00:00
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;
}
2026-05-12 20:28:45 +00:00
let members: GuildMember[];
2026-03-18 03:55:11 +00:00
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;
}
2026-05-12 20:28:45 +00:00
const data = parseJson<GuildMembersResponse>(response.text);
2026-03-18 03:55:11 +00:00
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}`);
}
2025-12-10 02:25:53 +00:00
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);
2026-05-12 20:28:45 +00:00
const frontmatter = this.getFrontmatter(cache);
if (!frontmatter) {
2025-12-10 02:25:53 +00:00
new Notice('File does not have frontmatter. Please add Link and Characters properties.');
return;
}
// Extract link and characters
2026-05-12 20:28:45 +00:00
const link = frontmatter?.['Link'];
if (typeof link !== 'string') {
2025-12-10 02:25:53 +00:00
new Notice('No Link property found in frontmatter. Please add a Discord thread URL.');
return;
}
2026-05-12 20:28:45 +00:00
const characters = this.getCharacterNames(frontmatter);
2025-12-10 02:25:53 +00:00
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) {
2026-05-12 20:28:45 +00:00
const data = parseJson<MusesListResponse>(response.text);
2025-12-10 02:25:53 +00:00
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;
}
2026-03-18 03:55:11 +00:00
// Find matching muse (case-insensitive fuzzy) for validation and optional muse_id
const matchedMuse = muses.find(m => {
2025-12-10 02:25:53 +00:00
const museLower = m.name.toLowerCase().trim();
const selectedLower = selectedMuse.toLowerCase().trim();
return museLower === selectedLower || museLower.includes(selectedLower) || selectedLower.includes(museLower);
});
2026-03-18 03:55:11 +00:00
if (!matchedMuse) {
2025-12-10 02:25:53 +00:00
new Notice(`Muse "${selectedMuse}" not found or not accessible. Available muses: ${muses.map(m => m.name).join(', ')}`);
return;
}
2026-03-18 03:55:11 +00:00
// 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;
}
2025-12-10 02:25:53 +00:00
// Post message via API
try {
const url = `${this.getBotApiUrl()}/api/v1/messages/post`;
const response = await requestUrl({
url: url,
method: 'POST',
headers: this.getApiHeaders(),
2026-03-18 03:55:11 +00:00
body: JSON.stringify(postBody)
2025-12-10 02:25:53 +00:00
});
if (response.status === 200) {
new Notice(`Message sent as ${selectedMuse}!`);
} else {
if (!this.handleApiError(response, 'sendSelectionAsMuse - post message')) {
2026-05-12 20:28:45 +00:00
const errorData = parseJson<ApiErrorBody>(response.text);
2025-12-10 02:25:53 +00:00
new Notice(`Failed to send message: ${errorData.message || response.status}`);
}
}
2026-05-12 20:28:45 +00:00
} catch (error) {
2025-12-10 02:25:53 +00:00
console.error('Error sending message:', error);
2026-05-12 20:28:45 +00:00
const errorMessage = getErrorMessage(error);
2025-12-10 02:25:53 +00:00
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();
2026-05-12 20:28:45 +00:00
new Setting(containerEl)
.setName('Multimuse Settings')
.setHeading();
2025-12-10 02:25:53 +00:00
// 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();
}));
2025-12-10 23:28:48 +00:00
// Scene Properties Tracking
2026-05-12 20:28:45 +00:00
new Setting(containerEl)
.setName('Scene Properties')
.setHeading();
2025-12-10 23:28:48 +00:00
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();
}));
2025-12-10 02:25:53 +00:00
// 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 => {
2026-05-12 20:28:45 +00:00
text.setValue(this.plugin.settings.cachedUserId);
text.inputEl.disabled = true;
2025-12-10 02:25:53 +00:00
});
}
// 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(() => {
2026-05-12 20:28:45 +00:00
void this.plugin.checkAllThreads();
2025-12-10 02:25:53 +00:00
new Notice('Checking Discord threads...');
}));
// Info section
containerEl.createEl('hr');
const infoEl = containerEl.createEl('div');
2026-05-12 20:28:45 +00:00
new Setting(infoEl)
.setName('How It Works')
.setHeading();
2025-12-10 02:25:53 +00:00
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' });
2026-03-18 03:55:11 +00:00
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.' });
2025-12-10 02:25:53 +00:00
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' });
}
}