From 25fbc1a8128bb93a0703bc2d0d91c1c4994cb7d7 Mon Sep 17 00:00:00 2001 From: Zachary Daniels Date: Tue, 7 Jul 2026 19:51:44 -0500 Subject: [PATCH 1/4] feat: discord webhook fit sharing --- gui/builtinPreferenceViews/__init__.py | 4 +- .../pyfaDiscordPreferences.py | 75 +++++++++++++ gui/mainFrame.py | 25 +++++ gui/mainMenuBar.py | 18 +++ gui/preferenceView.py | 3 +- imgs/gui/prefs_discord.png | Bin 0 -> 1069 bytes service/discord.py | 104 ++++++++++++++++++ service/settings.py | 29 +++++ 8 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 gui/builtinPreferenceViews/pyfaDiscordPreferences.py create mode 100644 imgs/gui/prefs_discord.png create mode 100644 service/discord.py diff --git a/gui/builtinPreferenceViews/__init__.py b/gui/builtinPreferenceViews/__init__.py index e3485d555d..7c4eef4562 100644 --- a/gui/builtinPreferenceViews/__init__.py +++ b/gui/builtinPreferenceViews/__init__.py @@ -8,5 +8,5 @@ "pyfaEnginePreferences", "pyfaEsiPreferences", "pyfaStatViewPreferences", - "pyfaMarketPreferences"] - + "pyfaMarketPreferences", + "pyfaDiscordPreferences"] diff --git a/gui/builtinPreferenceViews/pyfaDiscordPreferences.py b/gui/builtinPreferenceViews/pyfaDiscordPreferences.py new file mode 100644 index 0000000000..d642e87428 --- /dev/null +++ b/gui/builtinPreferenceViews/pyfaDiscordPreferences.py @@ -0,0 +1,75 @@ +# noinspection PyPackageRequirements +import wx + +from gui.preferenceView import PreferenceView +from gui.bitmap_loader import BitmapLoader + +from service.settings import DiscordSettings + +_t = wx.GetTranslation + + +class PFDiscordPref(PreferenceView): + + def populatePanel(self, panel): + self.title = _t("Discord") + self.settings = DiscordSettings.getInstance() + + mainSizer = wx.BoxSizer(wx.VERTICAL) + + dlgWidth = panel.GetParent().GetParent().ClientSize.width + + self.stTitle = wx.StaticText(panel, wx.ID_ANY, self.title, wx.DefaultPosition, wx.DefaultSize, 0) + self.stTitle.Wrap(-1) + self.stTitle.SetFont(wx.Font(12, 70, 90, 90, False, wx.EmptyString)) + mainSizer.Add(self.stTitle, 0, wx.EXPAND | wx.ALL, 5) + + self.m_staticline1 = wx.StaticLine(panel, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.LI_HORIZONTAL) + mainSizer.Add(self.m_staticline1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 5) + + desc = _t("Enable or disable Discord integration features in pyfa.") + self.stDesc = wx.StaticText(panel, wx.ID_ANY, desc, wx.DefaultPosition, wx.DefaultSize, 0) + self.stDesc.Wrap(dlgWidth - 50) + mainSizer.Add(self.stDesc, 0, wx.ALL, 5) + + self.cbEnableDiscord = wx.CheckBox(panel, wx.ID_ANY, _t("Enable Discord Integration"), wx.DefaultPosition, wx.DefaultSize, 0) + self.cbEnableDiscord.SetValue(bool(self.settings.get('enableDiscord'))) + self.cbEnableDiscord.Bind(wx.EVT_CHECKBOX, self.OnCBEnableChange) + mainSizer.Add(self.cbEnableDiscord, 0, wx.ALL | wx.EXPAND, 5) + + webhookSizer = wx.BoxSizer(wx.HORIZONTAL) + + self.stWebhookURL = wx.StaticText(panel, wx.ID_ANY, _t("Webhook URL:"), wx.DefaultPosition, wx.DefaultSize, 0) + self.stWebhookURL.Wrap(-1) + webhookSizer.Add(self.stWebhookURL, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5) + + webhookValue = self.settings.get('webhookUrl') or "" + self.editWebhookURL = wx.TextCtrl(panel, wx.ID_ANY, webhookValue, wx.DefaultPosition, wx.DefaultSize, 0) + self.editWebhookURL.Bind(wx.EVT_TEXT, self.OnWebhookUrlText) + webhookSizer.Add(self.editWebhookURL, 1, wx.ALL | wx.EXPAND, 5) + + mainSizer.Add(webhookSizer, 0, wx.ALL | wx.EXPAND, 0) + + self.ToggleWebhookSettings(self.cbEnableDiscord.GetValue()) + + panel.SetSizer(mainSizer) + panel.Layout() + + + def OnCBEnableChange(self, event): + self.settings.set('enableDiscord', self.cbEnableDiscord.GetValue()) + self.ToggleWebhookSettings(self.cbEnableDiscord.GetValue()) + + def OnWebhookUrlText(self, event): + self.settings.set('webhookUrl', self.editWebhookURL.GetValue().strip()) + + def ToggleWebhookSettings(self, enable): + self.stWebhookURL.Enable(enable) + self.editWebhookURL.Enable(enable) + + def getImage(self): + return BitmapLoader.getBitmap("prefs_discord", "gui") + + +PFDiscordPref.register() + diff --git a/gui/mainFrame.py b/gui/mainFrame.py index 44bfc5c496..a092b8109d 100644 --- a/gui/mainFrame.py +++ b/gui/mainFrame.py @@ -63,6 +63,7 @@ from gui.utils.clipboard import fromClipboard from gui.utils.progressHelper import ProgressHelper from service.character import Character +from service.discord import Discord, DiscordWebhookError from service.esi import Esi from service.fit import Fit from service.port import Port @@ -485,6 +486,7 @@ def OnShowExportDialog(self, event): def OnShowPreferenceDialog(self, event): with PreferenceDialog(self) as dlg: dlg.ShowModal() + self.GetMenuBar().refreshDiscordMenuVisibility() @staticmethod def goWiki(event): @@ -516,6 +518,8 @@ def registerMenu(self): self.Bind(wx.EVT_MENU, self.fileImportDialog, id=wx.ID_OPEN) # Export dialog self.Bind(wx.EVT_MENU, self.OnShowExportDialog, id=wx.ID_SAVEAS) + # Share active fit to Discord webhook + self.Bind(wx.EVT_MENU, self.shareFitToDiscord, id=menuBar.shareToDiscordId) # Import from Clipboard self.Bind(wx.EVT_MENU, self.importFromClipboard, id=wx.ID_PASTE) # Backup fits @@ -802,6 +806,27 @@ def exportToClipboard(self, event): with CopySelectDialog(self) as dlg: dlg.ShowModal() + def shareFitToDiscord(self, event): + activeFitID = self.getActiveFit() + if activeFitID is None: + return + + fit = Fit.getInstance().getFit(activeFitID) + if fit is None: + with wx.MessageDialog(self, _t("No active fit found."), _t("Discord Webhook"), wx.ICON_ERROR) as dlg: + dlg.ShowModal() + return + + try: + Discord.getInstance().sendFit(fit) + except DiscordWebhookError as e: + with wx.MessageDialog(self, str(e), _t("Discord Webhook"), wx.ICON_ERROR) as dlg: + dlg.ShowModal() + return + + with wx.MessageDialog(self, _t("Fit sent to Discord webhook."), _t("Discord Webhook"), wx.ICON_INFORMATION) as dlg: + dlg.ShowModal() + def exportSkillsNeeded(self, event): """ Exports skills needed for active fit and active character """ sCharacter = Character.getInstance() diff --git a/gui/mainMenuBar.py b/gui/mainMenuBar.py index ef677575dd..f845592c4b 100644 --- a/gui/mainMenuBar.py +++ b/gui/mainMenuBar.py @@ -24,6 +24,7 @@ import graphs from service.character import Character from service.fit import Fit +from service.settings import DiscordSettings import gui.globalEvents as GE from gui.bitmap_loader import BitmapLoader @@ -57,6 +58,7 @@ def __init__(self, mainFrame): self.toggleIgnoreRestrictionID = wx.NewId() self.devToolsId = wx.NewId() self.optimizeFitPrice = wx.NewId() + self.shareToDiscordId = wx.NewId() self.mainFrame = mainFrame wx.MenuBar.__init__(self) @@ -78,6 +80,7 @@ def __init__(self, mainFrame): # Fit menu fitMenu = wx.Menu() + self.fitMenu = fitMenu self.Append(fitMenu, _t("Fi&t")) fitMenu.Append(wx.ID_UNDO, _t("&Undo") + "\tCTRL+Z", _t("Undo the most recent action")) @@ -168,6 +171,19 @@ def __init__(self, mainFrame): self.mainFrame.Bind(GE.FIT_CHANGED, self.fitChanged) self.mainFrame.Bind(GE.FIT_RENAMED, self.fitRenamed) + self.refreshDiscordMenuVisibility() + + def refreshDiscordMenuVisibility(self): + discordEnabled = bool(DiscordSettings.getInstance().get('enableDiscord')) + existingItem = self.fitMenu.FindItemById(self.shareToDiscordId) + + if discordEnabled and existingItem is None: + optimizeItem = self.fitMenu.FindItemById(self.optimizeFitPrice) + menuItems = self.fitMenu.GetMenuItems() + insertPos = menuItems.index(optimizeItem) if optimizeItem in menuItems else len(menuItems) + self.fitMenu.Insert(insertPos, self.shareToDiscordId, _t("Share Fit to &Discord"), _t("Share active fit to Discord webhook")) + elif not discordEnabled and existingItem is not None: + self.fitMenu.Remove(existingItem) def fitChanged(self, event): event.Skip() @@ -177,6 +193,8 @@ def fitChanged(self, event): enable = activeFitID is not None self.Enable(wx.ID_SAVEAS, enable) self.Enable(wx.ID_COPY, enable) + if self.fitMenu.FindItemById(self.shareToDiscordId) is not None: + self.Enable(self.shareToDiscordId, enable) self.Enable(self.exportSkillsNeededId, enable) self.refreshUndo() diff --git a/gui/preferenceView.py b/gui/preferenceView.py index 0e5f8b3692..131318912d 100644 --- a/gui/preferenceView.py +++ b/gui/preferenceView.py @@ -50,5 +50,6 @@ def getImage(self): pyfaUpdatePreferences, pyfaEnginePreferences, pyfaDatabasePreferences, - pyfaLoggingPreferences + pyfaLoggingPreferences, + pyfaDiscordPreferences ) diff --git a/imgs/gui/prefs_discord.png b/imgs/gui/prefs_discord.png new file mode 100644 index 0000000000000000000000000000000000000000..cf9ec1889c7a1eb5c49cbc5755722fbde7d5fba3 GIT binary patch literal 1069 zcmV+|1k(G7P)zvdy>;lcX#@IJ-f}E%Vswzr4)LY|D5kO^ZjS$ zJ983T;O7#7i)#l)M$FowNi#US&&&!;2y;XG%+Tnlsb5K1RkZ_|DN`%S4lOov(lPW) zf)+uzH94^B#2V%$_P3$;OhY&zPOnH{c+zw>x@2YWErM#>Mkj|WyY+OX0m~A2W5V=f z_^8q(<@~Z+W3P`c>?!A0dqTT743C>m<1iTKtJan~<*}G`pC8=)?{nqG#1hamC%O8S zYqDZ;6~Q=fXGZ?N+*g8qECD(5AA(_(HAgD26ctEbG$zO&DT;wtgn)i(5)oT)Elsd< zJ6zia8#`d@t#H?EaQz0jCJj$K46Q9^wq2%(wD!!Kb23BF9wCrz;FVyAuXRY$ZCl|o z`O3H72M^r?cL>cbH^YN>L)QbauGVAI2A9p7{4wD^S##>Guq{GBw(^B=km{ZHLYu5I znN%$b&-0j{cWGY_w+pFggp`7zD{PMtkbq`EpslTia)~uile?~~;#s7wJS*Blovo-G zN5D~sjV(9X8XWiu4*%-gH2KkKmv4Ub^-_PQ)JI9S-^R$tZ5iSSSZ-gu@jCyleJ&5* zAA-*hfF{?KfA%%}G7Tr@HNr>hRsOqDUloD+bxYIKNDb(5tVw!ka`mL-&~)1xt=nH} zNnH^*j@*xyHIBfX<&PD+ANAOA|I(R1vJJW)2PJ{^B%-tHKgEiGXb6l4B1Fk9N+)er zs2x7Kv~n>8+TSzq=Rdy5m@`%HE5+V{PY68`0*OX4gq}U`!@-}Sq0XbV+2z}VF!lkw z`>~I)Dfsj&xr9>C-U`!yz^M3@ggsS~e2=ZG93k-1V~&TRSmPSvFYq_z@63~GIJ?r-tNM0eb)VB21wHZ0{^l~dN zO;eG824;F&j>}3qY8Psn. +# ============================================================================= + +import requests +from logbook import Logger + +from service.const import PortEftOptions +from service.port.eft import exportEft +from service.settings import DiscordSettings, NetworkSettings + + +pyfalog = Logger(__name__) + + +class DiscordWebhookError(Exception): + pass + + +class Discord: + + _instance = None + + @classmethod + def getInstance(cls): + if cls._instance is None: + cls._instance = Discord() + return cls._instance + + def __init__(self): + self.settings = DiscordSettings.getInstance() + self.networkSettings = NetworkSettings.getInstance() + + def sendFit(self, fit): + if not self.settings.get('enableDiscord'): + raise DiscordWebhookError('Discord integration is disabled. Enable it in Preferences > Discord.') + + webhookUrl = (self.settings.get('webhookUrl') or '').strip() + if not webhookUrl: + raise DiscordWebhookError('Discord webhook URL is empty. Set it in Preferences > Discord.') + + payload = self._buildWebhookPayload(fit) + proxies = self.networkSettings.getProxySettingsInRequestsFormat() + + try: + response = requests.post(webhookUrl, json=payload, proxies=proxies, timeout=5) + response.raise_for_status() + except requests.exceptions.RequestException: + pyfalog.warning('Discord webhook request failed.') + raise DiscordWebhookError('Failed to send fit to Discord webhook. Check webhook URL and network settings.') + + def _buildWebhookPayload(self, fit): + shipTypeId = fit.ship.item.ID + + options = { + PortEftOptions.LOADED_CHARGES: True, + PortEftOptions.MUTATIONS: True, + PortEftOptions.IMPLANTS: True, + PortEftOptions.BOOSTERS: True, + PortEftOptions.CARGO: True, + } + fitText = exportEft(fit, options, callback=None) + shipTypeLine = 'Ship Type: {}\n'.format(fit.ship.item.typeName) + maxCodeBlockLen = 4096 - len(shipTypeLine) - 1 + title = 'Pyfa Export' + description = '{}\n{}'.format(shipTypeLine, self._wrapCodeBlock(fitText, maxCodeBlockLen)) + + return { + 'embeds': [ + { + 'title': title, + 'description': description, + 'thumbnail': { + 'url': self._getShipImageUrl(shipTypeId) + } + } + ] + } + + @staticmethod + def _getShipImageUrl(typeId): + return 'https://images.evetech.net/types/{}/render?size=64'.format(typeId) + + @staticmethod + def _wrapCodeBlock(text, maxLength): + maxContentLength = maxLength - len('```') - len('```') + if len(text) > maxContentLength: + text = '{}...'.format(text[:maxContentLength - 3]) + return '```{}\n```'.format(text) diff --git a/service/settings.py b/service/settings.py index 9d24469033..095f7c784a 100644 --- a/service/settings.py +++ b/service/settings.py @@ -600,3 +600,32 @@ def set(self, key, value): if key == 'locale' and value not in self.supported_languages(): self.settings[key] = self.DEFAULT self.settings[key] = value + +class DiscordSettings: + + _instance = None + + @classmethod + def getInstance(cls): + if cls._instance is None: + cls._instance = DiscordSettings() + return cls._instance + + def __init__(self): + defaults = { + 'enableDiscord': False, + 'webhookUrl': ''} + self.settings = SettingsProvider.getInstance().getSettings('discordSettings', defaults) + + def get(self, type): + return self.settings[type] + + def set(self, type, value): + self.settings[type] = value + self.settings.save() + + def getRedacted(self): + return { + 'enableDiscord': bool(self.settings['enableDiscord']), + 'webhookUrl': '' if self.settings['webhookUrl'] else '' + } \ No newline at end of file From ce7474293ee3d36f07ba017bbe8185db0bf75aa1 Mon Sep 17 00:00:00 2001 From: Zachary Daniels Date: Tue, 7 Jul 2026 20:00:24 -0500 Subject: [PATCH 2/4] refactor: add input validation and unit test discord feature --- .../pyfaDiscordPreferences.py | 21 +++++++ gui/mainFrame.py | 9 ++- gui/mainMenuBar.py | 19 ++++--- gui/menu_utils.py | 10 ++++ service/discord.py | 36 +++++++++++- service/settings.py | 6 +- .../test_gui/test_mainMenuBar_discord.py | 57 +++++++++++++++++++ .../test_modules/test_service/test_discord.py | 35 ++++++++++++ 8 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 gui/menu_utils.py create mode 100644 tests/test_modules/test_gui/test_mainMenuBar_discord.py create mode 100644 tests/test_modules/test_service/test_discord.py diff --git a/gui/builtinPreferenceViews/pyfaDiscordPreferences.py b/gui/builtinPreferenceViews/pyfaDiscordPreferences.py index d642e87428..61788cab9e 100644 --- a/gui/builtinPreferenceViews/pyfaDiscordPreferences.py +++ b/gui/builtinPreferenceViews/pyfaDiscordPreferences.py @@ -4,6 +4,7 @@ from gui.preferenceView import PreferenceView from gui.bitmap_loader import BitmapLoader +from service.discord import Discord from service.settings import DiscordSettings _t = wx.GetTranslation @@ -50,7 +51,17 @@ def populatePanel(self, panel): mainSizer.Add(webhookSizer, 0, wx.ALL | wx.EXPAND, 0) + self.stWebhookValidation = wx.StaticText(panel, wx.ID_ANY, _t("Webhook URL format is invalid."), wx.DefaultPosition, wx.DefaultSize, 0) + self.stWebhookValidation.SetForegroundColour(wx.Colour(180, 40, 40)) + mainSizer.Add(self.stWebhookValidation, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 5) + + self.cbConfirmBeforeSend = wx.CheckBox(panel, wx.ID_ANY, _t("Confirm before sending fit to Discord"), wx.DefaultPosition, wx.DefaultSize, 0) + self.cbConfirmBeforeSend.SetValue(bool(self.settings.get('confirmBeforeSend'))) + self.cbConfirmBeforeSend.Bind(wx.EVT_CHECKBOX, self.OnConfirmBeforeSendChange) + mainSizer.Add(self.cbConfirmBeforeSend, 0, wx.ALL | wx.EXPAND, 5) + self.ToggleWebhookSettings(self.cbEnableDiscord.GetValue()) + self.UpdateWebhookValidationState() panel.SetSizer(mainSizer) panel.Layout() @@ -62,10 +73,20 @@ def OnCBEnableChange(self, event): def OnWebhookUrlText(self, event): self.settings.set('webhookUrl', self.editWebhookURL.GetValue().strip()) + self.UpdateWebhookValidationState() + + def OnConfirmBeforeSendChange(self, event): + self.settings.set('confirmBeforeSend', self.cbConfirmBeforeSend.GetValue()) def ToggleWebhookSettings(self, enable): self.stWebhookURL.Enable(enable) self.editWebhookURL.Enable(enable) + self.cbConfirmBeforeSend.Enable(enable) + + def UpdateWebhookValidationState(self): + webhookValue = self.editWebhookURL.GetValue().strip() + isValid = webhookValue == '' or Discord.isValidWebhookUrl(webhookValue) + self.stWebhookValidation.Show(not isValid) def getImage(self): return BitmapLoader.getBitmap("prefs_discord", "gui") diff --git a/gui/mainFrame.py b/gui/mainFrame.py index a092b8109d..54e1d2d27f 100644 --- a/gui/mainFrame.py +++ b/gui/mainFrame.py @@ -68,7 +68,7 @@ from service.fit import Fit from service.port import Port from service.price import Price -from service.settings import HTMLExportSettings, SettingsProvider +from service.settings import DiscordSettings, HTMLExportSettings, SettingsProvider from service.update import Update _t = wx.GetTranslation @@ -817,6 +817,13 @@ def shareFitToDiscord(self, event): dlg.ShowModal() return + dSettings = DiscordSettings.getInstance() + if dSettings.get('confirmBeforeSend'): + preview = _t("Send this fit to Discord?\n\n{0} ({1})").format(fit.name, fit.ship.item.typeName) + with wx.MessageDialog(self, preview, _t("Confirm Discord Share"), wx.YES_NO | wx.ICON_QUESTION) as dlg: + if dlg.ShowModal() != wx.ID_YES: + return + try: Discord.getInstance().sendFit(fit) except DiscordWebhookError as e: diff --git a/gui/mainMenuBar.py b/gui/mainMenuBar.py index f845592c4b..a3bd87bb16 100644 --- a/gui/mainMenuBar.py +++ b/gui/mainMenuBar.py @@ -26,6 +26,7 @@ from service.fit import Fit from service.settings import DiscordSettings import gui.globalEvents as GE +from gui.menu_utils import syncDiscordShareMenuVisibility from gui.bitmap_loader import BitmapLoader from logbook import Logger @@ -33,6 +34,8 @@ pyfalog = Logger(__name__) _t = wx.GetTranslation + + class MainMenuBar(wx.MenuBar): def __init__(self, mainFrame): pyfalog.debug("Initialize MainMenuBar") @@ -175,15 +178,13 @@ def __init__(self, mainFrame): def refreshDiscordMenuVisibility(self): discordEnabled = bool(DiscordSettings.getInstance().get('enableDiscord')) - existingItem = self.fitMenu.FindItemById(self.shareToDiscordId) - - if discordEnabled and existingItem is None: - optimizeItem = self.fitMenu.FindItemById(self.optimizeFitPrice) - menuItems = self.fitMenu.GetMenuItems() - insertPos = menuItems.index(optimizeItem) if optimizeItem in menuItems else len(menuItems) - self.fitMenu.Insert(insertPos, self.shareToDiscordId, _t("Share Fit to &Discord"), _t("Share active fit to Discord webhook")) - elif not discordEnabled and existingItem is not None: - self.fitMenu.Remove(existingItem) + syncDiscordShareMenuVisibility( + fitMenu=self.fitMenu, + shareToDiscordId=self.shareToDiscordId, + optimizeFitPriceId=self.optimizeFitPrice, + discordEnabled=discordEnabled, + menuText=_t("Share Fit to &Discord"), + menuHelp=_t("Share active fit to Discord webhook")) def fitChanged(self, event): event.Skip() diff --git a/gui/menu_utils.py b/gui/menu_utils.py new file mode 100644 index 0000000000..40cb8a2ef5 --- /dev/null +++ b/gui/menu_utils.py @@ -0,0 +1,10 @@ +def syncDiscordShareMenuVisibility(fitMenu, shareToDiscordId, optimizeFitPriceId, discordEnabled, menuText, menuHelp): + existingItem = fitMenu.FindItemById(shareToDiscordId) + + if discordEnabled and existingItem is None: + optimizeItem = fitMenu.FindItemById(optimizeFitPriceId) + menuItems = fitMenu.GetMenuItems() + insertPos = menuItems.index(optimizeItem) if optimizeItem in menuItems else len(menuItems) + fitMenu.Insert(insertPos, shareToDiscordId, menuText, menuHelp) + elif not discordEnabled and existingItem is not None: + fitMenu.Remove(existingItem) diff --git a/service/discord.py b/service/discord.py index ee72db3f12..9984f2307f 100644 --- a/service/discord.py +++ b/service/discord.py @@ -18,10 +18,10 @@ # ============================================================================= import requests +from urllib.parse import urlparse from logbook import Logger from service.const import PortEftOptions -from service.port.eft import exportEft from service.settings import DiscordSettings, NetworkSettings @@ -53,6 +53,8 @@ def sendFit(self, fit): webhookUrl = (self.settings.get('webhookUrl') or '').strip() if not webhookUrl: raise DiscordWebhookError('Discord webhook URL is empty. Set it in Preferences > Discord.') + if not self.isValidWebhookUrl(webhookUrl): + raise DiscordWebhookError('Discord webhook URL format is invalid. Set a valid Discord webhook URL in Preferences > Discord.') payload = self._buildWebhookPayload(fit) proxies = self.networkSettings.getProxySettingsInRequestsFormat() @@ -65,6 +67,8 @@ def sendFit(self, fit): raise DiscordWebhookError('Failed to send fit to Discord webhook. Check webhook URL and network settings.') def _buildWebhookPayload(self, fit): + from service.port.eft import exportEft + shipTypeId = fit.ship.item.ID options = { @@ -96,6 +100,36 @@ def _buildWebhookPayload(self, fit): def _getShipImageUrl(typeId): return 'https://images.evetech.net/types/{}/render?size=64'.format(typeId) + @staticmethod + def isValidWebhookUrl(url): + if not url: + return False + + parsed = urlparse(url.strip()) + if parsed.scheme != 'https' or not parsed.hostname: + return False + + validHosts = { + 'discord.com', + 'ptb.discord.com', + 'canary.discord.com', + 'discordapp.com', + 'ptb.discordapp.com', + 'canary.discordapp.com' + } + if parsed.hostname.lower() not in validHosts: + return False + + if not parsed.path.startswith('/api/webhooks/'): + return False + + webhookPathPart = parsed.path[len('/api/webhooks/'):].strip('/') + pathParts = webhookPathPart.split('/') + if len(pathParts) < 2: + return False + + return True + @staticmethod def _wrapCodeBlock(text, maxLength): maxContentLength = maxLength - len('```') - len('```') diff --git a/service/settings.py b/service/settings.py index 095f7c784a..e46d0236d9 100644 --- a/service/settings.py +++ b/service/settings.py @@ -614,7 +614,8 @@ def getInstance(cls): def __init__(self): defaults = { 'enableDiscord': False, - 'webhookUrl': ''} + 'webhookUrl': '', + 'confirmBeforeSend': False} self.settings = SettingsProvider.getInstance().getSettings('discordSettings', defaults) def get(self, type): @@ -627,5 +628,6 @@ def set(self, type, value): def getRedacted(self): return { 'enableDiscord': bool(self.settings['enableDiscord']), - 'webhookUrl': '' if self.settings['webhookUrl'] else '' + 'webhookUrl': '' if self.settings['webhookUrl'] else '', + 'confirmBeforeSend': bool(self.settings['confirmBeforeSend']) } \ No newline at end of file diff --git a/tests/test_modules/test_gui/test_mainMenuBar_discord.py b/tests/test_modules/test_gui/test_mainMenuBar_discord.py new file mode 100644 index 0000000000..29b51601c0 --- /dev/null +++ b/tests/test_modules/test_gui/test_mainMenuBar_discord.py @@ -0,0 +1,57 @@ +# Add root folder to python paths +import os +import sys + +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.realpath(os.path.join(script_dir, '..', '..', '..'))) + +from gui.menu_utils import syncDiscordShareMenuVisibility + + +class FakeMenuItem: + def __init__(self, item_id): + self.item_id = item_id + + +class FakeMenu: + def __init__(self): + self.items = [] + + def FindItemById(self, item_id): + for item in self.items: + if item.item_id == item_id: + return item + return None + + def GetMenuItems(self): + return list(self.items) + + def Insert(self, index, item_id, _text, _help): + item = FakeMenuItem(item_id) + self.items.insert(index, item) + return item + + def Remove(self, item): + self.items.remove(item) + + +def test_sync_discord_share_menu_visibility_adds_menu_item_when_enabled(): + fit_menu = FakeMenu() + optimize_id = 10 + share_id = 20 + fit_menu.items = [FakeMenuItem(optimize_id)] + + syncDiscordShareMenuVisibility(fit_menu, share_id, optimize_id, True, 'Share', 'Help') + + assert fit_menu.FindItemById(share_id) is not None + + +def test_sync_discord_share_menu_visibility_removes_menu_item_when_disabled(): + fit_menu = FakeMenu() + optimize_id = 10 + share_id = 20 + fit_menu.items = [FakeMenuItem(optimize_id), FakeMenuItem(share_id)] + + syncDiscordShareMenuVisibility(fit_menu, share_id, optimize_id, False, 'Share', 'Help') + + assert fit_menu.FindItemById(share_id) is None diff --git a/tests/test_modules/test_service/test_discord.py b/tests/test_modules/test_service/test_discord.py new file mode 100644 index 0000000000..e6bd5429f5 --- /dev/null +++ b/tests/test_modules/test_service/test_discord.py @@ -0,0 +1,35 @@ +# Add root folder to python paths +import os +import sys + +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.realpath(os.path.join(script_dir, '..', '..', '..'))) + +from service.discord import Discord +from service.settings import DiscordSettings + + +def test_discord_webhook_url_validation_accepts_valid_discord_url(): + assert Discord.isValidWebhookUrl('https://discord.com/api/webhooks/123456/token-value') + + +def test_discord_webhook_url_validation_rejects_invalid_urls(): + assert not Discord.isValidWebhookUrl('') + assert not Discord.isValidWebhookUrl('http://discord.com/api/webhooks/123456/token-value') + assert not Discord.isValidWebhookUrl('https://example.com/api/webhooks/123456/token-value') + assert not Discord.isValidWebhookUrl('https://discord.com/api/webhooks/123456') + + +def test_discord_settings_get_redacted_masks_webhook_url(): + dsettings = DiscordSettings.__new__(DiscordSettings) + dsettings.settings = { + 'enableDiscord': True, + 'webhookUrl': 'https://discord.com/api/webhooks/123456/token-value', + 'confirmBeforeSend': True + } + + redacted = dsettings.getRedacted() + + assert redacted['enableDiscord'] is True + assert redacted['webhookUrl'] == '' + assert redacted['confirmBeforeSend'] is True From 8185ca075cf58a277439ae05703cc1e5764afec2 Mon Sep 17 00:00:00 2001 From: Zachary Daniels Date: Tue, 7 Jul 2026 20:06:51 -0500 Subject: [PATCH 3/4] refactor: mitigate discord settings risks --- gui/errorDialog.py | 13 ++++++++++--- gui/mainFrame.py | 8 ++++++++ gui/mainMenuBar.py | 5 ++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/gui/errorDialog.py b/gui/errorDialog.py index 769821091a..855f854550 100644 --- a/gui/errorDialog.py +++ b/gui/errorDialog.py @@ -18,6 +18,7 @@ # =============================================================================== import datetime +import re import sys import traceback @@ -37,18 +38,24 @@ class ErrorHandler: __parent = None __frame = None + WEBHOOK_RE = re.compile(r'https://(?:ptb\\.|canary\\.)?(?:discord(?:app)?\\.com)/api/webhooks/\\S+', re.IGNORECASE) + + @classmethod + def _sanitizeSensitiveData(cls, text): + return cls.WEBHOOK_RE.sub('', text) @classmethod def HandleException(cls, exc_type, exc_value, exc_traceback): with config.logging_setup.threadbound(): # Print the base level traceback t = traceback.format_exception(exc_type, exc_value, exc_traceback) - pyfalog.critical("\n\n" + "".join(t)) + safeText = cls._sanitizeSensitiveData("".join(t)) + pyfalog.critical("\n\n" + safeText) if cls.__parent is None: app = wx.App(False) cls.__frame = ErrorFrame(None) - cls.__frame.addException("".join(t)) + cls.__frame.addException(safeText) cls.__frame.Show() app.MainLoop() sys.exit() @@ -56,7 +63,7 @@ def HandleException(cls, exc_type, exc_value, exc_traceback): if not cls.__frame: cls.__frame = ErrorFrame(cls.__parent) cls.__frame.Show() - cls.__frame.addException("".join(t)) + cls.__frame.addException(safeText) @classmethod def SetParent(cls, parent): diff --git a/gui/mainFrame.py b/gui/mainFrame.py index 54e1d2d27f..37706e9b5e 100644 --- a/gui/mainFrame.py +++ b/gui/mainFrame.py @@ -570,6 +570,8 @@ def registerMenu(self): # Graphs self.Bind(wx.EVT_MENU, self.OnShowGraphFrame, id=menuBar.graphFrameId) self.Bind(wx.EVT_MENU, self.OnShowGraphFrameHidden, id=self.hiddenGraphsId) + # Keep dynamic menu entries synchronized with current settings. + self.Bind(wx.EVT_MENU_OPEN, self.OnMenuOpen) toggleSearchBoxId = wx.NewId() toggleShipMarketId = wx.NewId() @@ -744,6 +746,12 @@ def CTabNext(self, event): def CTabPrev(self, event): self.fitMultiSwitch.PrevPage() + def OnMenuOpen(self, event): + menuBar = self.GetMenuBar() + if event.GetMenu() == menuBar.fitMenu: + menuBar.refreshDiscordMenuVisibility() + event.Skip() + def HAddPage(self, event): self.fitMultiSwitch.AddPage() diff --git a/gui/mainMenuBar.py b/gui/mainMenuBar.py index a3bd87bb16..22af08c278 100644 --- a/gui/mainMenuBar.py +++ b/gui/mainMenuBar.py @@ -23,6 +23,7 @@ import config import graphs from service.character import Character +from service.discord import Discord from service.fit import Fit from service.settings import DiscordSettings import gui.globalEvents as GE @@ -177,7 +178,9 @@ def __init__(self, mainFrame): self.refreshDiscordMenuVisibility() def refreshDiscordMenuVisibility(self): - discordEnabled = bool(DiscordSettings.getInstance().get('enableDiscord')) + settings = DiscordSettings.getInstance() + webhookUrl = (settings.get('webhookUrl') or '').strip() + discordEnabled = bool(settings.get('enableDiscord')) and Discord.isValidWebhookUrl(webhookUrl) syncDiscordShareMenuVisibility( fitMenu=self.fitMenu, shareToDiscordId=self.shareToDiscordId, From f7d7849675844536d6cd4479bc5be1878cbfe880 Mon Sep 17 00:00:00 2001 From: Zachary Daniels Date: Tue, 7 Jul 2026 20:08:54 -0500 Subject: [PATCH 4/4] refactor: discord webhook validation test --- .../pyfaDiscordPreferences.py | 21 ++++++++++++- service/discord.py | 31 ++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/gui/builtinPreferenceViews/pyfaDiscordPreferences.py b/gui/builtinPreferenceViews/pyfaDiscordPreferences.py index 61788cab9e..3dbc5088b6 100644 --- a/gui/builtinPreferenceViews/pyfaDiscordPreferences.py +++ b/gui/builtinPreferenceViews/pyfaDiscordPreferences.py @@ -4,7 +4,7 @@ from gui.preferenceView import PreferenceView from gui.bitmap_loader import BitmapLoader -from service.discord import Discord +from service.discord import Discord, DiscordWebhookError from service.settings import DiscordSettings _t = wx.GetTranslation @@ -60,6 +60,12 @@ def populatePanel(self, panel): self.cbConfirmBeforeSend.Bind(wx.EVT_CHECKBOX, self.OnConfirmBeforeSendChange) mainSizer.Add(self.cbConfirmBeforeSend, 0, wx.ALL | wx.EXPAND, 5) + btnSizer = wx.BoxSizer(wx.HORIZONTAL) + self.btnTestWebhook = wx.Button(panel, wx.ID_ANY, _t("Test Webhook"), wx.DefaultPosition, wx.DefaultSize, 0) + self.btnTestWebhook.Bind(wx.EVT_BUTTON, self.OnTestWebhook) + btnSizer.Add(self.btnTestWebhook, 0, wx.ALL, 5) + mainSizer.Add(btnSizer, 0, wx.ALL | wx.EXPAND, 0) + self.ToggleWebhookSettings(self.cbEnableDiscord.GetValue()) self.UpdateWebhookValidationState() @@ -70,6 +76,7 @@ def populatePanel(self, panel): def OnCBEnableChange(self, event): self.settings.set('enableDiscord', self.cbEnableDiscord.GetValue()) self.ToggleWebhookSettings(self.cbEnableDiscord.GetValue()) + self.UpdateWebhookValidationState() def OnWebhookUrlText(self, event): self.settings.set('webhookUrl', self.editWebhookURL.GetValue().strip()) @@ -82,11 +89,23 @@ def ToggleWebhookSettings(self, enable): self.stWebhookURL.Enable(enable) self.editWebhookURL.Enable(enable) self.cbConfirmBeforeSend.Enable(enable) + self.btnTestWebhook.Enable(enable) def UpdateWebhookValidationState(self): webhookValue = self.editWebhookURL.GetValue().strip() isValid = webhookValue == '' or Discord.isValidWebhookUrl(webhookValue) self.stWebhookValidation.Show(not isValid) + canTest = self.cbEnableDiscord.GetValue() and Discord.isValidWebhookUrl(webhookValue) + self.btnTestWebhook.Enable(canTest) + + def OnTestWebhook(self, event): + try: + Discord.getInstance().sendTestMessage() + except DiscordWebhookError as e: + wx.MessageBox(str(e), _t("Discord Webhook"), wx.OK | wx.ICON_ERROR) + return + + wx.MessageBox(_t("Discord webhook test message sent successfully."), _t("Discord Webhook"), wx.OK | wx.ICON_INFORMATION) def getImage(self): return BitmapLoader.getBitmap("prefs_discord", "gui") diff --git a/service/discord.py b/service/discord.py index 9984f2307f..89181a1340 100644 --- a/service/discord.py +++ b/service/discord.py @@ -19,6 +19,8 @@ import requests from urllib.parse import urlparse +# noinspection PyPackageRequirements +import wx from logbook import Logger from service.const import PortEftOptions @@ -26,6 +28,7 @@ pyfalog = Logger(__name__) +_t = wx.GetTranslation class DiscordWebhookError(Exception): @@ -48,13 +51,13 @@ def __init__(self): def sendFit(self, fit): if not self.settings.get('enableDiscord'): - raise DiscordWebhookError('Discord integration is disabled. Enable it in Preferences > Discord.') + raise DiscordWebhookError(_t('Discord integration is disabled. Enable it in Preferences > Discord.')) webhookUrl = (self.settings.get('webhookUrl') or '').strip() if not webhookUrl: - raise DiscordWebhookError('Discord webhook URL is empty. Set it in Preferences > Discord.') + raise DiscordWebhookError(_t('Discord webhook URL is empty. Set it in Preferences > Discord.')) if not self.isValidWebhookUrl(webhookUrl): - raise DiscordWebhookError('Discord webhook URL format is invalid. Set a valid Discord webhook URL in Preferences > Discord.') + raise DiscordWebhookError(_t('Discord webhook URL format is invalid. Set a valid Discord webhook URL in Preferences > Discord.')) payload = self._buildWebhookPayload(fit) proxies = self.networkSettings.getProxySettingsInRequestsFormat() @@ -64,7 +67,27 @@ def sendFit(self, fit): response.raise_for_status() except requests.exceptions.RequestException: pyfalog.warning('Discord webhook request failed.') - raise DiscordWebhookError('Failed to send fit to Discord webhook. Check webhook URL and network settings.') + raise DiscordWebhookError(_t('Failed to send fit to Discord webhook. Check webhook URL and network settings.')) + + def sendTestMessage(self): + if not self.settings.get('enableDiscord'): + raise DiscordWebhookError(_t('Discord integration is disabled. Enable it in Preferences > Discord.')) + + webhookUrl = (self.settings.get('webhookUrl') or '').strip() + if not webhookUrl: + raise DiscordWebhookError(_t('Discord webhook URL is empty. Set it in Preferences > Discord.')) + if not self.isValidWebhookUrl(webhookUrl): + raise DiscordWebhookError(_t('Discord webhook URL format is invalid. Set a valid Discord webhook URL in Preferences > Discord.')) + + payload = {'content': _t('pyfa webhook test message')} + proxies = self.networkSettings.getProxySettingsInRequestsFormat() + + try: + response = requests.post(webhookUrl, json=payload, proxies=proxies, timeout=5) + response.raise_for_status() + except requests.exceptions.RequestException: + pyfalog.warning('Discord webhook test request failed.') + raise DiscordWebhookError(_t('Failed to send test message to Discord webhook. Check webhook URL and network settings.')) def _buildWebhookPayload(self, fit): from service.port.eft import exportEft