Welcome to mirror list, hosted at ThFree Co, Russian Federation.

dev.gajim.org/gajim/gajim.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorlovetox <philipp@hoerist.com>2022-08-12 16:15:55 +0300
committerPhilipp Hörist <philipp@hoerist.com>2022-08-15 22:31:02 +0300
commita38757800ff6298685d6472c7dcbf592588b5cb1 (patch)
tree39069c31fa51263752543f74d10fb83175fd3e1c
parent783088db4bb01a8a0c3f35ca411491acb8b94486 (diff)
change: Rework ChatCommands
ChatCommands have been rewritten completely to be more maintainable. NOTES: Only following commands are now available - status - invite - ban - affiliate - kick - role
-rw-r--r--gajim/common/app.py2
-rw-r--r--gajim/common/application.py2
-rw-r--r--gajim/common/commands.py295
-rw-r--r--gajim/common/modules/contacts.py28
-rw-r--r--gajim/gtk/chat_action_processor.py18
-rw-r--r--gajim/gtk/chat_stack.py23
-rw-r--r--gajim/gtk/message_actions_box.py24
-rw-r--r--pyrightconfig.json1
8 files changed, 377 insertions, 16 deletions
diff --git a/gajim/common/app.py b/gajim/common/app.py
index 9d0544d34..964fd8539 100644
--- a/gajim/common/app.py
+++ b/gajim/common/app.py
@@ -63,6 +63,7 @@ if typing.TYPE_CHECKING:
from gajim.common.call_manager import CallManager
from gajim.common.preview import PreviewManager
from gajim.common.task_manager import TaskManager
+ from gajim.common.commands import ChatCommands
interface = cast(types.InterfaceT, None)
@@ -77,6 +78,7 @@ bob_cache: dict[str, bytes] = {}
ipython_window = None
app = None # type: GajimApplication
window = None # type: MainWindow
+commands = None # type: ChatCommands
ged = ged_module.GlobalEventsDispatcher() # Global Events Dispatcher
plugin_manager = cast(types.PluginManagerT, None) # Plugins Manager
diff --git a/gajim/common/application.py b/gajim/common/application.py
index 2a2930946..5817d9eb1 100644
--- a/gajim/common/application.py
+++ b/gajim/common/application.py
@@ -37,6 +37,7 @@ from gajim.common import ged
from gajim.common import configpaths
from gajim.common import logging_helpers
from gajim.common import passwords
+from gajim.common.commands import ChatCommands
from gajim.common.dbus import logind
from gajim.common.events import AccountDisonnected
from gajim.common.events import AllowGajimUpdateCheck
@@ -68,6 +69,7 @@ class CoreApplication:
app.settings.init()
app.config = LegacyConfig()
+ app.commands = ChatCommands()
app.storage.cache = CacheStorage()
app.storage.cache.init()
diff --git a/gajim/common/commands.py b/gajim/common/commands.py
new file mode 100644
index 000000000..15b335470
--- /dev/null
+++ b/gajim/common/commands.py
@@ -0,0 +1,295 @@
+# This file is part of Gajim.
+#
+# Gajim is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published
+# by the Free Software Foundation; version 3 only.
+#
+# Gajim is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Any
+from typing import NoReturn
+from typing import Callable
+from typing import Optional
+
+import io
+import argparse
+import shlex
+import operator
+
+from nbxmpp.protocol import JID
+
+from gajim.common import app
+from gajim.common.i18n import _
+from gajim.common.helpers import Observable, is_role_change_allowed
+from gajim.common.helpers import is_affiliation_change_allowed
+from gajim.common.modules.contacts import GroupchatContact
+
+
+def split_argument_string(string: str) -> list[str]:
+ '''
+ Split a string with shlex.split
+ '''
+
+ lex = shlex.shlex(string, posix=True)
+ lex.whitespace_split = True
+ lex.commenters = ''
+ result: list[str] = []
+
+ try:
+ for token in lex:
+ result.append(token)
+ except ValueError:
+ # If end-of-string is reached and there is a invalid state
+ # ValueError is raised. Still add the partial token to the result.
+ result.append(lex.token)
+
+ return result
+
+
+def get_usage_from_command(cmd: argparse.ArgumentParser) -> str:
+ with io.StringIO() as output:
+ cmd.print_usage(file=output)
+ usage = output.getvalue()
+
+ usage = usage.split('[-h] ')[1]
+ return usage.strip()
+
+
+class ArgumentParserError(Exception):
+ pass
+
+
+class CommandError(Exception):
+ pass
+
+
+class CommandNotFoundError(Exception):
+ pass
+
+
+class ArgumentParser(argparse.ArgumentParser):
+ def error(self, message: str) -> NoReturn:
+ raise ArgumentParserError(message)
+
+
+class ChatCommands(Observable):
+ def __init__(self) -> None:
+ Observable.__init__(self)
+ self._parser = ArgumentParser(prog='ChatCommands')
+ self._sub_parser = self._parser.add_subparsers()
+ self._commands: dict[str, tuple[list[str], str]] = {}
+
+ self._create_commands()
+
+ def get_commands(self, used_in: str) -> list[tuple[str, str]]:
+ commands: list[tuple[str, str]] = []
+ for cmd_name, cmd in self._commands.items():
+ if used_in in cmd[0]:
+ commands.append((cmd_name, cmd[1]))
+ commands.sort(key=operator.itemgetter(0))
+ return commands
+
+ def make_parser(self,
+ command_name: str,
+ callback: Callable[..., Any],
+ **kwargs: Any) -> ArgumentParser:
+
+ '''Add and return a subparser and initialize
+ it with the command name.
+ '''
+
+ parser = self._sub_parser.add_parser(command_name, **kwargs)
+ parser.set_defaults(command_name=command_name)
+ self.connect(command_name, callback)
+ return parser
+
+ def add_command(self,
+ command_name: str,
+ used_in: list[str],
+ cmd: argparse.ArgumentParser
+ ) -> None:
+
+ usage = get_usage_from_command(cmd)
+ self._commands[command_name] = (used_in, usage)
+
+ def parse(self, type_: str, arg_string: str) -> None:
+ arg_list = split_argument_string(arg_string[1:])
+
+ command_name = arg_list[0]
+ command = self._commands.get(command_name)
+ if command is None or type_ not in command[0]:
+ raise CommandNotFoundError(_('Unknown command: %s' % command_name))
+
+ args = self._parser.parse_args(arg_list)
+
+ self.notify(args.command_name, args)
+
+ def _create_commands(self) -> None:
+ parser = self.make_parser('status', self._status_command)
+ parser.add_argument('status',
+ choices=['online', 'away', 'xa', 'dnd'])
+ parser.add_argument('message', default=None, nargs='?')
+ self.add_command('status', ['chat', 'groupchat', 'pm'], parser)
+
+ parser = self.make_parser('invite', self._invite_command)
+ parser.add_argument('address')
+ parser.add_argument('reason', default=None, nargs='?')
+ self.add_command('invite', ['groupchat'], parser)
+
+ parser = self.make_parser('ban', self._ban_command)
+ parser.add_argument('who')
+ parser.add_argument('reason', default=None, nargs='?')
+ self.add_command('ban', ['groupchat'], parser)
+
+ parser = self.make_parser('affiliate', self._affiliate_command)
+ parser.add_argument('who')
+ parser.add_argument('affiliation',
+ choices=['owner', 'admin', 'member', 'none'])
+ self.add_command('affiliate', ['groupchat'], parser)
+
+ parser = self.make_parser('kick', self._kick_command)
+ parser.add_argument('who')
+ parser.add_argument('reason', default=None, nargs='?')
+ self.add_command('kick', ['groupchat'], parser)
+
+ parser = self.make_parser('role', self._role_command)
+ parser.add_argument('who')
+ parser.add_argument('role',
+ choices=['moderator', 'participant', 'visitor'])
+ self.add_command('role', ['groupchat'], parser)
+
+ def _status_command(self,
+ chat_commands: Any,
+ signal_name: str,
+ args: Any
+ ) -> None:
+
+ for client in app.get_clients():
+ if not app.settings.get_account_setting(client.account,
+ 'sync_with_global_status'):
+ continue
+
+ if not client.state.is_available:
+ continue
+
+ client.change_status(args.status,
+ args.message or client.status_message)
+
+ def _check_if_joined(self) -> GroupchatContact:
+ contact = app.window.get_control().contact
+ assert isinstance(contact, GroupchatContact)
+ if not contact.is_joined:
+ raise CommandError(_('You are currently not '
+ 'joined this group chat'))
+ return contact
+
+ def _invite_command(self,
+ chat_commands: Any,
+ signal_name: str,
+ args: Any
+ ) -> None:
+
+ contact = self._check_if_joined()
+
+ try:
+ jid = JID.from_string(args.address)
+ except Exception:
+ raise CommandError(_('Invalid address: %s' % args.address))
+
+ if jid.is_full or jid.localpart is None:
+ raise CommandError(_('Invalid address: %s' % args.address))
+
+ client = app.get_client(contact.account)
+ client.get_module('MUC').invite(contact.jid, args.address, args.reason)
+
+ def _change_affiliation(self,
+ nick_or_address: str,
+ affiliation: str,
+ reason: Optional[str]) -> None:
+
+ contact = self._check_if_joined()
+
+ nick_list = contact.get_user_nicknames()
+ if nick_or_address in nick_list:
+ participant = contact.get_resource(nick_or_address)
+ self_contact = contact.get_self()
+ assert self_contact is not None
+ if not is_affiliation_change_allowed(self_contact,
+ participant,
+ 'outcast'):
+ raise CommandError(_('You have insufficient permissions'))
+
+ jid = participant.real_jid
+
+ else:
+ try:
+ jid = JID.from_string(nick_or_address)
+ except Exception:
+ raise CommandError(_('Invalid address: %s' % nick_or_address))
+
+ if jid.is_full or jid.localpart is None:
+ raise CommandError(_('Invalid address: %s' % nick_or_address))
+
+ client = app.get_client(contact.account)
+ client.get_module('MUC').set_affiliation(
+ contact.jid,
+ {jid: {'affiliation': affiliation,
+ 'reason': reason}})
+
+ def _ban_command(self,
+ chat_commands: Any,
+ signal_name: str,
+ args: Any
+ ) -> None:
+
+ self._change_affiliation(args.who, 'outcast', args.reason)
+
+ def _affiliate_command(self,
+ chat_commands: Any,
+ signal_name: str,
+ args: Any
+ ) -> None:
+
+ self._change_affiliation(args.who, args.affiliation, None)
+
+ def _change_role(self, nick: str, role: str, reason: Optional[str]) -> None:
+
+ contact = self._check_if_joined()
+
+ nick_list = contact.get_user_nicknames()
+ if nick not in nick_list:
+ raise CommandError(_('User %s not found' % nick))
+
+ participant = contact.get_resource(nick)
+ self_contact = contact.get_self()
+ assert self_contact is not None
+ if not is_role_change_allowed(self_contact, participant):
+ raise CommandError(_('You have insufficient permissions'))
+
+ client = app.get_client(contact.account)
+ client.get_module('MUC').set_role(contact.jid,
+ nick,
+ role,
+ reason)
+
+ def _kick_command(self,
+ chat_commands: Any,
+ signal_name: str,
+ args: Any
+ ) -> None:
+
+ self._change_role(args.who, 'none', args.reason)
+
+ def _role_command(self,
+ chat_commands: Any,
+ signal_name: str,
+ args: Any
+ ) -> None:
+
+ self._change_role(args.who, args.role, None)
diff --git a/gajim/common/modules/contacts.py b/gajim/common/modules/contacts.py
index 2f20cd2d5..6eb4e1bc6 100644
--- a/gajim/common/modules/contacts.py
+++ b/gajim/common/modules/contacts.py
@@ -309,6 +309,10 @@ class CommonContact(Observable):
return disco_info.supports(requested_feature)
@property
+ def is_chat(self) -> bool:
+ return False
+
+ @property
def is_groupchat(self) -> bool:
return False
@@ -317,6 +321,10 @@ class CommonContact(Observable):
return False
@property
+ def type_string(self) -> str:
+ raise NotImplementedError
+
+ @property
def is_jingle_available(self) -> bool:
return False
@@ -558,6 +566,14 @@ class BareContact(CommonContact):
return False
return self.is_available
+ @property
+ def is_chat(self) -> bool:
+ return True
+
+ @property
+ def type_string(self) -> str:
+ return 'chat'
+
class ResourceContact(CommonContact):
def __init__(self, logger: LogAdapter, jid: JID, account: str) -> None:
@@ -632,6 +648,10 @@ class ResourceContact(CommonContact):
if notify:
self.notify('presence-update')
+ @property
+ def type_string(self) -> str:
+ raise NotImplementedError
+
class GroupchatContact(CommonContact):
def __init__(self, logger: LogAdapter, jid: JID, account: str) -> None:
@@ -791,6 +811,10 @@ class GroupchatContact(CommonContact):
room = self.settings.get('notify_on_all_messages')
return all_ or room
+ @property
+ def type_string(self) -> str:
+ return 'groupchat'
+
class GroupchatParticipant(CommonContact):
def __init__(self, logger: LogAdapter, jid: JID, account: str) -> None:
@@ -936,6 +960,10 @@ class GroupchatParticipant(CommonContact):
app.app.avatar_storage.invalidate_cache(self._jid)
self.notify('user-avatar-update')
+ @property
+ def type_string(self) -> str:
+ return 'pm'
+
def can_add_to_roster(contact: Union[BareContact,
GroupchatContact,
diff --git a/gajim/gtk/chat_action_processor.py b/gajim/gtk/chat_action_processor.py
index 749f9e3cc..9ad1c78d5 100644
--- a/gajim/gtk/chat_action_processor.py
+++ b/gajim/gtk/chat_action_processor.py
@@ -121,17 +121,9 @@ class ChatActionProcessor(Gtk.Popover):
self._buf.delete(start_iter, self._current_iter)
self._buf.insert(start_iter, selected_action)
- def _get_commands(self) -> list[str]:
- # TODO
- # commands: list[str] = []
- # assert self._account
- # assert self._contact
- # control = app.window.get_control()
- # for command in control.list_commands():
- # for name in command.names:
- # commands.append(name)
- # return commands
- return []
+ def _get_commands(self) -> list[tuple[str, str]]:
+ assert self._contact is not None
+ return app.commands.get_commands(self._contact.type_string)
def _on_changed(self, _textview: MessageInputTextView) -> None:
insert = self._buf.get_insert()
@@ -181,7 +173,7 @@ class ChatActionProcessor(Gtk.Popover):
self._menu.remove_all()
command_list = self._get_commands()
num_entries = 0
- for command in command_list:
+ for command, usage in command_list:
if not command.startswith(action_text[1:]):
continue
if num_entries >= MAX_ENTRIES:
@@ -189,7 +181,7 @@ class ChatActionProcessor(Gtk.Popover):
action_data = GLib.Variant('s', f'/{command}')
menu_item = Gio.MenuItem()
- menu_item.set_label(f'/{command}')
+ menu_item.set_label(f'/{command} {usage}')
menu_item.set_attribute_value('action-data', action_data)
self._menu.append_item(menu_item)
num_entries += 1
diff --git a/gajim/gtk/chat_stack.py b/gajim/gtk/chat_stack.py
index 37cb2e7f1..37e67401a 100644
--- a/gajim/gtk/chat_stack.py
+++ b/gajim/gtk/chat_stack.py
@@ -76,6 +76,10 @@ class ChatStack(Gtk.Stack, EventHelper):
self._chat_banner = ChatBanner()
self._chat_control = ChatControl()
self._message_action_box = MessageActionsBox()
+ self._message_action_box.connect('command-error',
+ self._on_command_error)
+ self._message_action_box.connect('command-not-found',
+ self._on_command_not_found)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box.add(self._chat_banner)
@@ -659,6 +663,9 @@ class ChatStack(Gtk.Stack, EventHelper):
def _on_send_message(self) -> None:
self._message_action_box.msg_textview.replace_emojis()
message = self._message_action_box.msg_textview.get_text()
+ if message.startswith('//'):
+ # Escape sequence for chat commands
+ message = message[1:]
contact = self._current_contact
assert contact is not None
@@ -683,9 +690,6 @@ class ChatStack(Gtk.Stack, EventHelper):
if message in ('', None, '\n'):
return
- # if process_commands and self.process_as_command(message):
- # return
-
label = self._message_action_box.get_seclabel()
correct_id = None
@@ -734,6 +738,19 @@ class ChatStack(Gtk.Stack, EventHelper):
self._message_action_box.clear()
self._chat_control.clear()
+ def _on_command_error(self,
+ message_actions_box: MessageActionsBox,
+ error: str) -> None:
+
+ self._chat_control.add_info_message(error)
+
+ def _on_command_not_found(self,
+ message_actions_box: MessageActionsBox,
+ error: str
+ ) -> None:
+
+ self._chat_control.add_info_message(error)
+
class ChatPlaceholderBox(Gtk.Box):
def __init__(self):
diff --git a/gajim/gtk/message_actions_box.py b/gajim/gtk/message_actions_box.py
index a0e3686b3..01068aa6d 100644
--- a/gajim/gtk/message_actions_box.py
+++ b/gajim/gtk/message_actions_box.py
@@ -38,6 +38,9 @@ from gajim.common import app
from gajim.common import events
from gajim.common import i18n
from gajim.common import ged
+from gajim.common.commands import ArgumentParserError
+from gajim.common.commands import CommandError
+from gajim.common.commands import CommandNotFoundError
from gajim.common.i18n import _
from gajim.common.client import Client
from gajim.common.const import SimpleClientState
@@ -67,6 +70,12 @@ log = logging.getLogger('gajim.gui.messageactionsbox')
class MessageActionsBox(Gtk.Grid, ged.EventHelper):
+
+ __gsignals__ = {
+ 'command-error': (GObject.SignalFlags.RUN_FIRST, None, (str,)),
+ 'command-not-found': (GObject.SignalFlags.RUN_FIRST, None, (str,))
+ }
+
def __init__(self) -> None:
Gtk.Grid.__init__(self)
ged.EventHelper.__init__(self)
@@ -595,6 +604,21 @@ class MessageActionsBox(Gtk.Grid, ged.EventHelper):
return True
assert self._contact is not None
+
+ message = self.msg_textview.get_text()
+ if (message.startswith('/') and
+ not message.startswith('//') and
+ not message.startswith('/me ')):
+ try:
+ app.commands.parse(self._contact.type_string, message)
+ except (ArgumentParserError, CommandError) as error:
+ self.emit('command-error', error)
+ except CommandNotFoundError as error:
+ self.emit('command-not-found', error)
+ else:
+ self.msg_textview.clear()
+ return True
+
if not app.account_is_available(self._contact.account):
# we are not connected
ErrorDialog(
diff --git a/pyrightconfig.json b/pyrightconfig.json
index 4cac3c1cc..3b2fb60fb 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -22,6 +22,7 @@
"gajim/common/application.py",
"gajim/common/call_manager.py",
"gajim/common/cert_store.py",
+ "gajim/common/commands.py",
"gajim/common/configpaths.py",
"gajim/common/const.py",
"gajim/common/events.py",