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 > {
/ * * S e l e c t o r c r e a t e s c e n e l o c a t i o n f o l d e r .
* @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' } ) ;
}
}