from phBot import *
import QtBind
import builtins
import json
import os
import re
import sys
import traceback
import webbrowser
pName = 'FCharacterPluginManager'
pVersion = '1.0.0'
DISCORD_URL = 'https://discord.gg/eB9sGSMYBg'
COLOR_PRIMARY = '#5b57e0'
COLOR_TEXT = '#2b3038'
COLOR_MUTED = '#9aa0ac'
COLOR_SUCCESS = '#1f9d63'
COLOR_WARNING = '#c98a1a'
COLOR_ERROR = '#d93a4d'
HOOKS = (
'connected', 'disconnected', 'joined_game', 'teleported', 'event_loop',
'handle_chat', 'handle_event', 'handle_joymax', 'handle_silkroad',
'handle_script', 'bot_started', 'bot_stopped', 'script_finished',
'finished'
)
PLUGIN_FILE = os.path.abspath(__file__)
PLUGIN_ROOT = os.path.dirname(PLUGIN_FILE)
MANAGED_ROOT = os.path.join(PLUGIN_ROOT, pName)
_available = {}
_enabled_names = []
_contexts = {}
_namespaces = {}
_callback_aliases = {}
_active_character = ''
_profile_loaded = False
_loading = False
def fixed_width_text(content, width):
return (
'
'
).format(width, content)
gui = QtBind.init(__name__, pName)
QtBind.createLabel(
gui,
u'⚙ %s' % (COLOR_PRIMARY, pName),
12, 6
)
QtBind.createLabel(
gui, 'v%s' % (COLOR_MUTED, pVersion), 255, 12
)
btn_discord = QtBind.createButton(
gui, 'discord_clicked', u'\U0001f4ac Discord', 462, 6
)
QtBind.createLabel(
gui, u'⚜ Made By FascinaTe' % COLOR_PRIMARY,
565, 11
)
QtBind.createLineEdit(gui, '', 12, 30, 716, 1)
QtBind.createLabel(gui, 'AVAILABLE PLUGINS' % COLOR_PRIMARY, 12, 44)
QtBind.createLabel(gui, 'ENABLED FOR CHARACTER' % COLOR_PRIMARY, 374, 44)
lst_available = QtBind.createList(gui, 12, 66, 340, 176)
lst_enabled = QtBind.createList(gui, 374, 66, 340, 176)
btn_refresh = QtBind.createButton(gui, 'refresh_clicked', 'Refresh', 12, 252)
btn_add = QtBind.createButton(gui, 'add_clicked', 'Add >', 126, 252)
btn_remove = QtBind.createButton(gui, 'remove_clicked', '< Remove', 374, 252)
btn_save_apply = QtBind.createButton(gui, 'save_apply_clicked', 'Save && Apply', 554, 252)
QtBind.createLineEdit(gui, '', 12, 286, 716, 1)
QtBind.createLabel(gui, 'ACTIVE CHARACTER' % COLOR_PRIMARY, 12, 300)
lbl_character = QtBind.createLabel(
gui, fixed_width_text('Waiting for character...' % COLOR_WARNING, 330), 150, 300
)
QtBind.createLabel(gui, 'STATUS' % COLOR_PRIMARY, 12, 326)
lbl_status = QtBind.createLabel(
gui, fixed_width_text('Ready' % COLOR_MUTED, 560), 150, 326
)
QtBind.createLabel(
gui,
fixed_width_text(
'Move plugin .py files/folders into Plugins\\%s\\, then press Refresh.' %
(COLOR_MUTED, pName), 700
),
12, 354
)
def plugin_log(message):
log('[%s] %s' % (pName, message))
def set_status(message, color=COLOR_MUTED):
QtBind.setText(
gui, lbl_status,
fixed_width_text('%s' % (color, str(message)), 560)
)
def _safe_name(value):
return re.sub(r'[^A-Za-z0-9_]+', '_', str(value))
def _settings_directory():
base = get_config_dir()
return os.path.join(base, pName)
def _profiles_path():
return os.path.join(_settings_directory(), 'profiles.json')
def _load_profiles():
try:
with open(_profiles_path(), 'r', encoding='utf-8') as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (IOError, OSError, ValueError):
return {}
def _save_profile(character_key, plugin_names):
directory = _settings_directory()
if not os.path.isdir(directory):
os.makedirs(directory)
profiles = _load_profiles()
profiles[character_key] = list(plugin_names)
with open(_profiles_path(), 'w', encoding='utf-8') as handle:
json.dump(profiles, handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write('\n')
def _character_key():
try:
character = get_character_data() or {}
server = str(character.get('server') or '').strip()
name = str(character.get('name') or '').strip()
if server and name:
return '%s_%s' % (server, name)
except Exception:
pass
return ''
def _entry_name(path):
relative = os.path.relpath(path, MANAGED_ROOT).replace('\\', '/')
if relative.lower().endswith('.py'):
relative = relative[:-3]
if relative.endswith('/__init__'):
relative = relative[:-9]
return relative.strip('/')
def _scan_plugins():
result = {}
if not os.path.isdir(MANAGED_ROOT):
os.makedirs(MANAGED_ROOT)
for root, directories, files in os.walk(MANAGED_ROOT):
directories[:] = [item for item in directories if item != '__pycache__' and not item.startswith('.')]
candidates = []
if '__init__.py' in files:
candidates.append(os.path.join(root, '__init__.py'))
else:
folder_name = os.path.basename(root)
preferred = folder_name + '.py'
if preferred in files:
candidates.append(os.path.join(root, preferred))
else:
candidates.extend(
os.path.join(root, item) for item in files
if item.lower().endswith('.py') and not item.startswith('_')
)
for path in candidates:
name = _entry_name(path)
if name and name not in result:
result[name] = path
if candidates and root != MANAGED_ROOT:
directories[:] = []
return result
def _refresh_lists():
global _available
_available = _scan_plugins()
QtBind.clear(gui, lst_available)
QtBind.clear(gui, lst_enabled)
for name in sorted(_available, key=str.lower):
if name not in _enabled_names:
QtBind.append(gui, lst_available, name)
for name in _enabled_names:
QtBind.append(gui, lst_enabled, name)
def _make_gui_callback(plugin_id, callback_name):
def callback(*args):
namespace = _namespaces.get(plugin_id, {})
target = namespace.get(callback_name)
if callable(target):
try:
return target(*args)
except Exception as error:
plugin_log('%s GUI callback %s failed: %s' % (plugin_id, callback_name, error))
return None
return callback
class _QtBindProxy(object):
def __init__(self, plugin_id):
self.plugin_id = plugin_id
def __getattr__(self, name):
return getattr(QtBind, name)
def _alias(self, callback_name):
callback_name = str(callback_name)
alias = '_fcpm_%s_%s' % (_safe_name(self.plugin_id), _safe_name(callback_name))
if alias not in _callback_aliases:
globals()[alias] = _make_gui_callback(self.plugin_id, callback_name)
_callback_aliases[alias] = self.plugin_id
return alias
def createButton(self, target_gui, callback_name, text, x, y):
return QtBind.createButton(target_gui, self._alias(callback_name), text, x, y)
def createCheckBox(self, target_gui, callback_name, text, x, y):
return QtBind.createCheckBox(target_gui, self._alias(callback_name), text, x, y)
def _plugin_builtins(plugin_id):
values = dict(vars(builtins))
original_import = builtins.__import__
qt_proxy = _QtBindProxy(plugin_id)
def managed_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == 'QtBind':
return qt_proxy
return original_import(name, globals, locals, fromlist, level)
values['__import__'] = managed_import
return values
def _load_plugin(plugin_id, path):
if plugin_id in _contexts:
return True
try:
with open(path, 'r', encoding='utf-8-sig') as handle:
source = handle.read()
namespace = {
'__name__': __name__,
'__file__': path,
'__package__': None,
'__builtins__': _plugin_builtins(plugin_id)
}
_namespaces[plugin_id] = namespace
plugin_directory = os.path.dirname(path)
inserted_path = plugin_directory not in sys.path
if inserted_path:
sys.path.insert(0, plugin_directory)
try:
exec(compile(source, path, 'exec'), namespace)
finally:
if inserted_path:
try:
sys.path.remove(plugin_directory)
except ValueError:
pass
context = {}
for hook in HOOKS:
target = namespace.get(hook)
if callable(target):
context[hook] = target
_contexts[plugin_id] = context
plugin_log('Loaded managed plugin: %s' % plugin_id)
return True
except Exception as error:
_namespaces.pop(plugin_id, None)
plugin_log('Could not load %s: %s' % (plugin_id, error))
plugin_log(traceback.format_exc())
return False
def _stop_plugin(plugin_id):
context = _contexts.pop(plugin_id, {})
callback = context.get('finished')
if callable(callback):
try:
callback()
except Exception as error:
plugin_log('%s cleanup failed: %s' % (plugin_id, error))
_namespaces.pop(plugin_id, None)
for alias, owner in list(_callback_aliases.items()):
if owner == plugin_id:
_callback_aliases.pop(alias, None)
globals().pop(alias, None)
def _apply_enabled_plugins():
global _loading
if _loading:
return
_loading = True
try:
for plugin_id in list(_contexts):
if plugin_id not in _enabled_names:
_stop_plugin(plugin_id)
loaded = 0
failed = []
for plugin_id in _enabled_names:
path = _available.get(plugin_id)
if not path:
failed.append(plugin_id)
elif _load_plugin(plugin_id, path):
loaded += 1
else:
failed.append(plugin_id)
if failed:
set_status('Loaded %d plugin(s); failed/missing: %s' % (loaded, ', '.join(failed)), COLOR_ERROR)
else:
set_status('Loaded %d plugin(s) for %s' % (loaded, _active_character), COLOR_SUCCESS)
finally:
_loading = False
def _activate_character_profile():
global _active_character, _profile_loaded, _enabled_names
character_key = _character_key()
if not character_key or (_profile_loaded and character_key == _active_character):
return False
for plugin_id in list(_contexts):
_stop_plugin(plugin_id)
_active_character = character_key
_profile_loaded = True
profiles = _load_profiles()
saved = profiles.get(character_key, [])
_enabled_names = [str(item) for item in saved if isinstance(item, str)]
QtBind.setText(
gui, lbl_character,
fixed_width_text('%s' % (COLOR_TEXT, character_key), 330)
)
_refresh_lists()
_apply_enabled_plugins()
return True
def _dispatch(name, *args):
results = []
for plugin_id, context in list(_contexts.items()):
callback = context.get(name)
if callable(callback):
try:
results.append(callback(*args))
except Exception as error:
plugin_log('%s hook %s failed: %s' % (plugin_id, name, error))
return results
def discord_clicked():
try:
webbrowser.open(DISCORD_URL)
set_status('Opening Discord invite...', COLOR_SUCCESS)
except Exception as error:
plugin_log('Discord link error: %s' % error)
set_status('Could not open Discord invite', COLOR_ERROR)
def refresh_clicked():
_refresh_lists()
set_status('Found %d managed plugin(s)' % len(_available), COLOR_SUCCESS)
def add_clicked():
selected = str(QtBind.text(gui, lst_available) or '')
if selected and selected in _available and selected not in _enabled_names:
_enabled_names.append(selected)
_refresh_lists()
def remove_clicked():
selected = str(QtBind.text(gui, lst_enabled) or '')
if selected in _enabled_names:
_enabled_names.remove(selected)
_refresh_lists()
def save_apply_clicked():
if not _active_character:
set_status('Enter the game with a character first', COLOR_WARNING)
return
try:
_save_profile(_active_character, _enabled_names)
_apply_enabled_plugins()
except Exception as error:
plugin_log('Could not save profile: %s' % error)
set_status('Could not save character profile', COLOR_ERROR)
def connected():
_dispatch('connected')
def disconnected():
_dispatch('disconnected')
def joined_game():
_dispatch('joined_game')
def teleported():
newly_loaded = _activate_character_profile()
if newly_loaded:
_dispatch('joined_game')
_dispatch('teleported')
def event_loop():
_activate_character_profile()
_dispatch('event_loop')
def handle_chat(t, player, msg):
_dispatch('handle_chat', t, player, msg)
def handle_event(t, data):
_dispatch('handle_event', t, data)
def handle_joymax(opcode, data):
return False not in _dispatch('handle_joymax', opcode, data)
def handle_silkroad(opcode, data):
return False not in _dispatch('handle_silkroad', opcode, data)
def handle_script(command, args):
return True in _dispatch('handle_script', command, args)
def bot_started():
_dispatch('bot_started')
def bot_stopped():
_dispatch('bot_stopped')
def script_finished():
_dispatch('script_finished')
def finished():
for plugin_id in list(_contexts):
_stop_plugin(plugin_id)
_refresh_lists()
log('[%s] Loaded - ⚜ Made By FascinaTe' % pName)