├── .gitignore ├── .gitlab-ci.yml ├── COPYING ├── NEWS ├── README.md ├── build-aux └── meson │ └── postinstall.py ├── data ├── icons │ ├── hicolor │ │ ├── scalable │ │ │ └── apps │ │ │ │ └── org.gnome.gitlab.ranchester.Mirdorph.svg │ │ └── symbolic │ │ │ └── apps │ │ │ └── org.gnome.gitlab.ranchester.Mirdorph-symbolic.svg │ ├── meson.build │ ├── org.gnome.gitlab.ranchester.Mirdorph.Source.svg │ └── scalable │ │ └── status │ │ ├── chat-communication.svg │ │ ├── gear-symbolic.svg │ │ ├── org.gnome.gitlab.ranchester.Mirdorph.DiscussionsLogo.svg │ │ ├── org.gnome.gitlab.ranchester.Mirdorph.Guilds-symbolic.svg │ │ ├── paper-plane-symbolic.svg │ │ ├── smile-symbolic.svg │ │ └── view-sidebar-symbolic.svg ├── meson.build ├── mirdorph.gresource.xml ├── org.gnome.gitlab.ranchester.Mirdorph.appdata.xml.in ├── org.gnome.gitlab.ranchester.Mirdorph.desktop.in ├── org.gnome.gitlab.ranchester.Mirdorph.gschema.xml ├── style.css ├── tos.txt └── ui │ ├── about_dialog.ui.in │ ├── channel_inner_window.ui │ ├── channel_list_entry.ui │ ├── channel_properties_window.ui │ ├── channel_sidebar.ui │ ├── context_error_dialog.ui │ ├── extension_row.ui │ ├── generic_attachment.ui │ ├── image_viewer.ui │ ├── link_preview.ui │ ├── login_window.ui │ ├── main_window.ui │ ├── meson.build │ ├── message.ui │ ├── message_entry_bar.ui │ ├── message_entry_bar_attachment.ui │ ├── message_view.ui │ ├── settings_window.ui │ ├── tos_notice.ui │ └── typing_indicator.ui ├── doc ├── STANDARD.md ├── asset │ ├── mirdorph-channel-properties.png │ ├── mirdorph-guild-search.png │ ├── mirdorph-image-viewer.png │ ├── mirdorph-loading-screen.png │ ├── mirdorph-login-gui.png │ ├── mirdorph-login-password.png │ ├── mirdorph-login-token.png │ ├── mirdorph-login.png │ ├── mirdorph-mobile-with-sidebar.png │ ├── mirdorph-mobile.png │ ├── mirdorph-popped-out.png │ ├── mirdorph-unselected-main-win.png │ └── mirdorph-with-channel.png ├── discord-maintainability.txt ├── on_message_in-selfbot-and-other-issues-info-attempt ├── on_message_self_bots_broken.md ├── plugin.md └── slow-window-focusing.txt ├── meson.build ├── mirdorph ├── __init__.py ├── attachment.py ├── channel_inner_window.py ├── channel_properties_window.py ├── channel_sidebar.py ├── confman.py ├── disc_cogs │ ├── event_listening.py │ └── meson.build ├── event_manager.py ├── event_receiver.py ├── image_viewer.py ├── link_preview.py ├── login_window.py ├── main.py ├── main_window.py ├── meson.build ├── message.py ├── message_entry_bar.py ├── message_parsing.py ├── message_view.py ├── mirdorph.in ├── plugin.py ├── plugins │ ├── helloworld │ │ ├── configuration.ui │ │ ├── helloworld.gresource.xml │ │ ├── helloworld.plugin │ │ ├── helloworld.py │ │ ├── meson.build │ │ └── welcome_message.txt │ ├── login_method_graphical │ │ ├── discord_web_grabber.py │ │ ├── get_token.js │ │ ├── login_method_graphical.plugin │ │ ├── login_method_graphical.py │ │ └── meson.build │ ├── login_method_manual │ │ ├── login_method_manual.gresource.xml │ │ ├── login_method_manual.plugin │ │ ├── login_method_manual.py │ │ ├── manual_login_page.ui │ │ └── meson.build │ ├── login_method_password │ │ ├── login_method_password.gresource.xml │ │ ├── login_method_password.plugin │ │ ├── login_method_password.py │ │ ├── meson.build │ │ └── password_login_page.ui │ └── meson.build ├── settings_window.py └── typing_indicator.py ├── mockups └── mirdorph.svg ├── org.gnome.gitlab.ranchester.Mirdorph.json ├── patches ├── discord.py │ ├── 0001-fix-broken-.content-and-.embeds-and-more-in-self-bot.patch │ └── 0002-fix-on-typing-for-self-bots.patch ├── html2pango │ └── 0001-allow-usage-from-python.patch └── libadwaita │ └── 0001-revert-stylesheet-softer-window-shadows.patch ├── po ├── LINGUAS ├── POTFILES.in ├── README.md ├── lt.po ├── meson.build └── update_potfiles.sh └── tests ├── load_gtk.py ├── test_confman.py ├── test_message_parsing.py └── test_plug_engine.py /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | build-dir/ 3 | .flatpak-builder/ 4 | .flatpak 5 | .pytest_cache/ 6 | unmeson-mirdorph.gresource.xml 7 | unmeson-mirdorph.gresource 8 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 'https://gitlab.gnome.org/GNOME/citemplates/raw/master/flatpak/flatpak_ci_initiative.yml' 2 | 3 | variables: 4 | BUNDLE: "mirdorph.flatpak" 5 | 6 | flatpak: 7 | image: 'registry.gitlab.gnome.org/gnome/gnome-runtime-images/gnome:master' 8 | before_script: 9 | - "flatpak install --user -y --noninteractive flathub-beta org.freedesktop.Sdk.Extension.rust-stable//21.08beta" 10 | variables: 11 | MANIFEST_PATH: "org.gnome.gitlab.ranchester.Mirdorph.json" 12 | FLATPAK_MODULE: "mirdorph" 13 | RUNTIME_REPO: "https://flathub.org/repo/flathub.flatpakrepo" 14 | APP_ID: "org.gnome.gitlab.ranchester.Mirdorph" 15 | extends: .flatpak 16 | 17 | flatpak-test: 18 | image: 'registry.gitlab.gnome.org/gnome/gnome-runtime-images/gnome:master' 19 | before_script: 20 | - "flatpak install --user -y --noninteractive flathub-beta org.freedesktop.Sdk.Extension.rust-stable//21.08beta" 21 | script: 22 | - !reference [.flatpak, script] 23 | - 'flatpak install -y --noninteractive --user --no-pull --no-related mirdorph.flatpak' 24 | - 'flatpak run --command="python3" --cwd=$(pwd)/tests --filesystem=$(pwd) org.gnome.gitlab.ranchester.Mirdorph -m pytest -v --junitxml=../report.xml' 25 | variables: 26 | MANIFEST_PATH: "org.gnome.gitlab.ranchester.Mirdorph.json" 27 | FLATPAK_MODULE: "mirdorph" 28 | RUNTIME_REPO: "https://flathub.org/repo/flathub.flatpakrepo" 29 | APP_ID: "org.gnome.gitlab.ranchester.Mirdorph" 30 | extends: .flatpak 31 | artifacts: 32 | when: always 33 | reports: 34 | junit: report.xml 35 | 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mirdorph - a crappy low feature Discord Client 2 | 3 | I don't have much else to add. 4 | 5 | ## To run: 6 | 7 | ### Download latest snapshot from CI (x86.64 only): 8 | 9 | 1. Head over to [the *Jobs* page of the repository](https://gitlab.gnome.org/ranchester/mirdorph/-/jobs) 10 | 2. Find the latest one with the label *flatpak* 11 | 3. Press the download button on the right 12 | 4. Extract the zip you downloaded and open a terminal in the resulting directory 13 | 5. Install the snapshot with `flatpak --user install mirdorph.flatpak` 14 | 15 | ### Build from source method: 16 | 17 | Have flatpak and flatpak-builder installed. 18 | Install `org.gnome.Sdk`, `org.gnome.Platform` (version master) and `org.freedesktop.Sdk.Extension.rust-stable` if they are 19 | not allready installed. 20 | 21 | And from the source directory: 22 | 23 | ```bash 24 | # To build and install 25 | flatpak-builder --force-clean --user --install build-dir org.gnome.gitlab.ranchester.Mirdorph.json 26 | # To run 27 | flatpak run org.gnome.gitlab.ranchester.Mirdorph 28 | ``` 29 | 30 | ## Screenshots 31 | 32 | Here are a few screenshots of 0.13.0: 33 | 34 | ![image](./doc/asset/mirdorph-login.png) 35 | ![image](./doc/asset/mirdorph-login-gui.png) 36 | ![image](./doc/asset/mirdorph-login-token.png) 37 | ![image](./doc/asset/mirdorph-login-password.png) 38 | ![image](./doc/asset/mirdorph-unselected-main-win.png) 39 | ![image](./doc/asset/mirdorph-with-channel.png) 40 | ![image](./doc/asset/mirdorph-popped-out.png) 41 | ![image](./doc/asset/mirdorph-mobile.png) 42 | ![image](./doc/asset/mirdorph-mobile-with-sidebar.png) 43 | ![image](./doc/asset/mirdorph-channel-properties.png) 44 | ![image](./doc/asset/mirdorph-guild-search.png) 45 | ![image](./doc/asset/mirdorph-image-viewer.png) 46 | ![image](./doc/asset/mirdorph-loading-screen.png) 47 | -------------------------------------------------------------------------------- /build-aux/meson/postinstall.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import environ, path 4 | from subprocess import call 5 | 6 | prefix = environ.get("MESON_INSTALL_PREFIX", "/usr/local") 7 | datadir = path.join(prefix, "share") 8 | destdir = environ.get("DESTDIR", "") 9 | 10 | # Package managers set this so we don't need to run 11 | if not destdir: 12 | print("Updating icon cache...") 13 | call(["gtk-update-icon-cache", "-qtf", path.join(datadir, "icons", "hicolor")]) 14 | 15 | print("Updating desktop database...") 16 | call(["update-desktop-database", "-q", path.join(datadir, "applications")]) 17 | 18 | print("Compiling GSettings schemas...") 19 | call(["glib-compile-schemas", path.join(datadir, "glib-2.0", "schemas")]) 20 | 21 | 22 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/org.gnome.gitlab.ranchester.Mirdorph.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/org.gnome.gitlab.ranchester.Mirdorph-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | scalable_dir = join_paths('hicolor', 'scalable', 'apps') 2 | install_data( 3 | join_paths(scalable_dir, ('@0@.svg').format(app_id)), 4 | install_dir: join_paths(get_option('datadir'), join_paths('icons', scalable_dir)) 5 | ) 6 | 7 | symbolic_dir = join_paths('hicolor', 'symbolic', 'apps') 8 | install_data( 9 | join_paths(symbolic_dir, ('@0@-symbolic.svg').format(app_id)), 10 | install_dir: join_paths(get_option('datadir'), join_paths('icons', symbolic_dir)) 11 | ) 12 | -------------------------------------------------------------------------------- /data/icons/scalable/status/chat-communication.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /data/icons/scalable/status/paper-plane-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /data/icons/scalable/status/smile-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /data/icons/scalable/status/view-sidebar-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 46 | 47 | 49 | 50 | 52 | image/svg+xml 53 | 55 | 56 | 57 | 58 | 59 | 64 | 69 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 2 | moduledir = join_paths(pkgdatadir, 'mirdorph') 3 | gnome = import('gnome') 4 | 5 | subdir('ui') 6 | app_resources = gnome.compile_resources( 7 | 'mirdorph', 8 | 'mirdorph.gresource.xml', 9 | gresource_bundle: true, 10 | install: true, 11 | install_dir: pkgdatadir, 12 | dependencies: [ 13 | configure_file( 14 | input: 'ui/about_dialog.ui.in', 15 | output: 'about_dialog.ui', 16 | configuration: glade_conf 17 | ) 18 | ] 19 | ) 20 | 21 | desktop_file = i18n.merge_file( 22 | input: 'org.gnome.gitlab.ranchester.Mirdorph.desktop.in', 23 | output: 'org.gnome.gitlab.ranchester.Mirdorph.desktop', 24 | type: 'desktop', 25 | po_dir: '../po', 26 | install: true, 27 | install_dir: join_paths(get_option('datadir'), 'applications') 28 | ) 29 | 30 | desktop_utils = find_program('desktop-file-validate', required: false) 31 | if desktop_utils.found() 32 | test('Validate desktop file', desktop_utils, 33 | args: [desktop_file] 34 | ) 35 | endif 36 | 37 | appstream_file = i18n.merge_file( 38 | input: 'org.gnome.gitlab.ranchester.Mirdorph.appdata.xml.in', 39 | output: 'org.gnome.gitlab.ranchester.Mirdorph.appdata.xml', 40 | po_dir: '../po', 41 | install: true, 42 | install_dir: join_paths(get_option('datadir'), 'appdata') 43 | ) 44 | 45 | appstream_util = find_program('appstream-util', required: false) 46 | if appstream_util.found() 47 | test('Validate appstream file', appstream_util, 48 | args: ['validate', appstream_file] 49 | ) 50 | endif 51 | 52 | install_data('org.gnome.gitlab.ranchester.Mirdorph.gschema.xml', 53 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 54 | ) 55 | 56 | compile_schemas = find_program('glib-compile-schemas', required: false) 57 | if compile_schemas.found() 58 | test('Validate schema file', compile_schemas, 59 | args: ['--strict', '--dry-run', meson.current_source_dir()] 60 | ) 61 | endif 62 | 63 | subdir('icons') 64 | -------------------------------------------------------------------------------- /data/mirdorph.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | style.css 5 | 6 | 7 | icons/scalable/status/org.gnome.gitlab.ranchester.Mirdorph.DiscussionsLogo.svg 8 | icons/scalable/status/org.gnome.gitlab.ranchester.Mirdorph.Guilds-symbolic.svg 9 | icons/scalable/status/view-sidebar-symbolic.svg 10 | icons/scalable/status/smile-symbolic.svg 11 | icons/scalable/status/paper-plane-symbolic.svg 12 | icons/scalable/status/gear-symbolic.svg 13 | icons/scalable/status/chat-communication.svg 14 | ui/login_window.ui 15 | ui/main_window.ui 16 | ui/channel_inner_window.ui 17 | ui/message_entry_bar.ui 18 | ui/channel_sidebar.ui 19 | ui/channel_list_entry.ui 20 | ui/message_view.ui 21 | ui/channel_properties_window.ui 22 | ui/message.ui 23 | ui/generic_attachment.ui 24 | ui/message_entry_bar_attachment.ui 25 | ui/typing_indicator.ui 26 | ui/link_preview.ui 27 | ui/tos_notice.ui 28 | ui/settings_window.ui 29 | ui/extension_row.ui 30 | ui/image_viewer.ui 31 | ui/context_error_dialog.ui 32 | 33 | about_dialog.ui 34 | tos.txt 35 | 36 | 37 | -------------------------------------------------------------------------------- /data/org.gnome.gitlab.ranchester.Mirdorph.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Mirdorph 3 | Comment=Human Interaction System for Discord 4 | Exec=mirdorph 5 | Icon=org.gnome.gitlab.ranchester.Mirdorph 6 | Terminal=false 7 | Type=Application 8 | Categories=GTK;Network; 9 | Keywords=Discord;Communicate;Discussions;Client; 10 | StartupNotify=true 11 | -------------------------------------------------------------------------------- /data/org.gnome.gitlab.ranchester.Mirdorph.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /data/style.css: -------------------------------------------------------------------------------- 1 | .login-button { 2 | padding-top: 10px; 3 | padding-bottom: 10px; 4 | border-radius: 9999px; 5 | } 6 | 7 | .password-entry-row { 8 | padding: 10px; 9 | } 10 | 11 | .discord-message { 12 | padding: 3px; 13 | } 14 | 15 | .merged-discord-message { 16 | padding-top: 0px; 17 | padding-bottom: 0px; 18 | } 19 | 20 | .quote { 21 | border-left: 2px solid @accent_bg_color; 22 | color: #888A85; 23 | padding-left: 6px; 24 | } 25 | 26 | .discord-message .username { 27 | color: @accent_color; 28 | font-weight: bold; 29 | font-size: small; 30 | } 31 | 32 | /* 33 | Both the message entry box and the message-history 34 | listbox are in a hdyclamp of equal size. So for them 35 | to align nicely the padding must be equal. 36 | 37 | However, I can't make it too small since I need there 38 | to be room from the message listbox to the border in mobile 39 | mode 40 | */ 41 | 42 | .message-history { 43 | background-color: @theme_base_color; 44 | padding: 0 8px 8px; 45 | /* For room for typing indicator, unfortunatej*/ 46 | padding-bottom: 20px; 47 | } 48 | 49 | .message-history > row { 50 | border-radius: 7px; 51 | } 52 | 53 | #message-entry-button-box { 54 | margin-left: 11px; 55 | margin-right: 11px; 56 | } 57 | 58 | .message-enforcement-box { 59 | background-color: @theme_base_color; 60 | } 61 | 62 | .channel-banner-box { 63 | padding: 10px; 64 | background-color: @theme_fg_color; 65 | } 66 | 67 | .scroll-button { 68 | padding: 6px; 69 | } 70 | 71 | .reverse-text { 72 | color: @theme_bg_color; 73 | } 74 | 75 | .channel-properties-content-container { 76 | padding: 10px; 77 | } 78 | 79 | .image-attachment-template { 80 | background-color: @theme_base_color; 81 | padding: 5px; 82 | } 83 | 84 | .entry-bar-toolbar { 85 | } 86 | 87 | .message-entry-bar-attachment { 88 | border-radius: 5px; 89 | box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 6px, rgba(0, 0, 0, 0.23) 0px 3px 6px; 90 | } 91 | 92 | .message-entry-bar-attachment-container { 93 | padding: 10px; 94 | background-color: @theme_bg_color; 95 | } 96 | 97 | .typing-indicator box { 98 | background-color: @theme_base_color; 99 | border-top-left-radius: 7px; 100 | border-top-right-radius: 7px; 101 | box-shadow: rgba(0, 0, 0, 0.05) 0px 6px 24px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px; 102 | } 103 | 104 | .typing-indicator label { 105 | margin-top: 2px; 106 | margin-bottom: 2px; 107 | margin-left: 4px; 108 | margin-right: 4px; 109 | color: @accent_color; 110 | } 111 | 112 | .link_preview row { 113 | padding: 6px; 114 | } 115 | 116 | .link_preview image { 117 | background-color: shade(@theme_base_color, 0.8); 118 | border-radius: 7px; 119 | padding: 10px; 120 | } 121 | 122 | /* 123 | For guild-list, modified autogenerated .content libhandy code. 124 | Basically mimics content without the rounding 125 | */ 126 | 127 | list.guild-list, list.guild-list list { background-color: transparent; } 128 | 129 | list.guild-list list.nested > row:not(:active):not(:hover):not(:selected), list.guild-list list.nested > row:not(:active):hover:not(.activatable):not(:selected) { background-color: mix(@theme_bg_color, @theme_base_color, 0.5); } 130 | 131 | list.guild-list list.nested > row.activatable:not(:active):hover:not(:selected) { background-color: mix(@theme_fg_color, @theme_base_color, 0.95); } 132 | 133 | list.guild-list > row:not(.expander):not(:active):not(:hover):not(:selected), list.guild-list > row:not(.expander):not(:active):hover:not(.activatable):not(:selected), list.guild-list > row.expander row.header:not(:active):not(:hover):not(:selected), list.guild-list > row.expander row.header:not(:active):hover:not(.activatable):not(:selected) { background-color: @theme_base_color; } 134 | 135 | list.guild-list > row.activatable:not(.expander):not(:active):hover:not(:selected), list.guild-list > row.expander row.header.activatable:not(:active):hover:not(:selected) { background-color: mix(@theme_fg_color, @theme_base_color, 0.95); } 136 | 137 | /* Border color not generalized */ 138 | list.guild-list > row, list.guild-list > row list > row { border-color: alpha(@borders, 0.7); border-style: solid; transition: 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); } 139 | 140 | list.guild-list > row:not(:last-child) { border-width: 1px 1px 0px 1px; } 141 | 142 | list.guild-list > row:first-child, list.guild-list > row.expander:first-child row.header, list.guild-list > row.expander:checked, list.guild-list > row.expander:checked row.header, list.guild-list > row.expander:checked + row, list.guild-list > row.expander:checked + row.expander row.header { border-top-left-radius: 0px; border-top-right-radius: 0px; } 143 | 144 | list.guild-list > row:last-child, list.guild-list > row.checked-expander-row-previous-sibling, list.guild-list > row.expander:checked { border-width: 1px; } 145 | 146 | list.guild-list > row:last-child, list.guild-list > row.checked-expander-row-previous-sibling, list.guild-list > row.expander:checked, list.guild-list > row.expander:not(:checked):last-child row.header, list.guild-list > row.expander.checked-expander-row-previous-sibling:not(:checked) row.header, list.guild-list > row.expander.empty:checked row.header, list.guild-list > row.expander list.nested > row:last-child { border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; } 147 | 148 | list.guild-list > row.expander:checked:not(:first-child), list.guild-list > row.expander:checked + row { margin-top: 6px; } 149 | 150 | /* Remove stupid padding around the channel list */ 151 | 152 | .guild-list row .nested row { 153 | padding: 0px; 154 | } 155 | -------------------------------------------------------------------------------- /data/ui/about_dialog.ui.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @prettyname@ 6 | @VERSION@ 7 | @CONTRIBUTORS@ 8 | @authorfullname@, et al. 9 | This application is not affiliated with or endorsed by Discord, Inc. 10 | @APPID@ 11 | gpl-3-0 12 | 13 | 14 | -------------------------------------------------------------------------------- /data/ui/channel_inner_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | Channel Properties 8 | context.properties 9 | 10 | 11 | Advanced Search... 12 | context.search 13 | 14 |
15 |
16 | 112 |
113 | -------------------------------------------------------------------------------- /data/ui/channel_list_entry.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 33 | 34 | -------------------------------------------------------------------------------- /data/ui/channel_sidebar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 127 | 128 | -------------------------------------------------------------------------------- /data/ui/context_error_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 36 | 37 | -------------------------------------------------------------------------------- /data/ui/extension_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 29 | 30 | -------------------------------------------------------------------------------- /data/ui/generic_attachment.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 59 | 60 | -------------------------------------------------------------------------------- /data/ui/image_viewer.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | Open with... 9 | image-viewer.open-in-app 10 | 11 |
12 |
13 | 14 | _more_actions_menu 15 | 16 | 145 |
146 | -------------------------------------------------------------------------------- /data/ui/link_preview.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 45 | 46 | -------------------------------------------------------------------------------- /data/ui/login_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 134 | 135 | -------------------------------------------------------------------------------- /data/ui/meson.build: -------------------------------------------------------------------------------- 1 | glade_conf = configuration_data() 2 | glade_conf.set('VERSION', meson.project_version()) 3 | glade_conf.set('APPID', app_id) 4 | glade_conf.set('authorfullname', authorfullname) 5 | glade_conf.set('prettyname', prettyname) 6 | glade_conf.set('CONTRIBUTORS', contributors) 7 | -------------------------------------------------------------------------------- /data/ui/message.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 46 | 47 | -------------------------------------------------------------------------------- /data/ui/message_entry_bar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 107 | 108 | -------------------------------------------------------------------------------- /data/ui/message_entry_bar_attachment.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 55 | 56 | -------------------------------------------------------------------------------- /data/ui/message_view.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 65 | 66 | -------------------------------------------------------------------------------- /data/ui/settings_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vertical 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | go-previous-symbolic 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | True 26 | 27 | 28 | 29 | 87 | 88 | -------------------------------------------------------------------------------- /data/ui/tos_notice.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 63 | 64 | -------------------------------------------------------------------------------- /data/ui/typing_indicator.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 30 | 31 | -------------------------------------------------------------------------------- /doc/STANDARD.md: -------------------------------------------------------------------------------- 1 | Mirdorph is a (bad) Discord client utilizing discord.py and GTK3. 2 | 3 | ## How interaction with discord 4 | 5 | First, discord with an asyncio loop runs in the main thread, GTK runs 6 | in a separate thread from the very start, however that doesn't mean 7 | we cannot communicate. 8 | 9 | To run discord code from GTK: 10 | 11 | use asyncio.run_coroutine_threadsafe: 12 | 13 | ```python3 14 | import asyncio 15 | 16 | asyncio.run_coroutine_threadsafe( 17 | discord_client.do_something(), 18 | discord_loop 19 | ) 20 | ``` 21 | 22 | `discord_loop` and `discord_client` are also attributes of the GtkApplication. 23 | You can get the return value from this with `.result()` (note: is blocking). 24 | 25 | To launch code in Gtk from an async function you pass to the run coroutine thing, 26 | you can use `GLib.idle_add(func, *args)`. 27 | 28 | Now, the other way round: 29 | 30 | There isn't really a reason to do this asside from listening to events, and that is simple. 31 | 32 | ```python3 33 | from .event_receiver import EventReceiver 34 | 35 | class YourObject(EventReceiver): 36 | def __init__(self): 37 | EventReceiver.__init__(self) 38 | 39 | def disc_on_message(self, message): 40 | print(message.content) 41 | ``` 42 | 43 | Simply subclass EventReceiver and you will be able to get any Discord event with 44 | `disc_` + the generic event name from the discord.py documentation. 45 | 46 | NOTE: make sure to run discord stuff with asyncio.run_coroutine_threadsafe. 47 | and that is about it with interaction. 48 | 49 | ## Main architect 50 | 51 | The main thing is that any channel/room is a `ChannelInnerWindow` and commonly 52 | refered to as a `context`. The context holds everything needed about a discord channel. 53 | And usually has an integrated `MessageView` which is the thingy that displays the 54 | messages. 55 | 56 | The objects are themselves meant to keep themselves in sync with their on-discord state. 57 | 58 | Messageview at it's heart uses a listview with a Gio.ListStore with GObject "Mobject" 59 | representations of messages, they are sorted automatically, and the row that you see 60 | are rebinded to be any specific message. The widget itself is`MessageWidget`. 61 | Things like the loading spinner are in a separate model, and both are part of a flattened 62 | model. 63 | 64 | A context itself is a Gtk widget too, and is the whole headerbar+area thing you see 65 | on the left. The popoit windows are simpy a `ChannelInnerWindow` inside a `AdwWindow`. 66 | 67 | Global state is application, yada yada, etc. There are quite a few things like that 68 | 69 | Also `ConfMan` is used for configuration, the application has an instance of it as an 70 | attribute. Simply use `confman.get_value("example")` or `confman.set_value("new_value", data)`. 71 | -------------------------------------------------------------------------------- /doc/asset/mirdorph-channel-properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-channel-properties.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-guild-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-guild-search.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-image-viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-image-viewer.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-loading-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-loading-screen.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-login-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-login-gui.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-login-password.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-login-password.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-login-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-login-token.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-login.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-mobile-with-sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-mobile-with-sidebar.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-mobile.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-popped-out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-popped-out.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-unselected-main-win.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-unselected-main-win.png -------------------------------------------------------------------------------- /doc/asset/mirdorph-with-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/doc/asset/mirdorph-with-channel.png -------------------------------------------------------------------------------- /doc/discord-maintainability.txt: -------------------------------------------------------------------------------- 1 | We are in quite a bad state with the maintainability of our connection to Discord 2 | 3 | First, self bots aren't allowed and the API may change under our feet, and 4 | they can try to prevent it. 5 | 6 | We use `discord.py` which has deprecated self bot support in 1.7 and is removing it in 2.0. 7 | 8 | This is quite a bad state for us, I am currently applying patches on top of 1.7, however I 9 | will have to eventually somehow replace on top of v2.0. discord.py-self fork seems promising, 10 | I think I can work together with them and allows for good communication. 11 | 12 | However there is some community for self bots it seems, and the most real place I found is 13 | r/discord_selfbots. However it seems like many of the community have turned against you, 14 | you can't get help on this in the most popular communication channels like official Discords API 15 | or r/discordapp. Posting a question about self bots on stackoverflow gets you absolutely hated 16 | by morrons trying to mark your issue as a duplicate of an unrelated issue and saying that 17 | "noone will help you here" because it is against the TOS. 18 | -------------------------------------------------------------------------------- /doc/on_message_in-selfbot-and-other-issues-info-attempt: -------------------------------------------------------------------------------- 1 | What someone has found out about the current issue. (NOTE: this has been worked around) 2 | 3 | > I've been investigating for the past week or so, and from what I can gather 4 | > there are a couple of issues. 5 | > 6 | > 1. Intents need to be disabled. Users don't use intents, and enabling 7 | > intents seems to be one of the causes of this problem. Discord may have 8 | > even done it on purpose. 9 | > 10 | > 2. The IDENTIFY packet sent needs to be changed. To log on to the API, 11 | > whatever is using it (be it discord.py or the official Discord client), 12 | > sends an IDENTIFY packet with the token and some other info. These are 13 | > different for bot and user, and also seem to be part of the problem. 14 | 15 | > I disabled intents and replaced the IDENTIFY packet in gateway.py, but 16 | > anything that needs intents (e.g. on_message), doesn't fire at all. There's 17 | > either another problem, or there's an internal check that disables things 18 | > that need intents if the specific intent is disabled, even though it 19 | > *does* have 20 | > the correct permissions. 21 | -------------------------------------------------------------------------------- /doc/on_message_self_bots_broken.md: -------------------------------------------------------------------------------- 1 | The various `on_message_*` events are broken in self bots in Discord.py since 2 | a few days ago. 3 | 4 | For example, the .content and .embeds don't work, unless they are messages that you sent! 5 | 6 | Luckily, messages retrieved through .history() still work! 7 | 8 | This basically solves the `on_message` use case, but for editing 9 | we can't really get the before now, only the after. (But who needs before anyways?) 10 | 11 | I really hope Discord doesnt continue breaking this. 12 | 13 | But I have patched discord.py to allways retrieve `on_message` from history. 14 | You can find it in the /patches directory. 15 | 16 | UPDATE: 17 | 18 | There are now kind of real solutions to this, the most promissing is this fork 19 | of discord.py doiflies https://github.com/dolfies/discord.py-self. It originally 20 | fixed the issue of .content and .embeds, has some other things now and more. 21 | 22 | I have backported the fix from it to a nicely formatted patch, and now use 23 | that to fix `on_message` and similar, note that it uses "Lazy Users` in some places. 24 | 25 | I think the community for this is good enough so that this project isn't 26 | likely to suffer a fate worse than death. 27 | -------------------------------------------------------------------------------- /doc/plugin.md: -------------------------------------------------------------------------------- 1 | You can create basic plugins for this (atleast you should in the future) 2 | 3 | ### Why not libpeas? 4 | 5 | I iniitally tried to use libpeas, and it seemed to be a great solution, already 6 | used by quite a few GNOME apps, no need to reinvent anything. 7 | 8 | However I hit a major issue with how you basically have to define your own GObject 9 | Ginterfaces, (it has a stupid assert to ensure that), and it isn't possible to do 10 | that from Python. 11 | 12 | So I reinvented the wheel, however it should still closely mimick that. 13 | 14 | ### Creating a plugin 15 | 16 | There is an example "helloworld" plugin that is very basic and demonstrates 17 | basic features. 18 | 19 | To create a plugin, you should create it in the `mirdorph/plugins` directory, 20 | first you need a .plugin file there, which looks like this 21 | 22 | ```json 23 | { 24 | "name": "A Plugin", 25 | "module": "aplugin", 26 | "description": "An advanced Plugin", 27 | "built_in": false, 28 | "authors": [ 29 | "Your Name" 30 | ] 31 | } 32 | ``` 33 | 34 | `name`, `module` and `description` are self-explanatory, however module is the name of the python 35 | module where your plugin is defined, and the directory should also be named after it. The gresource 36 | file also should be named after the module. 37 | 38 | You should use meson to install all the required files. 39 | 40 | The plugin class definition itself has to be a subclass of `MrdPlugin`, however you should use a fitting 41 | subclass of it instead, and check its documentation for more specifics. For example for the most basic plugin 42 | that justs loads at startup and exists on shutdown, you can use `MrdApplicationPlugin`. 43 | 44 | An example would be 45 | 46 | ```python 47 | from mirdorph.plugin import MrdApplicationPlugin 48 | 49 | class HelloWorldPlugin(MrdApplicationPlugin): 50 | def __init__(self): 51 | super().__init__() 52 | ``` 53 | 54 | You should also implement the `load` and `unload` methods, and any additional ones that would be needed by the 55 | specific type. 56 | 57 | `load` should actually "enable" the plugin (__init__ shouldn't do that), like showing widgets, enabling functionality, 58 | connecting signals. `unload` should undo **everything** that `load` does. 59 | 60 | ### Extension Sets 61 | 62 | Extension sets are a similar concept to the ones in libpeas (however I am not sure since libpeas has basically zero documentation). 63 | 64 | They are the extension points of the program, some objects have them, they specify the specific type of plugin that they support, 65 | and then it is responsible for loading the plugins when it is notified that a compatible one has shown up/disapeared. 66 | 67 | You can see a basic example in `main.py` with `MrdApplicationPlugin` 68 | 69 | ## CSS 70 | 71 | I just realized I have no way of handling css or icons and stuff. 72 | I guess I could technically just use standard gresurce and use css provider manually, but 73 | I don't know, maybe I will just continue dumping it into the main project css file. 74 | -------------------------------------------------------------------------------- /doc/slow-window-focusing.txt: -------------------------------------------------------------------------------- 1 | As you may have noticed, focusing/defocusing the window is visibly 2 | slow/laggy. Especially if you have a lot of messages. 3 | 4 | Fractal has this *exact* same issue, and we basically use very similar 5 | code for the message history too. 6 | 7 | You can find out more at the fractal issue https://gitlab.gnome.org/GNOME/fractal/-/issues/525. 8 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('mirdorph', 2 | version: '0.13.0', 3 | meson_version: '>= 0.50.0', 4 | default_options: [ 'warning_level=2', 5 | ], 6 | ) 7 | 8 | i18n = import('i18n') 9 | 10 | description = 'A client for Discord' 11 | prettyname = 'Mirdorph' 12 | authorfullname = 'Raidro Manchester' 13 | app_id = 'org.gnome.gitlab.ranchester.Mirdorph' 14 | contributors = '\n'.join([ 15 | 'Raidro Manchester' 16 | ]) 17 | 18 | subdir('data') 19 | subdir('mirdorph') 20 | subdir('po') 21 | 22 | meson.add_install_script('build-aux/meson/postinstall.py') 23 | -------------------------------------------------------------------------------- /mirdorph/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranchester2/mirdorph/d64ce5a6a23aa768f3ab0d6692c60fe18db42d43/mirdorph/__init__.py -------------------------------------------------------------------------------- /mirdorph/channel_properties_window.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import asyncio 17 | import datetime 18 | import threading 19 | import discord 20 | from gi.repository import Adw, Gtk, Gio, GLib, Gdk 21 | from .event_receiver import EventReceiver 22 | 23 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/ui/channel_properties_window.ui") 24 | class ChannelPropertiesWindow(Adw.Window): 25 | __gtype_name__ = "ChannelPropertiesWindow" 26 | 27 | _channel_avatar: Adw.Avatar = Gtk.Template.Child() 28 | 29 | _name_label: Gtk.Label = Gtk.Template.Child() 30 | _description_label: Gtk.Label = Gtk.Template.Child() 31 | 32 | _last_activity_button: Gtk.Button = Gtk.Template.Child() 33 | 34 | def __init__(self, channel: discord.TextChannel, *args, **kwargs): 35 | Adw.Window.__init__(self, *args, **kwargs) 36 | self.app = Gio.Application.get_default() 37 | self._channel_disc = channel 38 | 39 | self._name_label.set_label("#" + self._channel_disc.name) 40 | self._channel_avatar.set_text(self._channel_disc.name) 41 | if self._channel_disc.topic: 42 | # New lines mess up the formatting, as this is designed 43 | # for one line. 44 | cleaned_topic = self._channel_disc.topic.replace("\n", "") 45 | self._description_label.set_label(cleaned_topic) 46 | else: 47 | self._description_label.hide() 48 | 49 | threading.Thread(target=self._get_last_activity_time_target).start() 50 | 51 | async def _get_last_activity_time_async_target(self, channel: discord.TextChannel) -> datetime.datetime: 52 | async for message in channel.history(limit=1): 53 | return message.created_at 54 | 55 | def _get_last_activity_time_target(self): 56 | last_activity_time = asyncio.run_coroutine_threadsafe( 57 | self._get_last_activity_time_async_target(self._channel_disc), 58 | self.app.discord_loop 59 | ).result() 60 | 61 | GLib.idle_add(self._set_last_activity_gtk_target, last_activity_time) 62 | 63 | def _set_last_activity_gtk_target(self, time: datetime.datetime): 64 | readable_time = time.strftime("%a. %dd. %Hh. %Mm.") 65 | self._last_activity_button.set_label(readable_time) 66 | 67 | @Gtk.Template.Callback() 68 | def _on_last_activity_button_activate(self, button): 69 | time = self._last_activity_button.get_label() 70 | 71 | clipboard = self.get_clipboard() 72 | clipboard.set(time) 73 | 74 | @Gtk.Template.Callback() 75 | def _on_statistics_button_activate(self, button): 76 | raise NotImplementedError 77 | 78 | @Gtk.Template.Callback() 79 | def _on_back_button_clicked(self, button): 80 | # for mobile the back button is basically 81 | # a close button 82 | self.destroy() 83 | -------------------------------------------------------------------------------- /mirdorph/confman.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | # 16 | # Heavily based on Giara code 17 | 18 | import os 19 | import json 20 | import logging 21 | import copy 22 | from pathlib import Path 23 | from gi.repository import GObject 24 | 25 | 26 | class ConfManager(GObject.GObject): 27 | """ 28 | The ConfManager is a system that helps manage the configuration 29 | of the application. 30 | 31 | It is recommended to ever only have one instance, and add it to 32 | your application class 33 | """ 34 | __gsignals__ = { 35 | "setting_changed": (GObject.SignalFlags.RUN_FIRST, None, 36 | (str,)) 37 | } 38 | 39 | BASE_SCHEMA = { 40 | # Example and for testing 41 | "example": 0, 42 | "tos_notice_accepted": False, 43 | "send_typing_events": True, 44 | "preview_links": True, 45 | "enabled_extensions": [ 46 | "login_method_graphical", 47 | "login_method_password" 48 | ] 49 | } 50 | 51 | def __init__(self, path: Path = None): 52 | """ 53 | Create a ConfManager 54 | 55 | param: 56 | path: (optional) override the default path 57 | """ 58 | GObject.GObject.__init__(self) 59 | if path is None: 60 | self.path = Path( 61 | os.environ["XDG_CONFIG_HOME"] + "/" + "mirdorph.conf.json") 62 | else: 63 | self.path = path 64 | 65 | if self.path.is_file(): 66 | try: 67 | with open(str(self.path)) as fd: 68 | self._conf = json.load(fd) 69 | # All keys should be known to exist 70 | for k in ConfManager.BASE_SCHEMA: 71 | if k not in self._conf.keys(): 72 | if isinstance( 73 | ConfManager.BASE_SCHEMA[k], (list, dict) 74 | ): 75 | self._conf[k] = ConfManager.BASE_SCHEMA[k].copy() 76 | else: 77 | self._conf[k] = ConfManager.BASE_SCHEMA[k] 78 | except: 79 | logging.warning("unknown conf error, resetting conf") 80 | self._conf = ConfManager.BASE_SCHEMA.copy() 81 | self.save_conf() 82 | else: 83 | logging.warning("no conf file found, creating") 84 | self._conf = ConfManager.BASE_SCHEMA.copy() 85 | self.save_conf() 86 | 87 | def save_conf(self): 88 | """ 89 | Force save current configuration to disk 90 | """ 91 | with open(str(self.path), "w") as fd: 92 | fd.write(json.dumps(self._conf)) 93 | 94 | def set_value(self, name: str, val: any): 95 | """ 96 | Set a value in the configuration 97 | 98 | You do not need to use save_conf after this 99 | as it is done automatically. 100 | 101 | param: 102 | name: str name of the key, can be any json 103 | serializable object 104 | val: any json serializable value 105 | """ 106 | self._conf[name] = copy.deepcopy(val) 107 | self.save_conf() 108 | self.emit("setting_changed", name) 109 | 110 | def get_value(self, name: str) -> any: 111 | return self._conf[name] 112 | -------------------------------------------------------------------------------- /mirdorph/disc_cogs/meson.build: -------------------------------------------------------------------------------- 1 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 2 | moduledir = join_paths(pkgdatadir, 'mirdorph', 'disc_cogs') 3 | 4 | discord_test_cogs_sources = [ 5 | 'event_listening.py' 6 | ] 7 | 8 | install_data(discord_test_cogs_sources, install_dir: moduledir) 9 | -------------------------------------------------------------------------------- /mirdorph/event_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import logging 17 | from .event_receiver import EventReceiver 18 | from gi.repository import Gio, Gtk 19 | 20 | class EventManager: 21 | """ 22 | Manages events and event receivers 23 | 24 | You need your application to have one as .event_manager 25 | for the event receivers to register against. 26 | """ 27 | def __init__(self): 28 | self.app = Gio.Application.get_default() 29 | self._receivers = [] 30 | 31 | def register_receiver(self, receiver: EventReceiver): 32 | """ 33 | Register a receiver 34 | 35 | param: 36 | receiver: the receiver object that will now receive e 37 | NOTE: usually users don't use this as the EventReceiver __init__ 38 | does it 39 | """ 40 | self._receivers.append(receiver) 41 | 42 | def dispatch_event(self, name: str, *args, **kwargs): 43 | logging.info(f"re-dispatcihing event {name} to GLib") 44 | for receiver in self._receivers: 45 | func = getattr(receiver, ("disc_" + name)) 46 | func(*args, **kwargs) 47 | -------------------------------------------------------------------------------- /mirdorph/event_receiver.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import discord 17 | from gi.repository import Gio, Gtk 18 | 19 | 20 | class EventReceiver: 21 | """ 22 | An object used to receive Discord events 23 | 24 | To use, subclass it and init it. 25 | Then on functions "disc_" + event name from documentation 26 | you can receive events and all the arguments 27 | """ 28 | 29 | def __init__(self): 30 | self._ev_app = Gio.Application.get_default() 31 | self._ev_app.event_manager.register_receiver(self) 32 | 33 | def disc_on_ready(self, *args, **kwargs): 34 | pass 35 | 36 | def disc_on_connect(self, *args, **kwargs): 37 | pass 38 | 39 | def disc_on_shard_connect(self, *args, **kwargs): 40 | pass 41 | 42 | def disc_on_disconnect(self, *args, **kwargs): 43 | pass 44 | 45 | def disc_on_shard_disconnect(self, *args, **kwargs): 46 | pass 47 | 48 | def disc_on_ready(self, *args, **kwargs): 49 | pass 50 | 51 | def disc_on_shard_ready(self, *args, **kwargs): 52 | pass 53 | 54 | def disc_on_resumed(self, *args, **kwargs): 55 | pass 56 | 57 | def disc_on_shard_resumed(self, *args, **kwargs): 58 | pass 59 | 60 | def disc_on_error(self, event, *args, **kwargs): 61 | pass 62 | 63 | def disc_on_socket_raw_receive(self, *args, **kwargs): 64 | pass 65 | 66 | def disc_on_socket_raw_send(self, *args, **kwargs): 67 | pass 68 | 69 | def disc_on_typing(self, *args, **kwargs): 70 | pass 71 | 72 | def disc_on_message(self, *args, **kwargs): 73 | pass 74 | 75 | def disc_on_message_delete(self, *args, **kwargs): 76 | pass 77 | 78 | def disc_on_bulk_message_delete(self, *args, **kwargs): 79 | pass 80 | 81 | def disc_on_raw_message_delete(self, *args, **kwargs): 82 | pass 83 | 84 | def disc_on_message_edit(self, *args, **kwargs): 85 | pass 86 | 87 | def disc_on_raw_message_edit(self, *args, **kwargs): 88 | pass 89 | 90 | def disc_on_reaction_add(self, *args, **kwargs): 91 | pass 92 | 93 | def disc_on_raw_reaction_add(self, *args, **kwargs): 94 | pass 95 | 96 | def disc_on_reaction_remove(self, *args, **kwargs): 97 | pass 98 | 99 | def disc_on_raw_reaction_remove(self, *args, **kwargs): 100 | pass 101 | 102 | def disc_on_reaction_clear(self, *args, **kwargs): 103 | pass 104 | 105 | def disc_on_raw_reaction_clear(self, *args, **kwargs): 106 | pass 107 | 108 | def disc_on_reaction_clear_emoji(self, *args, **kwargs): 109 | pass 110 | 111 | def disc_on_raw_reaction_clear_emoji(self, *args, **kwargs): 112 | pass 113 | 114 | def disc_on_private_channel_delete(self, *args, **kwargs): 115 | pass 116 | 117 | def disc_on_private_channel_create(self, *args, **kwargs): 118 | pass 119 | 120 | def disc_on_private_channel_update(self, *args, **kwargs): 121 | pass 122 | 123 | def disc_on_private_channel_pins_update(self, *args, **kwargs): 124 | pass 125 | 126 | def disc_on_guild_channel_delete(self, *args, **kwargs): 127 | pass 128 | 129 | def disc_on_guild_channel_create(self, *args, **kwargs): 130 | pass 131 | 132 | def disc_on_guild_channel_update(self, *args, **kwargs): 133 | pass 134 | 135 | def disc_on_guild_channel_pins_update(self, *args, **kwargs): 136 | pass 137 | 138 | def disc_on_guild_integrations_update(self, *args, **kwargs): 139 | pass 140 | 141 | def disc_on_webhooks_update(self, *args, **kwargs): 142 | pass 143 | 144 | def disc_on_member_join(self, *args, **kwargs): 145 | pass 146 | 147 | def disc_on_member_remove(self, *args, **kwargs): 148 | pass 149 | 150 | def disc_on_member_update(self, *args, **kwargs): 151 | pass 152 | 153 | def disc_on_user_update(self, *args, **kwargs): 154 | pass 155 | 156 | def disc_on_guild_join(self, *args, **kwargs): 157 | pass 158 | 159 | def disc_on_guild_remove(self, *args, **kwargs): 160 | pass 161 | 162 | def disc_on_guild_update(self, *args, **kwargs): 163 | pass 164 | 165 | def disc_on_guild_role_create(self, *args, **kwargs): 166 | pass 167 | 168 | def disc_on_guild_role_delete(self, *args, **kwargs): 169 | pass 170 | 171 | def disc_on_guild_role_update(self, *args, **kwargs): 172 | pass 173 | 174 | def disc_on_guild_emojis_update(self, *args, **kwargs): 175 | pass 176 | 177 | def disc_on_guild_available(self, *args, **kwargs): 178 | pass 179 | 180 | def disc_on_guild_unavailable(self, *args, **kwargs): 181 | pass 182 | 183 | def disc_on_voice_state_update(self, *args, **kwargs): 184 | pass 185 | 186 | def disc_on_member_ban(self, *args, **kwargs): 187 | pass 188 | 189 | def disc_on_member_unban(self, *args, **kwargs): 190 | pass 191 | 192 | def disc_on_invite_create(self, *args, **kwargs): 193 | pass 194 | 195 | def disc_on_invite_delete(self, *args, **kwargs): 196 | pass 197 | 198 | def disc_on_group_join(self, *args, **kwargs): 199 | pass 200 | 201 | def disc_on_group_remove(self, *args, **kwargs): 202 | pass 203 | 204 | def disc_on_relationship_add(self, *args, **kwargs): 205 | pass 206 | 207 | def disc_on_relationship_remove(self, *args, **kwargs): 208 | pass 209 | 210 | def disc_on_relationship_update(self, *args, **kwargs): 211 | pass 212 | -------------------------------------------------------------------------------- /mirdorph/link_preview.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import linkpreview 17 | import threading 18 | import logging 19 | import requests 20 | import urllib 21 | import hashlib 22 | import os 23 | import gi 24 | from pathlib import Path 25 | from gi.repository import Gtk, Gdk, GdkPixbuf, GLib 26 | 27 | 28 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/ui/link_preview.ui") 29 | class LinkPreviewExport(Gtk.ListBox): 30 | __gtype_name__ = "LinkPreviewExport" 31 | 32 | _image_dir_path = Path(os.environ["XDG_CACHE_HOME"] / Path("mirdorph")) 33 | 34 | _link_label: Gtk.Label = Gtk.Template.Child() 35 | _link_image: Gtk.Image = Gtk.Template.Child() 36 | 37 | def __init__(self, link: str, *args, **kwargs): 38 | Gtk.ListBox.__init__(self, *args, **kwargs) 39 | self.link = link 40 | self._link_label.set_label(link) 41 | 42 | threading.Thread(target=self._fetch_preview).start() 43 | 44 | def _fetch_preview(self): 45 | try: 46 | preview = linkpreview.link_preview(self.link) 47 | except: 48 | logging.warning(f"could not get preview for {self.link}") 49 | return 50 | 51 | image_path = None 52 | if preview.image: 53 | try: 54 | r = requests.get(preview.image) 55 | except requests.exceptions.MissingSchema: 56 | # Some websites use relative links, which is why this is needed 57 | parsed = urllib.parse.urlparse(self.link) 58 | url_with_schema = parsed.scheme + "://" + parsed.netloc + preview.image 59 | r = requests.get(url_with_schema) 60 | 61 | web_image_path = urllib.parse.urlparse(preview.image).path 62 | ext = os.path.split(web_image_path)[1] 63 | file_hash = hashlib.sha256(preview.image.encode("utf-8")).hexdigest() 64 | 65 | image_path = self._image_dir_path / \ 66 | Path(f"link_image_{file_hash}.{ext}") 67 | 68 | with open(image_path, "wb") as f: 69 | f.write(r.content) 70 | 71 | GLib.idle_add(self._display_preview, preview.title, image_path) 72 | 73 | def _display_preview(self, title: str, image_path: str): 74 | if title: 75 | self._link_label.set_label(title) 76 | if image_path: 77 | try: 78 | image_pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( 79 | str(image_path), 80 | # 120 = request(140) - some phantom 20 - the padding (optional??) 81 | 120, 82 | 120, 83 | False 84 | ) 85 | except gi.repository.GLib.Error: 86 | logging.warning(f"attempted to load load GIF preview for {self.link}") 87 | return 88 | self._link_image.set_from_pixbuf(image_pixbuf) 89 | 90 | @Gtk.Template.Callback() 91 | def _on_row_activated(self, listbox, row): 92 | Gtk.show_uri_on_window(None, self.link, Gdk.CURRENT_TIME) 93 | -------------------------------------------------------------------------------- /mirdorph/main_window.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import os 17 | import logging 18 | import threading 19 | import queue 20 | import time 21 | from gi.repository import Adw, Gtk, GLib 22 | from .event_receiver import EventReceiver 23 | from .channel_inner_window import ChannelInnerWindow 24 | from .channel_sidebar import MirdorphChannelSidebar 25 | 26 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/ui/main_window.ui") 27 | class MirdorphMainWindow(Adw.ApplicationWindow, EventReceiver): 28 | __gtype_name__ = "MirdorphMainWindow" 29 | 30 | _loading_stack: Gtk.Stack = Gtk.Template.Child() 31 | _loading_progress_bar: Gtk.ProgressBar = Gtk.Template.Child() 32 | 33 | main_flap: Adw.Flap = Gtk.Template.Child() 34 | # Public, the contexts themselves interact with the stack to manage popout and popin 35 | context_stack: Gtk.Stack = Gtk.Template.Child() 36 | _flap_box: Gtk.Box = Gtk.Template.Child() 37 | 38 | _channel_search_button: Gtk.ToggleButton = Gtk.Template.Child() 39 | 40 | def __init__(self, *args, **kwargs): 41 | Adw.ApplicationWindow.__init__(self, *args, **kwargs) 42 | EventReceiver.__init__(self) 43 | 44 | self._empty_inner_window = ChannelInnerWindow(empty=True) 45 | self.main_flap.connect("notify::folded", self._empty_inner_window.handle_flap_folding) 46 | self.context_stack.add_child(self._empty_inner_window) 47 | 48 | self._channel_sidebar = MirdorphChannelSidebar(channel_search_button=self._channel_search_button, vexpand=True) 49 | self._flap_box.append(self._channel_sidebar) 50 | 51 | GLib.timeout_add(150, self._progress_bar_target) 52 | 53 | def _progress_bar_target(self): 54 | self._loading_progress_bar.pulse() 55 | # We want it to stop pulsing when loading has stopped 56 | return self._loading_stack.get_visible_child_name() == "loading" 57 | 58 | def disc_on_ready(self, *args): 59 | # on_ready shows that overall we are connected, and client.guilds 60 | # becomes available 61 | self._loading_stack.set_visible_child_name("session") 62 | 63 | def _setting_switching_focus_gtk_target(self, context): 64 | try: 65 | context.do_first_see() 66 | except AttributeError: 67 | logging.warning("impossible to set default focus of empty status context") 68 | 69 | @Gtk.Template.Callback() 70 | # When toggling search, the sidebar with the search should always be revealed, 71 | # and the button being active is an indication of that 72 | def _on_channel_search(self, button, gparam): 73 | if self._channel_search_button.get_active(): 74 | # When in stack navigation, we don't want to reveal the flap 75 | if self.main_flap.get_folded(): 76 | self.main_flap.set_reveal_flap(True) 77 | 78 | @Gtk.Template.Callback() 79 | def _on_context_stack_focus_change(self, stack, strpar): 80 | # I have been trying to set the default focus when swithing to a child to be the entry bar 81 | # However, you can't do it here, as you need to wait for GTK to finish everything, 82 | # which is why GLib.idle_add. And also why the standard focus APIs don't work, 83 | # as the channel is displayed down the line. 84 | GLib.idle_add(self._setting_switching_focus_gtk_target, stack.get_visible_child()) 85 | 86 | try: 87 | stack.get_visible_child().load_history() 88 | except AttributeError: 89 | logging.warning("impossible to load history of empty status context") 90 | 91 | @Gtk.Template.Callback() 92 | def _on_window_close(self, window): 93 | self.props.application.quit() 94 | 95 | def reconfigure_for_popout_window(self): 96 | """ 97 | Configure the main win for a popout window 98 | 99 | This basically just makes sure the status page 100 | for no channel selected appears. 101 | 102 | NOTE: must be called AFTER you remove the channelcontext 103 | """ 104 | self.context_stack.set_visible_child(self._empty_inner_window) 105 | 106 | def unconfigure_popout_window(self, context): 107 | """ 108 | Unconfigure the main win for a popout window 109 | 110 | This basically adds it back to the stack and makes it 111 | the currently displayed one 112 | 113 | param: 114 | context - the ChannelInnerWindow context 115 | """ 116 | self.context_stack.add_child(context) 117 | self.context_stack.set_visible_child(context) 118 | 119 | def show_active_channel(self, channel_id): 120 | """ 121 | Display a specifed channel to the user 122 | 123 | param: 124 | channel_id: int of the channel that you want 125 | to display 126 | """ 127 | context = self.props.application.retrieve_inner_window_context(channel_id) 128 | if context.is_poped: 129 | temp_win_top = context.get_native() 130 | if temp_win_top: 131 | temp_win_top.present() 132 | 133 | # May seem weird to use it here, however without it the previous channel 134 | # is shown, and the channel selection breaks. 135 | self.reconfigure_for_popout_window() 136 | else: 137 | # Temp hack to get it to work when switching channels in mobile 138 | # mode 139 | context.handle_flap_folding(self.main_flap, None) 140 | 141 | self.context_stack.set_visible_child(context) 142 | # For making the entry the default focus 143 | context.do_first_see() 144 | 145 | -------------------------------------------------------------------------------- /mirdorph/meson.build: -------------------------------------------------------------------------------- 1 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 2 | moduledir = join_paths(pkgdatadir, 'mirdorph') 3 | plugindir = join_paths(moduledir, 'plugins') 4 | gnome = import('gnome') 5 | python = import('python') 6 | 7 | conf = configuration_data() 8 | conf.set('PYTHON', python.find_installation('python3').path()) 9 | conf.set('VERSION', meson.project_version()) 10 | conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 11 | conf.set('pkgdatadir', pkgdatadir) 12 | 13 | configure_file( 14 | input: 'mirdorph.in', 15 | output: 'mirdorph', 16 | configuration: conf, 17 | install: true, 18 | install_dir: get_option('bindir') 19 | ) 20 | 21 | mirdorph_sources = [ 22 | '__init__.py', 23 | 'main.py', 24 | 'login_window.py', 25 | 'main_window.py', 26 | 'event_manager.py', 27 | 'event_receiver.py', 28 | 'channel_inner_window.py', 29 | 'message_view.py', 30 | 'message.py', 31 | 'message_parsing.py', 32 | 'message_entry_bar.py', 33 | 'attachment.py', 34 | 'channel_sidebar.py', 35 | 'confman.py', 36 | 'channel_properties_window.py', 37 | 'typing_indicator.py', 38 | 'settings_window.py', 39 | 'link_preview.py', 40 | 'image_viewer.py', 41 | 'plugin.py' 42 | ] 43 | 44 | install_data(mirdorph_sources, install_dir: moduledir) 45 | 46 | subdir('disc_cogs') 47 | subdir('plugins') 48 | -------------------------------------------------------------------------------- /mirdorph/message_parsing.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import copy 17 | import logging 18 | import re 19 | import gi 20 | import html2pango 21 | import mistune 22 | from gi.repository import Adw, Gtk, Gio, Pango 23 | from enum import Enum 24 | from .link_preview import LinkPreviewExport 25 | 26 | 27 | class ComponentType(Enum): 28 | STANDARD = 0 29 | QUOTE = 1 30 | 31 | 32 | def calculate_msg_parts(original_content: str) -> list: 33 | """ 34 | Calculate and separate a list of types of components in a str message. 35 | 36 | This isn't low-level formatting stuff, and discord mentions for example, 37 | but top-level stuff like separating quotes out, and probably in the future 38 | codeblocks. 39 | 40 | returns: 41 | `list` of `tuple` where each tupple is (ComponentType, text: str) 42 | """ 43 | components = [] 44 | 45 | # NOTE: components is a mutable list 46 | def reset_last_component(current_component_type: ComponentType, current_component_text: str, 47 | new_component_type: ComponentType, components: list) -> bool: 48 | if current_component_text and current_component_type != new_component_type: 49 | # Without this, before other parts we have a stupid blank line, (for example quotes) 50 | # For now for all types, and probably for all future ones we also will need this 51 | if current_component_text.endswith("\n"): 52 | current_component_text = current_component_text[:-1] 53 | components.append( 54 | ( 55 | copy.deepcopy(current_component_type), 56 | copy.deepcopy(current_component_text) 57 | ) 58 | ) 59 | # Is this really a new component and we should reset the counter? 60 | return True 61 | return False 62 | 63 | current_component_text = "" 64 | current_component_type = ComponentType.STANDARD 65 | for line in original_content.splitlines(keepends=True): 66 | if line.startswith("> ") or line.startswith(">>> "): 67 | if reset_last_component(current_component_type, current_component_text, ComponentType.QUOTE, components): 68 | current_component_text = "" 69 | current_component_type = None 70 | 71 | current_component_type = ComponentType.QUOTE 72 | 73 | # We don't put the > into the output, because 74 | # it is expected to handle that manually after the fact 75 | amount = len("> ") 76 | if line.startswith(">>>"): 77 | amount = len(">>> ") 78 | current_component_text += line[amount:] 79 | else: 80 | if reset_last_component(current_component_type, current_component_text, ComponentType.STANDARD, components): 81 | current_component_text = "" 82 | current_component_type = None 83 | current_component_type = ComponentType.STANDARD 84 | current_component_text += line 85 | 86 | # When the loop ends, we also need to add the last one 87 | if current_component_text: 88 | components.append( 89 | ( 90 | copy.deepcopy(current_component_type), 91 | copy.deepcopy(current_component_text) 92 | ) 93 | ) 94 | 95 | return components 96 | 97 | 98 | def _extract_discord_components(message_string) -> list: 99 | """ 100 | Extract the discord-specific components of a string. 101 | Returns a list of tuples. Where each tuple is: 102 | 0 - discord component type 103 | 1 - the data of the component 104 | 2 - range of chars of the original message string that are the 105 | extracted component 106 | 107 | List empty if none exist 108 | """ 109 | # Not immplemented for now, need to figure out embeding widgets in labels. 110 | return [] 111 | 112 | 113 | def _generate_exports(message_string: str, include_links: bool=True): 114 | links = [] 115 | if include_links: 116 | links = re.findall(r"(?Phttps?://[^\s]+)", message_string) 117 | return [LinkPreviewExport(link) for link in links] 118 | 119 | 120 | def _create_pango_markup(message_string: str) -> str: 121 | html_base = mistune.html(html2pango.html_escape(message_string)) 122 | workd_on_str = html2pango.markup_from_raw(html_base) 123 | 124 | return workd_on_str 125 | 126 | 127 | def build_widget_list(message_string: str) -> list: 128 | """ 129 | Build a widget list for a part of text in a discord message. 130 | List elements can be either a string of pango markup, or 131 | a custom Gtk Widget 132 | 133 | returns: 134 | A list of either `Gtk.Widget` or `str` 135 | """ 136 | # Discord components need to be parsed before converting the strings 137 | # to pango. 138 | discord_components = _extract_discord_components(message_string) 139 | if discord_components: 140 | pass 141 | 142 | widget_list = [] 143 | widget_list.append(_create_pango_markup(message_string)) 144 | 145 | return widget_list 146 | 147 | 148 | class MessageComponent(Adw.Bin): 149 | def __init__(self, component_content: str, component_type: ComponentType, *args, **kwargs): 150 | Adw.Bin.__init__(self, *args, **kwargs) 151 | self.app = Gio.Application.get_default() 152 | self.component_type = component_type 153 | self._raw_component_content = component_content 154 | # Exports are based on non-sescaped, non-processed content 155 | self.exports = _generate_exports( 156 | self._raw_component_content, 157 | include_links=self.app.confman.get_value("preview_links") 158 | ) 159 | 160 | if self.component_type in [ComponentType.STANDARD, ComponentType.QUOTE]: 161 | self._text_label = Gtk.Label( 162 | wrap=True, 163 | wrap_mode=Pango.WrapMode.WORD_CHAR, 164 | selectable=True, 165 | xalign=0.0 166 | ) 167 | # Safe currently as only strings 168 | self._text_label.set_markup("".join(build_widget_list(self._raw_component_content))) 169 | 170 | if self.component_type == ComponentType.QUOTE: 171 | self._text_label.add_css_class("quote") 172 | 173 | self.set_child(self._text_label) 174 | -------------------------------------------------------------------------------- /mirdorph/mirdorph.in: -------------------------------------------------------------------------------- 1 | #!@PYTHON@ 2 | 3 | # Copyright 2021 Raidro Manchester 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | 18 | import os 19 | import sys 20 | import signal 21 | import gettext 22 | import asyncio 23 | import threading 24 | import keyring 25 | import discord 26 | import logging 27 | from discord.ext import commands 28 | 29 | VERSION = '@VERSION@' 30 | pkgdatadir = '@pkgdatadir@' 31 | localedir = '@localedir@' 32 | 33 | sys.path.insert(1, pkgdatadir) 34 | signal.signal(signal.SIGINT, signal.SIG_DFL) 35 | 36 | if __name__ == '__main__': 37 | logging.basicConfig(level=logging.INFO) 38 | 39 | def init_gtk(discord_loop, client, keyring_exists): 40 | logging.info("starting gtk") 41 | # Gettext works for the python part, locale for the xml part. 42 | import gettext 43 | import locale 44 | locale.bindtextdomain("mirdorph", localedir) 45 | locale.textdomain("mirdorph") 46 | gettext.textdomain("mirdorph") 47 | gettext.bindtextdomain("mirdorph", localedir) 48 | 49 | import gi 50 | gi.require_version('Gtk', '4.0') 51 | gi.require_version('Adw', '1') 52 | from gi.repository import Gio, Gtk, Adw 53 | 54 | resource = Gio.Resource.load(os.path.join(pkgdatadir, 'mirdorph.gresource')) 55 | resource._register() 56 | 57 | from mirdorph import main 58 | sys.exit( 59 | main.main( 60 | VERSION, 61 | discord_loop=discord_loop, 62 | discord_client=client, 63 | keyring_exists=keyring_exists 64 | ) 65 | ) 66 | 67 | def init_discord(discord_client, discord_token): 68 | if discord_token: 69 | cogs = [ 70 | 'mirdorph.disc_cogs.event_listening' 71 | ] 72 | 73 | for cog in cogs: 74 | discord_client.load_extension(cog) 75 | 76 | try: 77 | discord_client.run(discord_token, bot=False) 78 | except discord.errors.LoginFailure: 79 | keyring.delete_password("mirdorph", "token") 80 | from gi.repository import GLib, Gio 81 | GLib.idle_add(lambda *_ : Gio.Application.get_default().relaunch()) 82 | 83 | logging.info("retrieving token") 84 | discord_token = keyring.get_password("mirdorph", "token") 85 | if discord_token is None: 86 | logging.info("token doesn't exist") 87 | keyring_exists = False 88 | else: 89 | logging.info("token exists") 90 | keyring_exists = True 91 | 92 | # If stuff breaks, drop intents and set fetch_offline_members to False again. 93 | intents = discord.Intents.all() 94 | client = commands.Bot(command_prefix="&&&", intents=intents, max_messages=100000000000000000, 95 | fetch_offline_members=True, guild_subscriptions=True) 96 | 97 | discord_loop = asyncio.get_event_loop() 98 | gtk_thread = threading.Thread(target=init_gtk, args=( 99 | discord_loop, client, keyring_exists)) 100 | gtk_thread.start() 101 | 102 | init_discord(client, discord_token) 103 | -------------------------------------------------------------------------------- /mirdorph/plugins/helloworld/configuration.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 30 | 31 | -------------------------------------------------------------------------------- /mirdorph/plugins/helloworld/helloworld.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | welcome_message.txt 5 | configuration.ui 6 | 7 | 8 | -------------------------------------------------------------------------------- /mirdorph/plugins/helloworld/helloworld.plugin: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Hello World", 3 | "module": "helloworld", 4 | "description": "A simple example plugin printing helloworld (or a selectable string) on startup", 5 | "built_in": false, 6 | "authors": [ 7 | "Raidro Manchester" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /mirdorph/plugins/helloworld/helloworld.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import gi 17 | from gi.repository import Adw, Gtk, Gio, GObject 18 | from mirdorph.plugin import MrdApplicationPlugin 19 | 20 | BYE_MESSAGE_CONFMAN_KEY = "helloworld_plugin_goodbye_message" 21 | DEFAULT_BYE_MESSAGE = "Bye, World" 22 | 23 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/plugins/helloworld/configuration.ui") 24 | class HelloSettingsPage(Adw.Clamp): 25 | __gtype_name__ = "HelloSettingsPage" 26 | 27 | _message_entry: Gtk.Entry = Gtk.Template.Child() 28 | 29 | def __init__(self, current_goodbye_message: str, *args, **kwargs): 30 | super().__init__(*args, **kwargs) 31 | self.app = Gio.Application.get_default() 32 | self._message_entry.set_text(current_goodbye_message) 33 | 34 | @Gtk.Template.Callback() 35 | def _on_map(self, widget): 36 | self._message_entry.grab_focus() 37 | 38 | @Gtk.Template.Callback() 39 | def _on_message_entry_changed(self, entry): 40 | self.app.confman.set_value(BYE_MESSAGE_CONFMAN_KEY, self._message_entry.get_text()) 41 | 42 | 43 | class HelloWorldPlugin(MrdApplicationPlugin): 44 | def __init__(self): 45 | super().__init__() 46 | self.app = Gio.Application.get_default() 47 | self._welcome_message = Gio.resources_lookup_data( 48 | "/org/gnome/gitlab/ranchester/Mirdorph/plugins/helloworld/welcome_message.txt", 0 49 | ).get_data().decode("utf-8") 50 | # Ugly, but since this is the example plugin used for tests, we really don't want to depend 51 | # on external stuff like MirdorphApplication directly 52 | try: 53 | self._goodbye_message = self.app.confman.get_value(BYE_MESSAGE_CONFMAN_KEY) 54 | except AttributeError: 55 | return 56 | except KeyError: 57 | self._goodbye_message = DEFAULT_BYE_MESSAGE 58 | self.app.confman.connect("setting-changed", self._on_confman_setting_changed) 59 | 60 | def _on_confman_setting_changed(self, confman, setting_name: str): 61 | if setting_name == BYE_MESSAGE_CONFMAN_KEY: 62 | self._goodbye_message = self.app.confman.get_value(BYE_MESSAGE_CONFMAN_KEY) 63 | 64 | def load(self): 65 | print(self._welcome_message) 66 | 67 | def unload(self): 68 | print(self._goodbye_message) 69 | 70 | def get_configuration_widget(self): 71 | return HelloSettingsPage(self._goodbye_message) 72 | -------------------------------------------------------------------------------- /mirdorph/plugins/helloworld/meson.build: -------------------------------------------------------------------------------- 1 | plugin_name = 'helloworld' 2 | 3 | data = [ 4 | 'helloworld.py', 5 | 'helloworld.plugin', 6 | 'helloworld.gresource.xml', 7 | 'welcome_message.txt', 8 | 'configuration.ui' 9 | ] 10 | 11 | install_data(data, install_dir: join_paths(plugindir, plugin_name)) 12 | -------------------------------------------------------------------------------- /mirdorph/plugins/helloworld/welcome_message.txt: -------------------------------------------------------------------------------- 1 | Hello World From Gresource! -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_graphical/discord_web_grabber.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import os 17 | import random 18 | import gi 19 | gi.require_version("WebKit2", "5.0") 20 | from gi.repository import Gtk, GObject, WebKit2 21 | from gettext import gettext as _ 22 | 23 | 24 | class DiscordGrabber(WebKit2.WebView): 25 | """ 26 | A custom Discord Web-based token grabber implementation. 27 | 28 | Created because discordlogin by diamondburned kind of sucks 29 | in some ways. 30 | 31 | To use, create the widget and listen for the "login-complete" 32 | signal, it has the token as an argument. 33 | 34 | The "login-failed" signal is emitted if it fails (comes with 35 | suggested help) 36 | """ 37 | __gtype_name__ = "DiscordGrabber" 38 | 39 | __gsignals__ = { 40 | "login_complete": (GObject.SIGNAL_RUN_FIRST, None, 41 | (str,)), 42 | "login_failed": (GObject.SIGNAL_RUN_FIRST, None, 43 | (str,)) 44 | } 45 | 46 | LOGIN_TROUBLESHOOT = _("Make sure the window isn't narrow.") 47 | 48 | def __init__(self, *args, **kwargs): 49 | WebKit2.WebView.__init__(self, is_ephemeral=True, *args, **kwargs) 50 | # URI schemes - how to send data from Javascript. 51 | # WebKitGtk doesn't seem to have an easy way to call host python code from 52 | # javascript, however we can register a custom URI scheme, 53 | # and put the data we want to send in the path. And trying to open 54 | # an arbitrary URI is easy in Javascript. 55 | 56 | # WebKit stores registered URI schemes globally, and fails if multiple of the same 57 | # are registered. We can't check which ones are registered easily, however we 58 | # can make them random to make this extremely unlikely. 59 | self.SCHEME_ID = random.randrange(1, 100000) 60 | self._scheme = f"token{str(self.SCHEME_ID)}" 61 | 62 | self.get_context().register_uri_scheme(self._scheme, self._token_uri_callback) 63 | 64 | # The js file can't immediately contain the scheme id, as it is unique 65 | with open(os.path.join(os.path.dirname(__file__), "get_token.js"), "r") as f: 66 | self._injection_code = f"var scheme_id = {self.SCHEME_ID}\n{f.read()}" 67 | 68 | self.connect("resource-load-started", self._on_resource_load_started) 69 | 70 | # The Javascript can not be loaded after the website, as then it is overriden. 71 | # However we can't just run it after load_uri either, as that will still get 72 | # it overriden. Listening to ::load-changed allows us to know when exactly 73 | # the website is fully loaded, which is when we need to inject the Javascript. 74 | self._initial_exec = False 75 | self.connect("load-changed", self._on_load_changed) 76 | self.load_uri("https://discord.com/login") 77 | 78 | def _on_load_changed(self, webview, load_event): 79 | if load_event == WebKit2.LoadEvent.FINISHED and not self._initial_exec: 80 | self._initial_exec = True 81 | self.run_javascript(self._injection_code, None, None, None) 82 | 83 | def _token_uri_callback(self, request: WebKit2.URISchemeRequest): 84 | self.emit("login_complete", request.get_uri()[len(f"{self._scheme}://"):]) 85 | # We don't want discord to be continued being displayed 86 | self.load_uri("http://www.blankwebsite.com") 87 | 88 | def _on_resource_load_started(self, webview, resource, request): 89 | # If the token isn't grabbed, we will go on to load the discord Application, 90 | # this indicates failure. 91 | if request.get_uri() == "https://discord.com/app": 92 | self.emit("login_failed", self.LOGIN_TROUBLESHOOT) 93 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_graphical/get_token.js: -------------------------------------------------------------------------------- 1 | let setRequestHeader = XMLHttpRequest.prototype.setRequestHeader 2 | let isAuth = (key, value) => { 3 | return key == "Authorization" && value && !value.startsWith("Bearer"); 4 | } 5 | 6 | XMLHttpRequest.prototype.setRequestHeader = function () { 7 | if (isAuth(arguments[0], arguments[1])) { 8 | window.location.href = "token" + scheme_id + "://" + arguments[1] 9 | return 10 | } 11 | 12 | setRequestHeader.apply(this, arguments); 13 | } 14 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_graphical/login_method_graphical.plugin: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Graphical Login", 3 | "module": "login_method_graphical", 4 | "description": "Connect your account with a simple graphical login supporting all methods of authentication.", 5 | "built_in": false, 6 | "authors": [ 7 | "Raidro Manchester" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_graphical/login_method_graphical.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import gi 17 | from gettext import gettext as _ 18 | from gi.repository import Adw, Gtk 19 | from mirdorph.plugin import MrdLoginMethodPlugin 20 | from .discord_web_grabber import DiscordGrabber 21 | 22 | 23 | class LoginMethodGraphical(MrdLoginMethodPlugin): 24 | def __init__(self): 25 | super().__init__() 26 | self.method_human_name = _("Graphical Login") 27 | self.is_primary = True 28 | 29 | self._login_method_cont = None 30 | self._grabber = None 31 | 32 | def load(self, login_method_cont): 33 | # Useful to directly have a reference of it for rebuilding it on failure 34 | self._login_method_cont = login_method_cont 35 | self._build_token_grabber() 36 | 37 | def unload(self, login_method_cont): 38 | self._grabber = None 39 | self._login_method_cont = None 40 | # We may not have this reference, so we should use the supplied one to avoid 41 | # headaches. 42 | login_method_cont.set_child(None) 43 | 44 | def _build_token_grabber(self): 45 | """ 46 | Build the DiscordGrabber and configure it for usage, add it to the method 47 | container. 48 | 49 | This is useful if you need to build it multiple times: for example if 50 | login failed. 51 | 52 | The container is the login method container at `self._login_method_cont` 53 | """ 54 | self._grabber = DiscordGrabber() 55 | self._grabber.connect("login-complete", lambda grabber, token : self.emit("token-obtained", token)) 56 | self._grabber.connect("login-failed", self._on_login_failed) 57 | self._login_method_cont.set_child(self._grabber) 58 | 59 | def _on_login_failed(self, grabber, help: str): 60 | self._grabber = None 61 | self._login_method_cont.set_child(None) 62 | self._build_token_grabber() 63 | 64 | dialog = Gtk.MessageDialog( 65 | buttons=Gtk.ButtonsType.OK, 66 | text="Login Failed", 67 | secondary_text=help, 68 | modal=True, 69 | transient_for=self._grabber.get_native() 70 | ) 71 | dialog.connect("response", lambda *_ : dialog.destroy()) 72 | dialog.show() 73 | 74 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_graphical/meson.build: -------------------------------------------------------------------------------- 1 | plugin_name = 'login_method_graphical' 2 | 3 | data = [ 4 | 'discord_web_grabber.py', 5 | 'get_token.js', 6 | 'login_method_graphical.py', 7 | 'login_method_graphical.plugin' 8 | ] 9 | 10 | install_data(data, install_dir: join_paths(plugindir, plugin_name)) 11 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_manual/login_method_manual.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | manual_login_page.ui 5 | 6 | 7 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_manual/login_method_manual.plugin: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Manual Token Login", 3 | "module": "login_method_manual", 4 | "description": "Connect your account by inserting your authentication token.", 5 | "built_in": true, 6 | "authors": [ 7 | "Raidro Manchester" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_manual/login_method_manual.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from __future__ import annotations 17 | import gi 18 | from gettext import gettext as _ 19 | from gi.repository import Adw, Gtk, Gio, GLib 20 | from mirdorph.plugin import MrdLoginMethodPlugin 21 | 22 | 23 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/plugins/login_method_manual/manual_login_page.ui") 24 | class ManualLoginPage(Adw.Clamp): 25 | __gtype_name__ = "ManualLoginPage" 26 | 27 | _token_entry: Gtk.Entry = Gtk.Template.Child() 28 | _token_submit_button: Gtk.Button = Gtk.Template.Child() 29 | 30 | def __init__(self, plugin: LoginMethodManual, *args, **kwargs): 31 | Adw.Clamp.__init__(self, *args, **kwargs) 32 | # Way to emit the token obtained signal from here 33 | self._plugin = plugin 34 | self._manual_login_action_group = Gio.SimpleActionGroup() 35 | 36 | self._action_token_submit = Gio.SimpleAction.new("submit", None) 37 | self._action_token_submit.set_enabled(False) 38 | self._action_token_submit.connect("activate", self._on_token_submit) 39 | self._manual_login_action_group.add_action(self._action_token_submit) 40 | 41 | self.insert_action_group("manual-login", self._manual_login_action_group) 42 | 43 | @Gtk.Template.Callback() 44 | def _on_token_entry_changed(self, entry): 45 | self._action_token_submit.set_enabled( 46 | self._token_entry.get_text() 47 | ) 48 | 49 | # Not entirely sure if this is how to correctly set the default widgets 50 | # and focus here 51 | @Gtk.Template.Callback() 52 | def _on_map(self, widget): 53 | window = self.get_native() 54 | # GLib.idle_add needed after first map, works correclty without it on first, 55 | # but not on subsequent uses 56 | GLib.idle_add(window.set_default_widget, self._token_submit_button) 57 | # grab_focus returns positive, causing infinite repetition 58 | GLib.idle_add(lambda *_ : self._token_entry.grab_focus() and 0) 59 | 60 | def _on_token_submit(self, *args): 61 | token = self._token_entry.get_text() 62 | self._token_entry.set_text("") 63 | self._plugin.emit("token-obtained", token) 64 | 65 | 66 | class LoginMethodManual(MrdLoginMethodPlugin): 67 | def __init__(self): 68 | super().__init__() 69 | self.method_human_name = _("Manual Token") 70 | self._page = None 71 | 72 | def load(self, login_method_cont): 73 | self._page = ManualLoginPage(self) 74 | login_method_cont.set_child(self._page) 75 | 76 | def unload(self, login_method_cont): 77 | login_method_cont.set_child(None) 78 | self._page = None 79 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_manual/manual_login_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 52 | 53 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_manual/meson.build: -------------------------------------------------------------------------------- 1 | plugin_name = 'login_method_manual' 2 | 3 | data = [ 4 | 'login_method_manual.py', 5 | 'login_method_manual.gresource.xml', 6 | 'login_method_manual.plugin', 7 | 'manual_login_page.ui' 8 | ] 9 | 10 | install_data(data, install_dir: join_paths(plugindir, plugin_name)) 11 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_password/login_method_password.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | password_login_page.ui 5 | 6 | 7 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_password/login_method_password.plugin: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Password Log In", 3 | "module": "login_method_password", 4 | "description": "Connect your account by entering your email and password.", 5 | "built_in": false, 6 | "authors": [ 7 | "Raidro Manchester" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_password/login_method_password.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from __future__ import annotations 17 | import threading 18 | import logging 19 | import gi 20 | import requests 21 | from gettext import gettext as _ 22 | from gi.repository import Gtk, Gio, GLib 23 | from mirdorph.plugin import MrdLoginMethodPlugin 24 | 25 | 26 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/plugins/login_method_password/password_login_page.ui") 27 | class PasswordLoginPage(Gtk.Overlay): 28 | __gtype_name__ = "PasswordLoginPage" 29 | 30 | _submit_button: Gtk.Button = Gtk.Template.Child() 31 | _email_entry: Gtk.Entry = Gtk.Template.Child() 32 | _password_entry: Gtk.PasswordEntry = Gtk.Template.Child() 33 | 34 | def __init__(self, plugin: LoginMethodPassword, *args, **kwargs): 35 | Gtk.Overlay.__init__(self, *args, **kwargs) 36 | self.app = Gio.Application.get_default() 37 | # Way to emit the token obtained signal from here 38 | self._plugin = plugin 39 | self._plugin.headerbar.pack_end(self._submit_button) 40 | 41 | # Hack: 42 | # action groups don't work on non-children, which this button is due to the headerbar separation, 43 | # so instead we add it to the global application, and use a fake prefix to indicate that this really 44 | # isn't an application-global action. 45 | self._action_password_submit = Gio.SimpleAction.new("fake-prefix-password-login-submit", None) 46 | self._action_password_submit.set_enabled(False) 47 | self._action_password_submit.connect("activate", self._on_submit) 48 | self.app.add_action(self._action_password_submit) 49 | 50 | @Gtk.Template.Callback() 51 | def _on_warning_bar_response(self, bar: Gtk.InfoBar, response_id: Gtk.ResponseType): 52 | if response_id == Gtk.ResponseType.CLOSE: 53 | bar.hide() 54 | 55 | @Gtk.Template.Callback() 56 | def _on_email_entry_activate(self, entry): 57 | if self._email_entry.get_text(): 58 | self._password_entry.grab_focus() 59 | 60 | @Gtk.Template.Callback() 61 | def _on_credentials_entries_changed(self, entry): 62 | self._action_password_submit.set_enabled( 63 | self._email_entry.get_text() and self._password_entry.get_text() 64 | ) 65 | 66 | @Gtk.Template.Callback() 67 | def _on_map(self, widget): 68 | window = self.get_native() 69 | window.set_default_widget(self._submit_button) 70 | 71 | @Gtk.Template.Callback() 72 | def _on_unmap(self, widget): 73 | self._plugin.headerbar.remove(self._submit_button) 74 | 75 | def _on_submit(self, *args): 76 | window = self.get_native() 77 | window.set_sensitive(False) 78 | threading.Thread(target=self._token_retrieval_target).start() 79 | 80 | def _token_retrieval_target(self): 81 | email = self._email_entry.get_text() 82 | password = self._password_entry.get_text() 83 | payload = { 84 | "login": email, 85 | "password": password 86 | } 87 | r = requests.post( 88 | "https://discord.com/api/v9/auth/login", json=payload) 89 | if "token" in r.json(): 90 | GLib.idle_add(self._plugin.emit, "token-obtained", r.json()["token"]) 91 | else: 92 | logging.fatal( 93 | "Token not found in Discord Password login response, login failed. Incorrect password?") 94 | GLib.idle_add(self.app.relaunch) 95 | 96 | 97 | class LoginMethodPassword(MrdLoginMethodPlugin): 98 | def __init__(self): 99 | super().__init__() 100 | self.method_human_name = _("Username and Password") 101 | self._page = None 102 | 103 | def load(self, login_method_cont): 104 | self._page = PasswordLoginPage(self) 105 | login_method_cont.set_child(self._page) 106 | 107 | def unload(self, login_method_cont): 108 | login_method_cont.set_child(None) 109 | self._page = None 110 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_password/meson.build: -------------------------------------------------------------------------------- 1 | plugin_name = 'login_method_password' 2 | 3 | data = [ 4 | 'login_method_password.py', 5 | 'login_method_password.gresource.xml', 6 | 'login_method_password.plugin', 7 | 'password_login_page.ui' 8 | ] 9 | 10 | install_data(data, install_dir: join_paths(plugindir, plugin_name)) 11 | -------------------------------------------------------------------------------- /mirdorph/plugins/login_method_password/password_login_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 110 | 111 | -------------------------------------------------------------------------------- /mirdorph/plugins/meson.build: -------------------------------------------------------------------------------- 1 | plugins = [ 2 | 'helloworld', 3 | 'login_method_graphical', 4 | 'login_method_manual', 5 | 'login_method_password' 6 | ] 7 | 8 | foreach plugin : plugins 9 | subdir(plugin) 10 | endforeach 11 | -------------------------------------------------------------------------------- /mirdorph/settings_window.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | from gettext import gettext as _ 17 | from gi.repository import Adw, Gtk, Gio, GObject 18 | from mirdorph.plugin import MrdPluginInfo 19 | 20 | 21 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/ui/extension_row.ui") 22 | class ExtensionRow(Adw.ActionRow): 23 | __gtype_name__ = "ExtensionRow" 24 | 25 | plugin = GObject.Property(type=MrdPluginInfo) 26 | 27 | _settings_button: Gtk.Button = Gtk.Template.Child() 28 | _is_active_switch: Gtk.Switch = Gtk.Template.Child() 29 | 30 | def __init__(self, plugin: MrdPluginInfo, preferences_window: Adw.PreferencesWindow, *args, **kwargs): 31 | Adw.ExpanderRow.__init__(self, *args, **kwargs) 32 | self.plugin = plugin 33 | self._preferences_window = preferences_window 34 | # GtkExpression directly in the UI file would be better, however after a lot 35 | # of trying I found out it doesn't work in PyGObject. When it gets support, 36 | # this should be easily changable to and in the UI file 37 | self.plugin.bind_property("name", self, "title", GObject.BindingFlags.SYNC_CREATE) 38 | self.plugin.bind_property("description", self, "subtitle", GObject.BindingFlags.SYNC_CREATE) 39 | self.plugin.bind_property("configurable", self._settings_button, "sensitive", GObject.BindingFlags.SYNC_CREATE) 40 | # SYNC_CREATE alone isn't enough for changing the property here to change it on the plugin object, 41 | # so for properties that can change at runtime, binding them again with different flags is needed too. 42 | self.plugin.bind_property("active", self._is_active_switch, "active", GObject.BindingFlags.SYNC_CREATE) 43 | self.plugin.bind_property("active", self._is_active_switch, "active", GObject.BindingFlags.BIDIRECTIONAL) 44 | 45 | @Gtk.Template.Callback() 46 | def _on_settings_button_clicked(self, button): 47 | self._preferences_window.present_extension_configuration(self.plugin) 48 | 49 | 50 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/ui/settings_window.ui") 51 | class MirdorphSettingsWindow(Adw.PreferencesWindow): 52 | __gtype_name__ = "MirdorphSettingsWindow" 53 | 54 | _send_typing_switch: Gtk.Switch = Gtk.Template.Child() 55 | _preview_links_switch: Gtk.Switch = Gtk.Template.Child() 56 | 57 | _extensions_pref_group: Adw.PreferencesGroup = Gtk.Template.Child() 58 | 59 | _configuration_page: Gtk.Box = Gtk.Template.Child() 60 | _configuration_window_title: Adw.WindowTitle = Gtk.Template.Child() 61 | _configuration_content: Adw.Bin = Gtk.Template.Child() 62 | 63 | def __init__(self, *args, **kwargs): 64 | Adw.PreferencesWindow.__init__(self, *args, **kwargs) 65 | self._init_values() 66 | self._init_extensions() 67 | 68 | def _init_values(self): 69 | self._send_typing_switch.set_state( 70 | self.props.application.confman.get_value("send_typing_events") 71 | ) 72 | self._preview_links_switch.set_state( 73 | self.props.application.confman.get_value("preview_links") 74 | ) 75 | 76 | def _init_extensions(self): 77 | for plugin in self.props.application.plugin_engine.get_available_plugins(): 78 | if not plugin.built_in: 79 | extension_row = ExtensionRow(plugin, self) 80 | self._extensions_pref_group.add(extension_row) 81 | 82 | def present_extension_configuration(self, plugin: MrdPluginInfo): 83 | """ 84 | Present the configuration settings of a configurable plugin to 85 | the user. 86 | 87 | param: 88 | plugin: the configurable `MrdPluginInfo` settings' you want to present 89 | """ 90 | self._configuration_window_title.set_title(_("{} Settings").format(plugin.name)) 91 | self._configuration_content.set_child(plugin.u_activatable.get_configuration_widget()) 92 | self.present_subpage( 93 | self._configuration_page 94 | ) 95 | 96 | @Gtk.Template.Callback() 97 | def _on_configuration_close(self, *args): 98 | self.close_subpage() 99 | 100 | @Gtk.Template.Callback() 101 | def _on_send_typing_switch_state_set(self, switch: Gtk.Switch, state: bool): 102 | self.props.application.confman.set_value("send_typing_events", state) 103 | 104 | @Gtk.Template.Callback() 105 | def _on_preview_links_switch_state_set(self, switch: Gtk.Switch, state: bool): 106 | self.props.application.confman.set_value("preview_links", state) 107 | -------------------------------------------------------------------------------- /mirdorph/typing_indicator.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Raidro Manchester 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | import discord 17 | import time 18 | import threading 19 | import datetime 20 | from gettext import gettext as _ 21 | from gi.repository import Gtk, GLib 22 | from xml.sax.saxutils import escape as escape_xml 23 | from .event_receiver import EventReceiver 24 | 25 | 26 | @Gtk.Template(resource_path="/org/gnome/gitlab/ranchester/Mirdorph/ui/typing_indicator.ui") 27 | class TypingIndicator(Gtk.Revealer, EventReceiver): 28 | __gtype_name__ = "TypingIndicator" 29 | 30 | _typing_label: Gtk.Label = Gtk.Template.Child() 31 | 32 | def __init__(self, channel: discord.channel.TextChannel, *args, **kwargs): 33 | Gtk.Revealer.__init__(self, *args, **kwargs) 34 | EventReceiver.__init__(self) 35 | self._channel = channel 36 | 37 | self._currently_typing_users = [] 38 | # Basically saving when the last typing event was received, 39 | # so we know when to stop displaying it. 40 | self._times_of_user_typings = [] 41 | 42 | def _wating_for_type_end_target(self, user: discord.User, when: datetime.datetime): 43 | # Even if we wanted this to be smaller, we can't decrease it too much as discord itself 44 | # only sends the typing event every so often. 45 | time.sleep(10) 46 | for tims in [typing_event[1] for typing_event in self._times_of_user_typings if typing_event[0] == user]: 47 | if tims > when: 48 | return 49 | 50 | if user in self._currently_typing_users: 51 | self._currently_typing_users.remove(user) 52 | GLib.idle_add(self._sync_typing_label) 53 | 54 | def _sync_typing_label(self): 55 | if self._currently_typing_users: 56 | self.set_reveal_child(True) 57 | username_list = ", ".join( 58 | [f"{escape_xml(user.name)}" for user in self._currently_typing_users] 59 | ) 60 | if len(self._currently_typing_users) >= 2: 61 | typing_info_message = _("%s are typing...") % username_list 62 | else: 63 | typing_info_message = _("%s is typing...") % username_list 64 | self._typing_label.set_markup(typing_info_message) 65 | else: 66 | self.set_reveal_child(False) 67 | self._typing_label.set_label(_("Noone is typing.")) 68 | 69 | def disc_on_message(self, message: discord.Message): 70 | if message.channel == self._channel and message.author in self._currently_typing_users: 71 | self._currently_typing_users.remove(message.author) 72 | self._sync_typing_label() 73 | 74 | def disc_on_typing(self, channel: discord.channel.TextChannel, user: discord.User, when: datetime.datetime): 75 | if user == self._channel.guild.me: 76 | return 77 | 78 | if channel.id == self._channel.id: 79 | if user not in self._currently_typing_users: 80 | self._currently_typing_users.append(user) 81 | self._sync_typing_label() 82 | self._times_of_user_typings.append((user, when)) 83 | threading.Thread(target=self._wating_for_type_end_target, args=(user, when)).start() 84 | -------------------------------------------------------------------------------- /patches/discord.py/0001-fix-broken-.content-and-.embeds-and-more-in-self-bot.patch: -------------------------------------------------------------------------------- 1 | From 476382f788fdab9dc3e6a3723f753a03e2de10bf Mon Sep 17 00:00:00 2001 2 | From: Raidro Manchester 3 | Date: Sat, 15 May 2021 16:02:51 +0300 4 | Subject: [PATCH] fix broken .content and .embeds and more in self bots 5 | 6 | To fix broken .content and .embeds, the main thing you need to do is to 7 | drop all intents before sending identify, and to substitute relasonship 8 | and recipient data when it randomly breaks. Then everything (as far as I 9 | can see) works again 10 | 11 | We do not need to change user agent or the identify packet more as the 12 | work that this patch is based on does. It is unncessary. 13 | 14 | We also warn when substituting the data with "LazyUser". 15 | --- 16 | discord/channel.py | 12 +++++++++++- 17 | discord/gateway.py | 4 +++- 18 | discord/relationship.py | 9 ++++++++- 19 | discord/state.py | 17 ++++++++++++++++- 20 | discord/user.py | 14 ++++++++++++++ 21 | 5 files changed, 52 insertions(+), 4 deletions(-) 22 | 23 | diff --git a/discord/channel.py b/discord/channel.py 24 | index bd4a39dc..b5322a2b 100644 25 | --- a/discord/channel.py 26 | +++ b/discord/channel.py 27 | @@ -26,6 +26,7 @@ DEALINGS IN THE SOFTWARE. 28 | 29 | import time 30 | import asyncio 31 | +import logging 32 | 33 | import discord.abc 34 | from .permissions import Permissions 35 | @@ -35,6 +36,8 @@ from . import utils 36 | from .asset import Asset 37 | from .errors import ClientException, NoMoreItems, InvalidArgument 38 | 39 | +log = logging.getLogger(__name__) 40 | + 41 | __all__ = ( 42 | 'TextChannel', 43 | 'VoiceChannel', 44 | @@ -1211,7 +1214,14 @@ class DMChannel(discord.abc.Messageable, Hashable): 45 | 46 | def __init__(self, *, me, state, data): 47 | self._state = state 48 | - self.recipient = state.store_user(data['recipients'][0]) 49 | + # Why? For some reason it complains about it not existing, 50 | + # so we workaround that by using lazy users 51 | + if 'recipients' in data: 52 | + self.recipient = state.store_user(data['recipients'][0]) 53 | + else: 54 | + log.warning('recipients data not available in payload, doing lazy') 55 | + self.recipient = state.store_lazy_user(data['recipient_ids'][0]) 56 | + 57 | self.me = me 58 | self.id = int(data['id']) 59 | 60 | diff --git a/discord/gateway.py b/discord/gateway.py 61 | index 210a8822..7647b591 100644 62 | --- a/discord/gateway.py 63 | +++ b/discord/gateway.py 64 | @@ -396,7 +396,9 @@ class DiscordWebSocket: 65 | } 66 | 67 | if state._intents is not None: 68 | - payload['d']['intents'] = state._intents.value 69 | + # If intents are sent as self bot, various message stuff 70 | + # breaks completely 71 | + pass 72 | 73 | await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify) 74 | await self.send_as_json(payload) 75 | diff --git a/discord/relationship.py b/discord/relationship.py 76 | index 0a9fffda..b309f134 100644 77 | --- a/discord/relationship.py 78 | +++ b/discord/relationship.py 79 | @@ -24,9 +24,12 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 80 | DEALINGS IN THE SOFTWARE. 81 | """ 82 | 83 | +import logging 84 | from .enums import RelationshipType, try_enum 85 | from . import utils 86 | 87 | +log = logging.getLogger(__name__) 88 | + 89 | class Relationship: 90 | """Represents a relationship in Discord. 91 | 92 | @@ -48,7 +51,11 @@ class Relationship: 93 | def __init__(self, *, state, data): 94 | self._state = state 95 | self.type = try_enum(RelationshipType, data['type']) 96 | - self.user = state.store_user(data['user']) 97 | + if 'user' in data: 98 | + self.user = state.store_user(data['user']) 99 | + else: 100 | + log.warning('relasionship data for user %s not available in payload, doing lazy' % data['user_id']) 101 | + self.user = state.store_lazy_user(data['user_id']) 102 | 103 | def __repr__(self): 104 | return ''.format(self) 105 | diff --git a/discord/state.py b/discord/state.py 106 | index da1212c1..5978f132 100644 107 | --- a/discord/state.py 108 | +++ b/discord/state.py 109 | @@ -39,7 +39,7 @@ import os 110 | 111 | from .guild import Guild 112 | from .activity import BaseActivity 113 | -from .user import User, ClientUser 114 | +from .user import User, ClientUser, LazyUser 115 | from .emoji import Emoji 116 | from .mentions import AllowedMentions 117 | from .partial_emoji import PartialEmoji 118 | @@ -280,6 +280,21 @@ class ConnectionState: 119 | self._users[user_id] = user 120 | return user 121 | 122 | + # Self bots in certain places may randomly not get the 123 | + # required data in the payload. In that case, 124 | + # we substitute it with this and give them a warning. 125 | + # Doesn't seem to break anything 126 | + def store_lazy_user(self, user_id): 127 | + # significantly (300%) faster than setdefault 128 | + user_id = int(user_id) 129 | + try: 130 | + return self._users[user_id] 131 | + except KeyError: 132 | + user = LazyUser(state=self, user_id=user_id) 133 | + if user.discriminator != '0000': 134 | + self._users[user_id] = user 135 | + return user 136 | + 137 | def store_user_no_intents(self, data): 138 | return User(state=self, data=data) 139 | 140 | diff --git a/discord/user.py b/discord/user.py 141 | index 8b2d3a1c..9426c5bf 100644 142 | --- a/discord/user.py 143 | +++ b/discord/user.py 144 | @@ -957,3 +957,17 @@ class User(BaseUser, discord.abc.Messageable): 145 | mutual_guilds=mutual_guilds, 146 | user=self, 147 | connected_accounts=data['connected_accounts']) 148 | + 149 | +# Private lazy user for self bots with substitute data, 150 | +# for when receiving real users in recipients and relationships 151 | +# throws random errors 152 | +class LazyUser(BaseUser): 153 | + def __init__(self, state, user_id): 154 | + fake_data = { 155 | + 'username': None, 156 | + 'id': user_id, 157 | + 'discriminator': 'BUMBA', 158 | + 'avatar': None, 159 | + } 160 | + 161 | + super().__init__(state=state, data=fake_data) 162 | -- 163 | 2.30.2 164 | 165 | -------------------------------------------------------------------------------- /patches/discord.py/0002-fix-on-typing-for-self-bots.patch: -------------------------------------------------------------------------------- 1 | From baf3848c225ccaefb0f5ac078e2ce6f063d4540e Mon Sep 17 00:00:00 2001 2 | From: Raidro Manchester 3 | Date: Tue, 1 Jun 2021 22:01:27 +0300 4 | Subject: [PATCH] fix on_typing for self bots 5 | 6 | For `on_typing` to fire for self bots, it is required to send opcode 14 7 | to the guild in question. 8 | 9 | This does that, which is apparently used by the official client to 10 | signify when it is "viewing" the guild. 11 | 12 | Apparently you should ensure that the channel is a text channel first, 13 | which I did. Based on doflies 14 | --- 15 | discord/client.py | 2 ++ 16 | discord/gateway.py | 33 ++++++++++++++++++++++++++++++++- 17 | 2 files changed, 34 insertions(+), 1 deletion(-) 18 | 19 | diff --git a/discord/client.py b/discord/client.py 20 | index 1c35fddf..a186b336 100644 21 | --- a/discord/client.py 22 | +++ b/discord/client.py 23 | @@ -269,6 +269,8 @@ class Client: 24 | await self.ws.request_sync(guilds) 25 | 26 | def _handle_ready(self): 27 | + for guild in self.guilds: 28 | + self.ws.subscribe_to_guild_events(guild) 29 | self._ready.set() 30 | 31 | @property 32 | diff --git a/discord/gateway.py b/discord/gateway.py 33 | index 7647b591..bf75471d 100644 34 | --- a/discord/gateway.py 35 | +++ b/discord/gateway.py 36 | @@ -40,7 +40,7 @@ import aiohttp 37 | 38 | from . import utils 39 | from .activity import BaseActivity 40 | -from .enums import SpeakingState 41 | +from .enums import SpeakingState, ChannelType 42 | from .errors import ConnectionClosed, InvalidArgument 43 | 44 | log = logging.getLogger(__name__) 45 | @@ -249,6 +249,8 @@ class DiscordWebSocket: 46 | a connection issue. 47 | GUILD_SYNC 48 | Send only. Requests a guild sync. 49 | + LAZY_GUILD_REQUEST 50 | + Send only. Subscribes you to guilds. Responds with GUILD_MEMBER_LIST_UPDATE sync. 51 | gateway 52 | The gateway we are currently connected to. 53 | token 54 | @@ -268,6 +270,7 @@ class DiscordWebSocket: 55 | HELLO = 10 56 | HEARTBEAT_ACK = 11 57 | GUILD_SYNC = 12 58 | + LAZY_GUILD_REQUEST = 14 59 | 60 | def __init__(self, socket, *, loop): 61 | self.socket = socket 62 | @@ -418,6 +421,34 @@ class DiscordWebSocket: 63 | await self.send_as_json(payload) 64 | log.info('Shard ID %s has sent the RESUME payload.', self.shard_id) 65 | 66 | + def subscribe_to_guild_events(self, guild): 67 | + """Sends opcode 14 to guild.""" 68 | + # The channel we use must be a text or news channel 69 | + first_valid_channel = None 70 | + for channel in guild.channels: 71 | + if channel.type in (ChannelType.news, ChannelType.text): 72 | + first_valid_channel = channel 73 | + 74 | + payload = { 75 | + "op": self.LAZY_GUILD_REQUEST, 76 | + "d": { 77 | + "guild_id": str(guild.id), 78 | + "typing": True, 79 | + "threads": False, 80 | + "activities": True, 81 | + "members": [], 82 | + "channels": { 83 | + str(first_valid_channel.id): [ 84 | + [ 85 | + 0, 86 | + 99 87 | + ] 88 | + ] 89 | + } 90 | + } 91 | + } 92 | + asyncio.ensure_future(self.send_as_json(payload), loop=self.loop) 93 | + 94 | async def received_message(self, msg): 95 | self._dispatch('socket_raw_receive', msg) 96 | 97 | -- 98 | 2.30.2 99 | 100 | -------------------------------------------------------------------------------- /patches/html2pango/0001-allow-usage-from-python.patch: -------------------------------------------------------------------------------- 1 | From 08945b9300fee9611b5619eb8ea9de82d1ae0471 Mon Sep 17 00:00:00 2001 2 | From: Raidro Manchester 3 | Date: Sun, 4 Jul 2021 18:10:39 +0300 4 | Subject: [PATCH] allow usage from python 5 | 6 | It fits my usecase to use this library, except from python. Even though 7 | I barely know Rust at all, here it works with PyO3. 8 | --- 9 | Cargo.toml | 8 ++++++++ 10 | src/lib.rs | 18 ++++++++++++++++++ 11 | 2 files changed, 26 insertions(+) 12 | 13 | diff --git a/Cargo.toml b/Cargo.toml 14 | index a703ad3..07c240d 100644 15 | --- a/Cargo.toml 16 | +++ b/Cargo.toml 17 | @@ -14,6 +14,10 @@ documentation = "https://world.pages.gitlab.gnome.org/html2pango/html2pango/" 18 | repository = "https://gitlab.gnome.org/World/html2pango" 19 | edition = "2018" 20 | 21 | +[lib] 22 | +name = "html2pango" 23 | +crate-type = ["cdylib"] 24 | + 25 | [dependencies] 26 | regex = "1.4.2" 27 | lazy_static = "1.4.0" 28 | @@ -24,5 +28,9 @@ anyhow = "1.0.35" 29 | html5ever = "0.25.1" 30 | markup5ever_rcdom = "0.1.0" 31 | 32 | +[dependencies.pyo3] 33 | +version = "0.14.0" 34 | +features = ["extension-module"] 35 | + 36 | [dev-dependencies] 37 | pretty_assertions = "0.6.1" 38 | diff --git a/src/lib.rs b/src/lib.rs 39 | index b583e35..08ed289 100644 40 | --- a/src/lib.rs 41 | +++ b/src/lib.rs 42 | @@ -11,12 +11,25 @@ use maplit::{hashmap, hashset}; 43 | 44 | use regex::Regex; 45 | 46 | +use pyo3::prelude::*; 47 | + 48 | const AMP: &str = "(&)"; 49 | const DOMAIN: &str = "[^\\s,)(\"]+"; 50 | const HASH: &str = "(#[\\w._-]+)?"; 51 | 52 | pub mod block; 53 | 54 | +#[pymodule] 55 | +fn html2pango(_py: Python, m: &PyModule) -> PyResult<()> { 56 | + m.add_function(wrap_pyfunction!(markup, m)?)?; 57 | + m.add_function(wrap_pyfunction!(markup_from_raw, m)?)?; 58 | + m.add_function(wrap_pyfunction!(matrix_html_to_markup, m)?)?; 59 | + m.add_function(wrap_pyfunction!(html_escape, m)?)?; 60 | + m.add_function(wrap_pyfunction!(markup_links, m)?)?; 61 | + 62 | + Ok(()) 63 | +} 64 | + 65 | /// Sanitize the input using [`ammonia`][ammonia]'s defaults, 66 | /// Convert the input `&str` to pango format and parse 67 | /// URLS to show as `pango` markup links(removes rel attributes). 68 | @@ -51,6 +64,7 @@ pub mod block; 69 | /// ``` 70 | /// 71 | /// [ammonia]: https://docs.rs/ammonia/1.1.0/ammonia/fn.clean.html 72 | +#[pyfunction] 73 | pub fn markup(s: &str) -> String { 74 | let sanitized_html = ammonia::Builder::new().link_rel(None).clean(s).to_string(); 75 | markup_from_raw(&sanitized_html) 76 | @@ -75,6 +89,7 @@ pub fn markup(s: &str) -> String { 77 | /// let m = markup_from_raw("with links: http://gnome.org"); 78 | /// assert_eq!(&m, "with links: http://gnome.org"); 79 | /// ``` 80 | +#[pyfunction] 81 | pub fn markup_from_raw(s: &str) -> String { 82 | lazy_static! { 83 | static ref PARAM: String = format!("({amp}?\\w+(=[\\w._-]+)?)", amp = AMP); 84 | @@ -123,6 +138,7 @@ pub fn markup_from_raw(s: &str) -> String { 85 | } 86 | 87 | // WIP: only allow the html subset that matrix uses. 88 | +#[pyfunction] 89 | pub fn matrix_html_to_markup(s: &str) -> String { 90 | // https://github.com/matrix-org/matrix-react-sdk/blob/4bf5e44b2043bbe95faa66943878acad23dfb823/src/HtmlUtils.js#L178-L184 91 | #[rustfmt::skip] 92 | @@ -164,6 +180,7 @@ pub fn matrix_html_to_markup(s: &str) -> String { 93 | } 94 | 95 | /// Escape the html entities of `s` 96 | +#[pyfunction] 97 | pub fn html_escape(s: &str) -> String { 98 | s.to_string() 99 | .replace('&', "&") 100 | @@ -173,6 +190,7 @@ pub fn html_escape(s: &str) -> String { 101 | } 102 | 103 | /// Converts links to LINK 104 | +#[pyfunction] 105 | pub fn markup_links(s: &str) -> String { 106 | let mut parsed = String::with_capacity(s.len()); 107 | let finder = LinkFinder::new(); 108 | -- 109 | 2.30.2 110 | 111 | -------------------------------------------------------------------------------- /patches/libadwaita/0001-revert-stylesheet-softer-window-shadows.patch: -------------------------------------------------------------------------------- 1 | From 31dc61d91d3602af18ca8c48a532479a26a3b73c Mon Sep 17 00:00:00 2001 2 | From: Raidro Manchester 3 | Date: Sat, 28 Aug 2021 10:35:52 +0300 4 | Subject: [PATCH] Revert "stylesheet: Softer window shadows" 5 | 6 | It makes it barely usable on X11 as the default sizes are completely 7 | wrong. 8 | This reverts commit 1ca63211d69b7d44401efccd62409c81a27f86eb. 9 | --- 10 | src/stylesheet/widgets/_window.scss | 12 ++++-------- 11 | 1 file changed, 4 insertions(+), 8 deletions(-) 12 | 13 | diff --git a/src/stylesheet/widgets/_window.scss b/src/stylesheet/widgets/_window.scss 14 | index e447e18..01ec0a8 100644 15 | --- a/src/stylesheet/widgets/_window.scss 16 | +++ b/src/stylesheet/widgets/_window.scss 17 | @@ -8,10 +8,8 @@ window { 18 | $_wm_border_backdrop: if($variant=='light', transparentize(black, 0.82), transparentize(black, 0.25)); 19 | 20 | &.csd { 21 | - box-shadow: 0 1px 3px 3px transparent, 22 | - 0 2px 8px 2px transparentize(black, 0.87), 23 | - 0 3px 20px 10px transparentize(black, 0.91), 24 | - 0 6px 32px 16px transparentize(black, 0.96), 25 | + box-shadow: 0 3px 9px 1px transparentize(black, 0.5), 26 | + 0 2px 6px 2px transparent, 27 | 0 0 0 1px $_wm_border; //doing borders with box-shadow 28 | margin: 0px; 29 | border-radius: $window_radius; 30 | @@ -27,10 +25,8 @@ window { 31 | // change when we go to backdrop, to prevent jumping windows. 32 | // The biggest shadow should be in the same order then in the active state 33 | // or the jumping will happen during the transition. 34 | - box-shadow: 0 1px 3px 3px transparentize(black, 0.91), 35 | - 0 2px 14px 5px transparentize(black, 0.95), 36 | - 0 4px 28px 12px transparentize(black, 0.97), 37 | - 0 6px 32px 16px transparent, 38 | + box-shadow: 0 3px 9px 1px transparent, 39 | + 0 2px 6px 2px transparentize(black, 0.8), 40 | 0 0 0 1px $_wm_border_backdrop; 41 | transition: $backdrop_transition; 42 | } 43 | -- 44 | 2.31.1 45 | 46 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | lt 2 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | data/org.gnome.gitlab.ranchester.Mirdorph.desktop.in 2 | data/ui/about_dialog.ui.in 3 | data/ui/channel_inner_window.ui 4 | data/ui/channel_properties_window.ui 5 | data/ui/channel_sidebar.ui 6 | data/ui/context_error_dialog.ui 7 | data/ui/image_viewer.ui 8 | data/ui/login_window.ui 9 | data/ui/main_window.ui 10 | data/ui/settings_window.ui 11 | data/ui/tos_notice.ui 12 | data/ui/login_window.ui 13 | src/attachment.py 14 | src/discord_web_grabber.py 15 | src/message_entry_bar.py 16 | src/typing_indicator.py 17 | -------------------------------------------------------------------------------- /po/README.md: -------------------------------------------------------------------------------- 1 | # How to create and update a translation 2 | 3 | First, run the script `update_potfiles.sh` like this, where `LANGUAGE` is the language code that you want to add or update (`it`: italian, `fr`: french, `es`: spanish...): 4 | 5 | ```bash 6 | cd po 7 | ./update_potfiles.sh LANGUAGE 8 | ``` 9 | 10 | It will ask for an email, provide yours if you want and it will be used to credit you. It's historically been used to report issues in the translation for a specific language, but nowadays with issue systems and easier bug reporting than ever it's not really necessary. 11 | 12 | Finally edit the `.po` file that was just created with the language code you used. You can use a normal text editor or a simpler tool like **lokalize** or **poedit** (you can probably find both in your distribution's repositories). 13 | 14 | Note that it also seems you need to update LINGUAS (and make sure in abc order). 15 | 16 | Attribution: I copied and adapted this translation code from GIARA by Gabmus, who himself copied it from uberwriter. 17 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(meson.project_name(), 2 | preset: 'glib' 3 | ) 4 | -------------------------------------------------------------------------------- /po/update_potfiles.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | APPNAME="mirdorph" 4 | 5 | if [ -z $1 ]; then 6 | echo "Usage: $0 lang" 7 | exit 8 | fi 9 | lang="$1" 10 | 11 | rm *.pot 12 | 13 | version=$(fgrep -m 1 "version: " ../meson.build | grep -v "meson" | grep -o "'.*'" | sed "s/'//g") 14 | 15 | find ../src -iname "*.py" | xargs xgettext --package-name=$APPNAME --package-version=$version --from-code=UTF-8 --output=$APPNAME-python.pot 16 | find ../data/ui ../src/plugins -iname "*.glade" -or -iname "*.xml" -or -iname "*.ui" -or -iname "*.ui.in" | xargs xgettext --package-name=$APPNAME --package-version=$version --from-code=UTF-8 --output=$APPNAME-glade.pot -L Glade 17 | find ../data/ -iname "*.desktop.in" | xargs xgettext --package-name=$APPNAME --package-version=$version --from-code=UTF-8 --output=$APPNAME-desktop.pot -L Desktop 18 | 19 | msgcat --use-first $APPNAME-python.pot $APPNAME-glade.pot $APPNAME-desktop.pot > $APPNAME.pot 20 | 21 | sed 's/#: //g;s/:[0-9]*//g;s/\.\.\///g' <(fgrep "#: " $APPNAME.pot) | sort | uniq | sed 's/ /\n/g' | uniq > POTFILES.in 22 | 23 | [ -f "${lang}.po" ] && mv "${lang}.po" "${lang}.po.old" 24 | msginit --locale=$lang --input $APPNAME.pot 25 | if [ -f "${lang}.po.old" ]; then 26 | mv "${lang}.po" "${lang}.po.new" 27 | msgmerge -N "${lang}.po.old" "${lang}.po.new" > ${lang}.po 28 | rm "${lang}.po.old" "${lang}.po.new" 29 | fi 30 | sed -i 's/ASCII/UTF-8/' "${lang}.po" 31 | rm *.pot 32 | -------------------------------------------------------------------------------- /tests/load_gtk.py: -------------------------------------------------------------------------------- 1 | # Loading the Gresource for importing modules with composite templates, and stuff like 2 | # Adw.init + version requires 3 | import os 4 | import gi 5 | gi.require_version("Gtk", "4.0") 6 | gi.require_version("Adw", "1") 7 | from gi.repository import Gio 8 | # About dialog is created by meson which we can't use 9 | os.system("sed '/about_dialog/d' ../data/mirdorph.gresource.xml > ../data/unmeson-mirdorph.gresource.xml") 10 | os.system("cd ../data/ && glib-compile-resources unmeson-mirdorph.gresource.xml") 11 | resource = Gio.resource_load("../data/unmeson-mirdorph.gresource") 12 | Gio.Resource._register(resource) 13 | 14 | from gi.repository import Adw 15 | Adw.init() 16 | -------------------------------------------------------------------------------- /tests/test_confman.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pytest 3 | # Workaround from stackoverflow to allow importing the program 4 | sys.path.append("..") 5 | 6 | from mirdorph.confman import ConfManager 7 | 8 | @pytest.fixture() 9 | def confman(tmp_path): 10 | conf_file_path = tmp_path / "conf" 11 | yield ConfManager(conf_file_path) 12 | 13 | class TestConfMan: 14 | def test_create(self, confman): 15 | assert confman is not None 16 | 17 | def test_base_schema_copied(self, confman): 18 | assert confman.BASE_SCHEMA["example"] == confman.get_value("example") 19 | 20 | def test_setting_value(self, confman): 21 | ex_val = True 22 | confman.set_value("test", ex_val) 23 | assert confman.get_value("test") == ex_val 24 | 25 | def test_persistence(self, tmp_path): 26 | # Manually 27 | conf_file_path = tmp_path / "conf" 28 | confman = ConfManager(conf_file_path) 29 | 30 | ex_val = 12 31 | confman.set_value("test", 12) 32 | 33 | del(confman) 34 | new_confman = ConfManager(conf_file_path) 35 | 36 | assert new_confman.get_value("test") == ex_val 37 | 38 | -------------------------------------------------------------------------------- /tests/test_message_parsing.py: -------------------------------------------------------------------------------- 1 | import sys 2 | # Workaround from stackoverflow to allow importing the program 3 | sys.path.append("..") 4 | 5 | # Message parsing has gresource templates, and linkpreview uses handy 6 | import tests.load_gtk 7 | from mirdorph.link_preview import LinkPreviewExport 8 | from mirdorph.message_parsing import _create_pango_markup, calculate_msg_parts, _generate_exports, ComponentType 9 | 10 | def test_create_pango_markup_links(): 11 | test_text = """\ 12 | with a link https://google.com 13 | and other http://example.com""" 14 | expected_text = """\ 15 | with a link https://google.com 16 | and other http://example.com""" 17 | 18 | assert _create_pango_markup(test_text) == expected_text 19 | 20 | def test_create_pango_markup_escaping(): 21 | test_text = """\ 22 | spooky non escaped 23 | other""" 24 | expected_text = """\ 25 | spooky <b>non escaped</b> 26 | <a href="hello">other</a>""" 27 | 28 | assert _create_pango_markup(test_text) == expected_text 29 | 30 | # Why not test all of markdown? Because html2pango has tests in itself. 31 | 32 | def test_calculate_message_parts(): 33 | example_message = """\ 34 | Hello, this is a test message 35 | > That has a quote 36 | > And it continues 37 | > Almost forever 38 | Until it suddenly stops and you don't 39 | know what to do. 40 | However the bot said that: 41 | >>> No this doesn't make any sense.""" 42 | 43 | correct_components = [ 44 | ( 45 | ComponentType.STANDARD, 46 | "Hello, this is a test message" 47 | ), 48 | ( 49 | ComponentType.QUOTE, 50 | "That has a quote\nAnd it continues\nAlmost forever" 51 | ), 52 | ( 53 | ComponentType.STANDARD, 54 | "Until it suddenly stops and you don't\nknow what to do.\nHowever the bot said that:" 55 | ), 56 | ( 57 | ComponentType.QUOTE, 58 | "No this doesn't make any sense." 59 | ) 60 | ] 61 | 62 | components = calculate_msg_parts(example_message) 63 | 64 | assert correct_components == components 65 | 66 | -------------------------------------------------------------------------------- /tests/test_plug_engine.py: -------------------------------------------------------------------------------- 1 | import load_gtk 2 | import sys 3 | import pytest 4 | import gi 5 | # Workaround from stackoverflow to allow importing the program 6 | sys.path.append("..") 7 | 8 | from gi.repository import Gtk, Gio 9 | from mirdorph.plugin import MrdPluginEngine, MrdExtensionSet, MrdPlugin 10 | 11 | 12 | def test_plugin_discovery(): 13 | engine = MrdPluginEngine() 14 | # We currently have atleast an example plugin 15 | plugins = engine.get_available_plugins() 16 | assert plugins 17 | 18 | 19 | def test_plugin_load_unload(): 20 | engine = MrdPluginEngine() 21 | for av_plugin in engine.get_available_plugins(): 22 | # A plugin that is built-in won't be deactivated at first, 23 | # so we should ensure that. 24 | # and a for plugin that is (which we expect to exit), we can 25 | # test this here. 26 | if av_plugin.built_in: 27 | assert av_plugin.active 28 | av_plugin.active = False 29 | else: 30 | plugin = av_plugin 31 | 32 | assert not plugin.active 33 | assert not engine.get_enabled_plugins() 34 | engine.load_plugin(plugin) 35 | assert plugin.active 36 | assert engine.get_enabled_plugins() 37 | engine.unload_plugin(plugin) 38 | assert not plugin.active 39 | assert not engine.get_enabled_plugins() 40 | 41 | 42 | def test_is_plugin_compatible(): 43 | engine = MrdPluginEngine() 44 | plugin = engine.get_available_plugins()[0] 45 | engine.load_plugin(engine.get_available_plugins()[0]) 46 | 47 | extension_set = MrdExtensionSet( 48 | engine, 49 | # Will be correct 50 | plugin.type 51 | ) 52 | assert engine.is_plugin_compatible(plugin, extension_set) 53 | 54 | class FakePlugin(MrdPlugin): 55 | pass 56 | extension_set = MrdExtensionSet( 57 | engine, 58 | # Incompatible 59 | FakePlugin 60 | ) 61 | assert not engine.is_plugin_compatible(plugin, extension_set) 62 | 63 | def test_get_plugin_by_module(): 64 | engine = MrdPluginEngine() 65 | plugin = engine.get_available_plugins()[0] 66 | 67 | plugin_by_mod_name = engine.get_plugin_from_module(plugin.module_name) 68 | 69 | assert plugin is plugin_by_mod_name 70 | 71 | def test_extension_set_discovery(): 72 | engine = MrdPluginEngine() 73 | plugin = engine.get_available_plugins()[0] 74 | engine.load_plugin(plugin) 75 | 76 | extension_set = MrdExtensionSet( 77 | engine, 78 | # Any plugin is ok 79 | plugin.type 80 | ) 81 | assert list(extension_set) 82 | 83 | class FakePlugin(MrdPlugin): 84 | pass 85 | extension_set_wrong = MrdExtensionSet( 86 | engine, 87 | FakePlugin 88 | ) 89 | assert not list(extension_set_wrong) 90 | 91 | 92 | def test_extension_set_signals(mocker): 93 | engine = MrdPluginEngine() 94 | plugin = engine.get_available_plugins()[0] 95 | plugin.active = False 96 | extension_set = MrdExtensionSet( 97 | engine, 98 | plugin.type 99 | ) 100 | 101 | mock = mocker.patch.object(extension_set, "emit") 102 | engine.load_plugin(plugin) 103 | mock.assert_called_with("extension_added", plugin) 104 | engine.unload_plugin(plugin) 105 | mock.assert_called_with("extension_removed", plugin) 106 | 107 | def test_extension_state_change_via_property(mocker): 108 | # Signals are best as a showcase of the affects of enabling, 109 | # simply setting the active property (useful for binding) should 110 | # also do this. 111 | engine = MrdPluginEngine() 112 | plugin = engine.get_available_plugins()[0] 113 | plugin.active = False 114 | extension_set = MrdExtensionSet( 115 | engine, 116 | plugin.type 117 | ) 118 | 119 | mock = mocker.patch.object(extension_set, "emit") 120 | plugin.active = True 121 | mock.assert_called_with("extension_added", plugin) 122 | plugin.active = False 123 | mock.assert_called_with("extension_removed", plugin) 124 | 125 | def test_gresource(mocker): 126 | engine = MrdPluginEngine() 127 | 128 | # We don't need to load the plugin as gresources are created on initial startup. 129 | # At first I thought about doing it on load and undoing on unload, however then implementations 130 | # can't really define anything that uses gresources. 131 | # Helloworld example - "welcome_message.txt" 132 | assert Gio.resources_get_info("/org/gnome/gitlab/ranchester/Mirdorph/plugins/helloworld/welcome_message.txt", 0) 133 | --------------------------------------------------------------------------------