diff --git a/src/custom-commands-auto-complete/bot-sources-editor.vue b/src/custom-commands-auto-complete/bot-sources-editor.vue new file mode 100644 index 0000000..4470276 --- /dev/null +++ b/src/custom-commands-auto-complete/bot-sources-editor.vue @@ -0,0 +1,711 @@ + + + + + + diff --git a/src/custom-commands-auto-complete/core.js b/src/custom-commands-auto-complete/core.js new file mode 100644 index 0000000..b1b20be --- /dev/null +++ b/src/custom-commands-auto-complete/core.js @@ -0,0 +1,362 @@ +/** + * Core Module + * + * Main orchestration for custom bot command auto-completion. + * Coordinates between sources, fetching, and FFZ's tab-completion system. + */ + +import { truncateDescription, sortBySourceOrder } from './utils'; +import { fetchFromSource, fetchWithTimeout } from './fetcher'; +import { getEnabledSources, filterApplicableSources } from './sources'; + +export class Core extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + + this.inject('chat'); + this.inject('settings'); + + this.commandsByKey = {}; + this.lastFetchTime = null; + this.currentChannel = null; + this.currentRoom = null; + this.isLoading = false; + this.hasLoadedForChannel = false; + this.loadingPromise = null; + this.userPermissionLevel = 0; + } + + onEnable() { + this.on('chat:get-tab-commands', this.getTabCommands, this); + this.on('chat:pre-send-message', this.handlePreSendMessage, this); + + this.settings.on('changed:addon.custom-commands.source-order', this.refreshCommands, this); + this.settings.on('changed:addon.custom-commands.custom-sources', this.refreshCommands, this); + } + + onDisable() { + this.off('chat:get-tab-commands', this.getTabCommands, this); + this.off('chat:pre-send-message', this.handlePreSendMessage, this); + this.settings.off('changed:addon.custom-commands.source-order', this.refreshCommands, this); + this.settings.off('changed:addon.custom-commands.custom-sources', this.refreshCommands, this); + this.clearCommands(); + } + + handlePreSendMessage(e) { + if (!this.settings.get('addon.custom-commands.enabled')) return; + + // Handle add-on built-in command to force refresh commands + if (e.message.trim() === '/ccac-refresh') { + e.preventDefault(); + this.refreshCommands(); + return; + } + + // Handle prefix replacement for bot commands typed with / + const mode = this.settings.get('addon.custom-commands.completion-mode'); + if (mode === 'slash' || mode === 'both') { + const message = e.message.trim(); + if (message.startsWith('/')) { + const parts = message.substring(1).split(' '); + const commandName = parts[0]; + + // Search through all commands to find if this is a bot command + for (const commands of Object.values(this.commandsByKey)) { + for (const cmd of commands) { + if (cmd.name === commandName || (cmd.aliases && cmd.aliases.includes(commandName))) { + // Found it! Replace / with the source prefix + const sourcePrefix = cmd.sourcePrefix || '!'; + e.message = sourcePrefix + message.substring(1); + return; + } + } + } + } + } + } + + /** + * Provide commands for FFZ's tab-completion. + * Triggers lazy loading on first keystroke if enabled. + * + * @param {Object} event - Tab-completion event object + */ + getTabCommands(event) { + if (!this.settings.get('addon.custom-commands.enabled')) return; + if (!this.currentChannel) return; + + const lazyLoad = this.settings.get('addon.custom-commands.lazy-loading'); + + if (lazyLoad && !this.hasLoadedForChannel) { + if (!this.isLoading) { + this.isLoading = true; + this.loadingPromise = this.loadCommandsForChannel(this.currentChannel) + .then(() => this.triggerTabCompletionRefresh()); + } + event.commands.push({ + name: 'loading', + description: 'Loading commands...', + permissionLevel: 0, + ffz_group: 'Bot Commands' + }); + return; + } + + const commands = this.getAllCommands(); + if (commands.length > 0) { + event.commands.push(...commands); + } + } + + triggerTabCompletionRefresh() { + try { + const chatInput = document.querySelector('[data-a-target="chat-input"]'); + if (chatInput) { + chatInput.dispatchEvent(new Event('input', { bubbles: true })); + } + } catch (err) { + this.log.debug('Could not trigger tab completion refresh:', err); + } + } + + /** + * Get all commands formatted for tab-completion. + * + * @returns {Array} Array of command objects for tab-completion + * each command object has: + * - name: Command name + * - description: Command description + * - permissionLevel: Minimum permission level to see the command + * - ffz_group: Group name for FFZ tab-completion + */ + getAllCommands() { + const showAll = this.settings.get('addon.custom-commands.show-all-commands'); + const mode = this.settings.get('addon.custom-commands.completion-mode'); + const sourceOrder = this.settings.provider + ? (this.settings.provider.get('addon.custom-commands.source-order') || []) + : []; + + const sourceKeys = sortBySourceOrder(Object.keys(this.commandsByKey), sourceOrder); + const result = []; + + for (let i = 0; i < sourceKeys.length; i++) { + const commands = this.commandsByKey[sourceKeys[i]]; + if (!commands?.length) continue; + + const groupName = `Bot Commands #${i + 1} (${commands[0]?.source || sourceKeys[i]})`; + const sourcePrefix = commands[0]?.sourcePrefix || '!'; + + this.addCommandsToResult(result, commands, { + showAll, + mode, + groupName, + sourcePrefix + }); + } + + // Add control command + result.push({ + name: 'ccac-refresh', + description: 'Force refresh custom bot commands', + permissionLevel: 0, + ffz_group: 'Bot Commands (Control)' + }); + + return result; + } + + /** + * Add commands from a source to the result array. + * + * @param {Array} result - Result array to push commands into + * @param {Array} commands - Array of command objects from the source + * @param {Object} options - Options for adding commands + * @param {boolean} options.showAll - Whether to show all commands regardless of permission + * @param {string} options.mode - Completion mode ('prefix', 'slash', 'both') + * @param {string} options.groupName - FFZ group name for the commands + * @param {string} options.sourcePrefix - Prefix used by the source (e.g., '!') + */ + addCommandsToResult(result, commands, { showAll, mode, groupName, sourcePrefix }) { + for (const cmd of commands) { + if (!showAll && cmd.permissionLevel > this.userPermissionLevel) { + continue; + } + + // Add main command + this.addCommandVariants(result, cmd.name, { + description: truncateDescription(cmd.description), + mode, + groupName, + sourcePrefix + }); + + // Add aliases + for (const alias of cmd.aliases || []) { + this.addCommandVariants(result, alias, { + description: `Alias for ${sourcePrefix}${cmd.name}`, + mode, + groupName, + sourcePrefix + }); + } + } + } + + /** + * Add slash and/or prefix variants of a command. + * + * @param {Array} result - Result array to push commands into + * @param {string} name - Command name + * @param {Object} options - Options for command variant + * @param {string} options.description - Command description + * @param {string} options.mode - Completion mode ('prefix', 'slash', 'both') + * @param {string} options.groupName - FFZ group name for the command + * @param {string} options.sourcePrefix - Prefix used by the source (e.g., '!') + */ + addCommandVariants(result, name, { description, mode, groupName, sourcePrefix }) { + if (mode === 'slash' || mode === 'both') { + result.push({ + name, + description, + permissionLevel: 0, + ffz_group: groupName + }); + } + + if (mode === 'prefix' || mode === 'both') { + result.push({ + prefix: sourcePrefix, + name, + description, + permissionLevel: 0, + ffz_group: groupName + }); + } + } + + /** + * Load commands for a channel from all applicable sources. + * + * @param {string} channelLogin - Twitch channel login name + */ + async loadCommandsForChannel(channelLogin) { + if (!this.settings.get('addon.custom-commands.enabled')) return; + + if (this.loadingPromise && this.isLoading) { + return this.loadingPromise; + } + + if (this.isCacheValid(channelLogin)) { + return; + } + + this.isLoading = true; + this.clearCommands(); + this.currentChannel = channelLogin; + + const allSources = getEnabledSources(this.settings, this.log); + const applicableSources = filterApplicableSources(allSources, channelLogin, this.currentRoom, this.log); + + if (applicableSources.length === 0) { + this.log.debug(`No applicable command sources for #${channelLogin}`); + this.isLoading = false; + this.hasLoadedForChannel = true; + return; + } + + this.loadingPromise = this.fetchAllSources(applicableSources, channelLogin); + await this.loadingPromise; + } + + isCacheValid(channelLogin) { + const cacheDuration = this.settings.get('addon.custom-commands.cache-duration') * 1000; + const now = Date.now(); + + return ( + this.currentChannel === channelLogin && + this.lastFetchTime && + cacheDuration > 0 && + (now - this.lastFetchTime) < cacheDuration + ); + } + + async fetchAllSources(sources, channelLogin) { + const results = await Promise.all( + sources.map(source => + fetchWithTimeout( + fetchFromSource(source, channelLogin, this.log), + 5000, + source.name, + this.log + ).catch(err => ({ source, commands: null, error: err })) + ) + ); + + for (const result of results) { + if (result?.commands?.length > 0) { + this.commandsByKey[result.source.key] = result.commands; + this.log.debug(`${result.source.name}: Loaded ${result.commands.length} commands`); + } + } + + this.lastFetchTime = Date.now(); + this.isLoading = false; + this.hasLoadedForChannel = true; + this.loadingPromise = null; + + this.logLoadSummary(channelLogin); + } + + logLoadSummary(channelLogin) { + const entries = Object.entries(this.commandsByKey); + const total = entries.reduce((sum, [, cmds]) => sum + cmds.length, 0); + + if (total > 0) { + const summary = entries.map(([key, cmds]) => `${key}: ${cmds.length}`).join(', '); + this.log.info(`Loaded ${total} commands for #${channelLogin} (${summary})`); + } else { + this.log.info(`No commands found for #${channelLogin}`); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // State Management + // ───────────────────────────────────────────────────────────────────────────── + + updateUserPermissionLevel() { + try { + const chatContainer = this.chat?.ChatContainer?.first; + if (chatContainer?.props) { + const level = chatContainer.props.commandPermissionLevel; + this.userPermissionLevel = level === 3 ? 4 : level === 2 ? 3 : level === 1 ? 2 : 0; + } + } catch { + this.userPermissionLevel = 0; + } + } + + setCurrentChannel(channelLogin, room = null) { + if (this.currentChannel !== channelLogin) { + this.clearCommands(); + this.currentChannel = channelLogin; + this.currentRoom = room; + } + } + + clearCommands() { + this.commandsByKey = {}; + this.lastFetchTime = null; + this.hasLoadedForChannel = false; + } + + async refreshCommands() { + if (this.currentChannel) { + this.log.info(`Refreshing commands for #${this.currentChannel}`); + this.lastFetchTime = null; + this.hasLoadedForChannel = false; + this.isLoading = false; + this.loadingPromise = null; + await this.loadCommandsForChannel(this.currentChannel); + } + } +} diff --git a/src/custom-commands-auto-complete/fetcher.js b/src/custom-commands-auto-complete/fetcher.js new file mode 100644 index 0000000..7bd27f6 --- /dev/null +++ b/src/custom-commands-auto-complete/fetcher.js @@ -0,0 +1,274 @@ +/** + * API Fetcher + * + * Handles all HTTP communication with bot APIs. + */ + +import { getNestedValue, mapPermissionLevel } from './utils'; + +/** + * Fetch from API with support for GET/POST, headers, and form data. + * + * @param {string} url - The URL to fetch + * @param {string} method - HTTP method ('GET' or 'POST') + * @param {Object|null} formData - Form data for POST requests + * @param {Object|null} headers - Custom headers + * @param {Object} log - Logger instance + * @returns {Promise} Parsed JSON response or null on error + */ +export async function fetchApi(url, method = 'GET', formData = null, headers = null, log = console) { + try { + const options = { + method: method || 'GET', + headers: headers || {} + }; + + if (formData && method === 'POST') { + const form = new URLSearchParams(); + for (const [key, value] of Object.entries(formData)) { + form.append(key, value); + } + options.body = form; + options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + const response = await fetch(url, options); + if (response.ok) { + return await response.json(); + } + + // Log non-404 errors as warnings (404 is expected when bot not configured) + if (response.status === 404) { + log.info(`API returned 404 for ${url} (channel not configured?)`); + } else { + log.warn(`HTTP ${response.status} for ${url}`); + } + } catch (err) { + const msg = err.message || String(err); + if (msg.includes('Failed to fetch')) { + log.warn(`CORS/network error for ${url}`); + } else { + log.error(`Fetch error for ${url}: ${msg}`); + } + } + return null; +} + +/** + * Wraps a promise with a timeout. + * + * @param {Promise} promise - The promise to wrap + * @param {number} timeoutMs - Timeout in milliseconds + * @param {string} sourceName - Source name for error message + * @param {Object} log - Logger instance + * @returns {Promise} Promise that resolves with the result or rejects on timeout + */ +export async function fetchWithTimeout(promise, timeoutMs, sourceName, log) { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } catch (err) { + if (err.message.includes('Timeout')) { + log.warn(`${sourceName}: Request timed out after ${timeoutMs}ms`); + } + throw err; + } +} + +/** + * Build headers object with placeholders replaced. + * + * @param {Object|null} headersConfig - Headers configuration with placeholders + * @param {string} channelLogin - Channel login name + * @param {string} channelId - Resolved channel ID + * @returns {Object|null} Headers with placeholders replaced, or null + */ +export function buildHeaders(headersConfig, channelLogin, channelId) { + if (!headersConfig) return null; + const headers = {}; + for (const [key, value] of Object.entries(headersConfig)) { + headers[key] = String(value) + .replace('{channel}', channelLogin) + .replace('{channelId}', channelId); + } + return headers; +} + +/** + * Build form data object with placeholders replaced. + * + * @param {Object|null} formDataConfig - Form data configuration with placeholders + * @param {string} channelLogin - Channel login name + * @param {string} channelId - Resolved channel ID + * @returns {Object|null} Form data with placeholders replaced, or null + */ +export function buildFormData(formDataConfig, channelLogin, channelId) { + if (!formDataConfig) return null; + const data = {}; + for (const [key, value] of Object.entries(formDataConfig)) { + data[key] = String(value) + .replace('{channel}', channelLogin) + .replace('{channelId}', channelId); + } + return data; +} + +/** + * Resolve channel ID from a source's channel endpoint. + * + * @param {Object} source - Source definition + * @param {string} channelLogin - Channel login name + * @param {Object} log - Logger instance + * @returns {Promise} Resolved channel ID or null on failure + */ +export async function resolveChannelId(source, channelLogin, log) { + if (!source.channelUrl || !source.channelIdPath) { + return channelLogin; + } + + const channelUrl = source.channelUrl.replace('{channel}', channelLogin); + const channelHeaders = buildHeaders(source.channelHeaders, channelLogin, channelLogin); + const channelFormData = buildFormData(source.channelFormData, channelLogin, channelLogin); + + if (channelFormData) { + log.debug(`${source.name}: Channel form data:`, channelFormData); + } + + const channelData = await fetchApi(channelUrl, source.channelMethod, channelFormData, channelHeaders, log); + + if (!channelData) { + log.debug(`${source.name}: Failed to fetch channel data`); + return null; + } + + const channelId = getNestedValue(channelData, source.channelIdPath); + const pathStr = Array.isArray(source.channelIdPath) ? source.channelIdPath.join('.') : source.channelIdPath; + + if (!channelId) { + log.warn(`${source.name}: Channel ID not found at path "${pathStr}"`); + log.debug(`${source.name}: Response keys: ${Object.keys(channelData).join(', ')}`); + return null; + } + + log.debug(`${source.name}: Resolved channel ID: ${channelId}`); + return channelId; +} + +/** + * Fetch and parse commands from a source. + * + * @param {Object} source - Source definition + * @param {string} channelLogin - Channel login name + * @param {Object} log - Logger instance + * @returns {Promise} Result with source and commands array + */ +export async function fetchFromSource(source, channelLogin, log) { + log.debug(`${source.name}: Fetching commands for ${channelLogin}`); + + try { + // Resolve channel ID if needed + const channelId = await resolveChannelId(source, channelLogin, log); + if (!channelId) { + return { source, commands: null }; + } + + if (!source.commandsUrl) { + log.warn(`${source.name}: No commandsUrl configured`); + return { source, commands: null }; + } + + const finalUrl = source.commandsUrl + .replace('{channel}', channelLogin) + .replace('{channelId}', channelId); + + log.debug(`${source.name}: Fetching commands from ${finalUrl}`); + + const headers = buildHeaders(source.commandsHeaders, channelLogin, channelId); + const formData = buildFormData(source.commandsFormData, channelLogin, channelId); + + const data = await fetchApi(finalUrl, source.commandsMethod, formData, headers, log); + if (!data) { + log.info(`${source.name}: No data returned from commands endpoint`); + return { source, commands: null }; + } + + const commands = parseCommands(data, source, log); + return { source, commands }; + } catch (err) { + log.error(`${source.name}: Error during fetch - ${err.message || err}`); + return { source, commands: null, error: err }; + } +} + +/** + * Parse raw API response into standardized command objects. + * + * @param {Object|Array} data - Raw API response + * @param {Object} source - Source definition + * @param {Object} log - Logger instance + * @returns {Array} Array of parsed command objects + */ +function parseCommands(data, source, log) { + // Extract commands array from response + let commandsArray = source.commandsPath + ? getNestedValue(data, source.commandsPath) + : data; + + if (!commandsArray) { + log.info(`${source.name}: Commands array not found at path "${source.commandsPath}"`); + return []; + } + + if (!Array.isArray(commandsArray)) { + commandsArray = [commandsArray]; + } + + const sourcePrefix = source.prefix || '!'; + + return commandsArray + .filter(cmd => { + if (source.enabledPath) { + return getNestedValue(cmd, source.enabledPath) !== false; + } + return true; + }) + .map(cmd => parseCommand(cmd, source, sourcePrefix)) + .filter(cmd => cmd.name); +} + +/** + * Parse a single command from raw API data. + * + * @param {Object} cmd - Raw command object from API + * @param {Object} source - Source definition + * @param {string} sourcePrefix - Default prefix for this source + * @returns {Object} Standardized command object + */ +function parseCommand(cmd, source, sourcePrefix) { + let name = getNestedValue(cmd, source.namePath || 'name') || ''; + let cmdPrefix = sourcePrefix; + + // If prefix is embedded in command name, extract it + if (source.prefixInName && name) { + const prefixMatch = name.match(/^([!.$/\\?#@~])(.+)$/); + if (prefixMatch) { + cmdPrefix = prefixMatch[1]; + name = prefixMatch[2]; + } + } + + return { + name, + description: getNestedValue(cmd, source.descriptionPath || 'description') || '', + permissionLevel: mapPermissionLevel( + getNestedValue(cmd, source.permissionPath || 'permission'), + source.permissionMapping + ), + aliases: getNestedValue(cmd, source.aliasesPath) || [], + source: source.name, + sourcePrefix: cmdPrefix + }; +} diff --git a/src/custom-commands-auto-complete/index.js b/src/custom-commands-auto-complete/index.js new file mode 100644 index 0000000..a5aea4b --- /dev/null +++ b/src/custom-commands-auto-complete/index.js @@ -0,0 +1,174 @@ +import { Core } from './core'; +import { PRESETS } from './presets'; + +class CustomCommandsAutoComplete extends Addon { + constructor(...args) { + super(...args); + + this.inject('chat'); + this.inject('site'); + this.inject('settings'); + + this.injectAs('core', Core); + + this.currentRoom = null; + this.currentChannelLogin = null; + + this.registerSettings(); + + this.on('chat:room-add', this.onRoomAdd, this); + this.on('chat:room-remove', this.onRoomRemove, this); + } + + registerSettings() { + // Register Vue component + this.settings.addUI('addon.custom-commands.bot-sources-editor', { + path: 'Add-Ons > Custom Commands Auto Complete >> Source Ordering @{"sort": -1}', + component: () => import(/* webpackChunkName: 'custom-commands' */ './bot-sources-editor.vue'), + force_seen: true + }); + + // Hidden setting to store source order (default: all built-in presets enabled) + this.settings.add('addon.custom-commands.source-order', { + default: Object.keys(PRESETS) + // Note: Don't reload on change - order change doesn't require re-fetch + }); + + // Hidden setting to track which sources are disabled (to preserve order) + this.settings.add('addon.custom-commands.disabled-sources', { + default: [] + }); + + // General Settings + this.settings.add('addon.custom-commands.enabled', { + default: true, + ui: { + sort: 0, + path: 'Add-Ons > Custom Commands Auto Complete >> General', + title: 'Enable Custom Commands Auto Complete', + description: 'Enable or disable the auto-completion of custom bot commands.', + component: 'setting-check-box' + }, + changed: enabled => { + if (enabled && this.currentChannelLogin) { + this.loadCurrentRoom(); + } else if (!enabled) { + this.core.clearCommands(); + } + } + }); + + this.settings.add('addon.custom-commands.completion-mode', { + default: 'prefix', + ui: { + sort: 1, + path: 'Add-Ons > Custom Commands Auto Complete >> General', + title: 'Completion Mode', + description: 'How commands appear in auto-completion.', + component: 'setting-select-box', + data: [ + { value: 'prefix', title: 'Prefix (!ping or source-defined)' }, + { value: 'slash', title: 'Slash (/ping)' }, + { value: 'both', title: 'Both (prefix and /)' } + ] + } + }); + + this.settings.add('addon.custom-commands.lazy-loading', { + default: true, + ui: { + sort: 2, + path: 'Add-Ons > Custom Commands Auto Complete >> General', + title: 'Lazy Loading', + description: 'Only fetch commands when you start typing. Reduces API calls.', + component: 'setting-check-box' + } + }); + + this.settings.add('addon.custom-commands.cache-duration', { + default: 3600, + ui: { + sort: 3, + path: 'Add-Ons > Custom Commands Auto Complete >> General', + title: 'Cache Duration (seconds)', + description: 'How long to cache commands. Set to 0 to disable.', + component: 'setting-text-box', + process: 'to_int' + } + }); + + this.settings.add('addon.custom-commands.show-all-commands', { + default: false, + ui: { + sort: 4, + path: 'Add-Ons > Custom Commands Auto Complete >> General', + title: 'Show All Commands', + description: 'Show all commands regardless of permission level.', + component: 'setting-check-box' + } + }); + + // Custom Sources (managed via Source Ordering panel) + this.settings.add('addon.custom-commands.custom-sources', { + default: [], + changed: () => this.reloadIfEnabled() + }); + } + + onEnable() { + this.log.info('Custom Commands Auto Complete enabled'); + this.loadExistingRooms(); + } + + onDisable() { + this.core.clearCommands(); + this.currentRoom = null; + this.currentChannelLogin = null; + this.log.info('Custom Commands Auto Complete disabled'); + } + + loadExistingRooms() { + for (const room of this.chat.iterateRooms()) { + if (room) { + this.onRoomAdd(room); + break; + } + } + } + + reloadIfEnabled() { + if (this.settings.get('addon.custom-commands.enabled') && this.currentChannelLogin) { + this.core.refreshCommands(); + } + } + + loadCurrentRoom() { + if (this.currentRoom) { + this.core.loadCommandsForChannel(this.currentChannelLogin); + } + } + + async onRoomAdd(room) { + if (!this.settings.get('addon.custom-commands.enabled')) return; + + this.currentRoom = room; + this.currentChannelLogin = room.login; + + this.core.updateUserPermissionLevel(); + this.core.setCurrentChannel(room.login, room); + + if (!this.settings.get('addon.custom-commands.lazy-loading')) { + await this.core.loadCommandsForChannel(room.login); + } + } + + onRoomRemove(room) { + if (this.currentRoom === room) { + this.core.clearCommands(); + this.currentRoom = null; + this.currentChannelLogin = null; + } + } +} + +CustomCommandsAutoComplete.register(); diff --git a/src/custom-commands-auto-complete/manifest.json b/src/custom-commands-auto-complete/manifest.json new file mode 100644 index 0000000..fd4da22 --- /dev/null +++ b/src/custom-commands-auto-complete/manifest.json @@ -0,0 +1,12 @@ +{ + "enabled": true, + "requires": [], + "version": "1.0.0", + "short_name": "CustomCmds", + "name": "Custom Commands Auto Completer", + "author": "iXyles", + "description": "Adds auto-completion support for custom bot commands. Dynamically fetches commands from popular bots like Fossabot, StreamElements, NightBot, MooBot or custom bots when they are active in the channel.", + "settings": "add_ons.custom_commands_auto_complete", + "created": "2026-02-01T17:45:00.000Z", + "updated": "2026-02-01T17:45:00.000Z" +} diff --git a/src/custom-commands-auto-complete/presets.js b/src/custom-commands-auto-complete/presets.js new file mode 100644 index 0000000..b39bc1e --- /dev/null +++ b/src/custom-commands-auto-complete/presets.js @@ -0,0 +1,127 @@ +/** + * Bot API Presets + * + * Pre-configured source definitions for common bots. + * These use the same code path as custom sources. + * + * Detection: Presets must define either botNames or channelNames (or both): + * - botNames: Array of bot account names to detect in chat (e.g., ['streamelements']) + * - channelNames: Array of specific channels this source applies to (for custom sources) + * + * API Options: + * - channelMethod/commandsMethod: 'GET' (default) or 'POST' + * - channelHeaders/commandsHeaders: Custom headers object, supports {channel} and {channelId} placeholders + * - channelFormData/commandsFormData: Form data for POST requests, supports placeholders + * - prefixInName: If true, extract prefix from command name (e.g., "!ping" -> prefix="!", name="ping") + * + * These can be used as a reference for creating custom sources as well. + * + * Are you missing a common bot, or you got a custom one? Feel free to reachout, or contribute as needed! + * + * Notes: + * - Streamlabs API is not public from 'https://www.twitch.tv', which results in CORS (will not be added unless streamlabs open CORS against twitch web app). + */ + +export const PRESETS = { + streamelements: { + name: 'StreamElements', + botNames: ['streamelements'], + prefix: '!', + channelUrl: 'https://api.streamelements.com/kappa/v2/channels/{channel}', + channelIdPath: ['_id'], + commandsUrl: 'https://api.streamelements.com/kappa/v2/bot/commands/{channelId}/default', + namePath: ['command'], + descriptionPath: ['reply'], + enabledPath: ['enabled'], + aliasesPath: ['aliases'], + permissionPath: ['accessLevel'], + // SE uses numeric ranges for permission levels + permissionMapping: [ + [[100, 299], 0], // 100-299=Everyone + [[300, 399], 1], // 300-399=Subscriber + [[400, 499], 2], // 400-499=VIP + [[500, 1499], 3], // 500-1499=Moderator (including SE Mods) + [[1500, Infinity], 4] // 1500+=Broadcaster + ] + }, + + fossabot: { + name: 'Fossabot', + botNames: ['fossabot'], + prefix: '!', + channelUrl: 'https://fossabot.com/api/v2/cached/channels/by-slug/{channel}', + channelIdPath: ['channel', 'id'], + commandsUrl: 'https://fossabot.com/api/v2/cached/channels/{channelId}/commands', + commandsPath: ['commands'], + namePath: ['name'], + descriptionPath: ['response'], + enabledPath: ['enabled'], + aliasesPath: ['aliases'], + permissionPath: ['minimum_role'] + // Uses default string mapping (everyone, subscriber, vip, moderator, broadcaster) + }, + + nightbot: { + name: 'Nightbot', + botNames: ['nightbot'], + prefix: '!', + prefixInName: true, // Nightbot includes prefix in command name + channelUrl: 'https://api.nightbot.tv/1/channels/twitch/{channel}', + channelIdPath: ['channel', '_id'], + commandsUrl: 'https://api.nightbot.tv/1/commands', + commandsHeaders: { + 'Nightbot-Channel': '{channelId}' + }, + commandsPath: ['commands'], + namePath: ['name'], + descriptionPath: ['message'], + permissionPath: ['userLevel'], + permissionMapping: { + 'everyone': 0, + 'subscriber': 1, + 'regular': 2, + 'moderator': 3, + 'owner': 4 + } + }, + + moobot: { + name: 'Moobot', + botNames: ['moobot'], + prefix: '!', + channelUrl: 'https://api.moo.bot/1/channel/meta', + channelMethod: 'POST', + channelFormData: { name: '{channel}' }, + channelIdPath: ['channel', 'userid'], + commandsUrl: 'https://api.moo.bot/1/channel/public/commands/list', + commandsMethod: 'POST', + commandsFormData: { channel: '{channelId}' }, + commandsPath: ['list'], + namePath: ['command'], + descriptionPath: ['response'], + enabledPath: ['enabled'], + permissionPath: ['access'] + // Uses default string mapping + } +}; + +/** + * Validate a source configuration. + * At least one of botNames or channelNames must be defined. + * + * @param {Object} source - The source configuration to validate. + * @return {Object} Validation result with 'valid' boolean and optional 'error' message. + */ +export function validateSource(source) { + const hasBotNames = Array.isArray(source.botNames) && source.botNames.length > 0; + const hasChannelNames = Array.isArray(source.channelNames) && source.channelNames.length > 0; + + if (!hasBotNames && !hasChannelNames) { + return { + valid: false, + error: `Source "${source.name}" must define either botNames or channelNames` + }; + } + + return { valid: true }; +} diff --git a/src/custom-commands-auto-complete/sources.js b/src/custom-commands-auto-complete/sources.js new file mode 100644 index 0000000..b51f30b --- /dev/null +++ b/src/custom-commands-auto-complete/sources.js @@ -0,0 +1,170 @@ +/** + * Source Management + * + * Handles source discovery, validation, and filtering. + */ + +import { PRESETS, validateSource } from './presets'; + +/** + * Get all enabled sources (presets + custom). + * Validates sources and sorts by user-defined order. + * + * @param {Object} settings - FFZ settings instance + * @param {Object} log - Logger instance + * @returns {Array} Array of valid, sorted source definitions + */ +export function getEnabledSources(settings, log) { + const sources = []; + + // Read source order and disabled list from provider (where Vue component writes) + const sourceOrder = settings.provider + ? (settings.provider.get('addon.custom-commands.source-order') || []) + : []; + const disabledSources = settings.provider + ? (settings.provider.get('addon.custom-commands.disabled-sources') || []) + : []; + + // Add presets that are in source order AND not disabled + const presetKeys = ['streamelements', 'fossabot', 'nightbot', 'moobot']; + for (const key of presetKeys) { + if (sourceOrder.includes(key) && !disabledSources.includes(key)) { + sources.push({ ...PRESETS[key], enabled: true, key }); + } + } + + // Add custom sources + const customSources = settings.get('addon.custom-commands.custom-sources') || []; + log.debug(`Custom sources from settings: ${JSON.stringify(customSources)}`); + + for (let i = 0; i < customSources.length; i++) { + const source = customSources[i]; + if (source.enabled !== false) { + sources.push({ ...source, key: `custom-${i}`, customIndex: i }); + log.debug(`Added custom source: ${source.name}`); + } + } + + // Validate and filter sources + const validSources = sources.filter(source => { + const validation = validateSource(source); + if (!validation.valid) { + log.warn(validation.error); + return false; + } + return true; + }); + + // Sort by user-defined order + validSources.sort((a, b) => { + const aIndex = sourceOrder.indexOf(a.key); + const bIndex = sourceOrder.indexOf(b.key); + + if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + return 0; + }); + + log.debug(`Total valid sources: ${validSources.length}`); + return validSources; +} + +/** + * Filter sources to only those applicable for the current channel. + * Checks botNames (if bots are in chat) and channelNames (if channel matches). + * + * @param {Array} sources - All enabled sources + * @param {string} channelLogin - The channel login name + * @param {Object} room - FFZ room reference for bot detection + * @param {Object} log - Logger instance + * @returns {Array} Sources applicable to this channel + */ +export function filterApplicableSources(sources, channelLogin, room, log) { + const applicable = []; + const lowerChannelLogin = channelLogin.toLowerCase(); + + if (!room) { + log.warn('No room reference available - bot detection will not work'); + } + + for (const source of sources) { + const reason = checkSourceApplicability(source, lowerChannelLogin, room, log); + if (reason) { + log.debug(`${source.name}: applicable (${reason})`); + applicable.push(source); + } + } + + log.debug(`${applicable.length}/${sources.length} sources applicable for #${channelLogin}`); + return applicable; +} + +/** + * Check if a source is applicable for a channel. + * + * @param {Object} source - Source definition + * @param {string} lowerChannelLogin - Lowercase channel login + * @param {Object} room - FFZ room reference + * @param {Object} log - Logger instance + * @returns {string|null} Reason string if applicable, null if not + */ +function checkSourceApplicability(source, lowerChannelLogin, room, log) { + let reason = ''; + + // Check if any of the source's bots are present in the channel + if (source.botNames?.length > 0) { + const detectedBot = detectBotInChannel(source.botNames, room, log); + if (detectedBot) { + reason = `bot "${detectedBot}" detected`; + } + } + + // Check if this is a channel-specific source + if (source.channelNames?.length > 0) { + const isForThisChannel = source.channelNames.some( + channel => channel.toLowerCase() === lowerChannelLogin + ); + if (isForThisChannel) { + reason = reason ? `${reason} + channel match` : 'channel match'; + } + } + + return reason || null; +} + +/** + * Check if any of the specified bot names are present in the current channel. + * + * @param {Array} botNames - Array of bot usernames to check + * @param {Object} room - FFZ room reference + * @param {Object} log - Logger instance + * @returns {string|null} The detected bot name, or null if none found + */ +export function detectBotInChannel(botNames, room, log) { + if (!room) { + log.debug('No current room available for bot detection'); + return null; + } + + log.debug(`Checking for bots: ${botNames.join(', ')} in room: ${room.login}`); + + for (const botName of botNames) { + try { + const lowerBotName = botName.toLowerCase(); + + // Use getUser() - pass false to avoid creating the user if it doesn't exist + const botUser = room.getUser(null, lowerBotName, false); + if (botUser) { + log.debug(`Bot "${botName}" found in channel`); + return botName; + } + + log.debug(`Bot "${botName}" not found`); + } catch (err) { + log.warn(`Error checking for bot ${botName}: ${err.message || err}`); + } + } + + return null; +} diff --git a/src/custom-commands-auto-complete/utils.js b/src/custom-commands-auto-complete/utils.js new file mode 100644 index 0000000..8d49c47 --- /dev/null +++ b/src/custom-commands-auto-complete/utils.js @@ -0,0 +1,117 @@ +/** + * Utility Functions + * + * Pure utility functions with no external dependencies. + */ + +/** + * Truncate a description to a maximum length. + * + * @param {string} description - The description to truncate + * @param {number} maxLength - Maximum length (default 100) + * @returns {string} Truncated description + */ +export function truncateDescription(description, maxLength = 100) { + if (!description) return ''; + if (description.length <= maxLength) return description; + return `${description.substring(0, maxLength - 3)}...`; +} + +/** + * Get a value from an object using a path. + * + * Supports two formats: + * - Array: ['channel', 'id'] -> obj.channel.id (preferred, unambiguous) + * - String: 'channel.id' -> obj.channel.id (for backward compatibility) + * + * @param {Object} obj - The object to traverse + * @param {string|Array} path - Path as array or dot-notation string + * @returns {*} The value at the path, or undefined if not found + */ +export function getNestedValue(obj, path) { + if (!path) return undefined; + if (!obj || typeof obj !== 'object') return undefined; + + const keys = Array.isArray(path) ? path : path.split('.'); + + let current = obj; + for (const key of keys) { + if (current === null || current === undefined) { + return undefined; + } + current = current[key]; + } + + return current; +} + +/** + * Map permission values to standard levels (0-4). + * Supports exact matches, range-based mappings, and common string values. + * + * Custom mapping can be: + * - Object: {100: 0, 500: 3} for exact matches + * - Array: [[[100, 299], 0], [[300, 499], 1]] for ranges + * + * @param {any} value - The permission value to map + * @param {Object|Array} customMapping - Optional custom mapping + * @returns {number} Mapped permission level (0-4, everyone -> broadcaster) + */ +export function mapPermissionLevel(value, customMapping) { + if (value === undefined || value === null) return 0; + + if (customMapping) { + // Array format: [[[min, max], level], ...] + if (Array.isArray(customMapping)) { + if (typeof value === 'number') { + for (const [[min, max], level] of customMapping) { + if (value >= min && value <= max) { + return level; + } + } + } + // Object format: {value: level} + } else if (customMapping[value] !== undefined) { + return customMapping[value]; + } + } + + // Numeric values (fallback) + if (typeof value === 'number') { + return Math.min(value, 4); + } + + // Common string mappings + const mappings = { + 'everyone': 0, 'all': 0, 'public': 0, 'any': 0, + 'subscriber': 1, 'sub': 1, 'subs': 1, 'follower': 1, + 'vip': 2, 'vips': 2, 'regular': 2, + 'moderator': 3, 'mod': 3, 'mods': 3, + 'broadcaster': 4, 'owner': 4, 'streamer': 4 + }; + + return mappings[String(value).toLowerCase()] ?? 0; +} + +/** + * Sort source keys by user-defined order. + * + * @param {Array} keys - Array of source keys to sort + * @param {Array} sourceOrder - User-defined order array + * @returns {Array} Sorted keys + */ +export function sortBySourceOrder(keys, sourceOrder) { + return [...keys].sort((a, b) => { + const indexA = sourceOrder.indexOf(a); + const indexB = sourceOrder.indexOf(b); + + // Both in order: sort by position + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + // Only A in order: A first + if (indexA !== -1) return -1; + // Only B in order: B first + if (indexB !== -1) return 1; + // Neither: alphabetical + return a.localeCompare(b); + }); +}