diff options
Diffstat (limited to 'plugins')
40 files changed, 1780 insertions, 398 deletions
diff --git a/plugins/admin.py b/plugins/admin.py index 7bbc01d6..c2901844 100644 --- a/plugins/admin.py +++ b/plugins/admin.py @@ -122,10 +122,14 @@ class Plugin(BasePlugin): completion=self.complete_nick) def role(self, role): - return lambda args: self.api.current_tab().command_role(args + ' ' + role) + async def inner(args): + await self.api.current_tab().command_role(args + ' ' + role) + return inner def affiliation(self, affiliation): - return lambda args: self.api.current_tab().command_affiliation(args + ' ' + affiliation) + async def inner(args): + await self.api.current_tab().command_affiliation(args + ' ' + affiliation) + return inner def complete_nick(self, the_input): tab = self.api.current_tab() diff --git a/plugins/amsg.py b/plugins/amsg.py index 4cd6c055..3b81085a 100644 --- a/plugins/amsg.py +++ b/plugins/amsg.py @@ -29,7 +29,7 @@ class Plugin(BasePlugin): short='Broadcast a message', help='Broadcast the message to all the joined rooms.') - def command_amsg(self, args): + async def command_amsg(self, args): for room in self.core.tabs: if isinstance(room, MucTab) and room.joined: - room.command_say(args) + await room.command_say(args) diff --git a/plugins/b64.py b/plugins/b64.py new file mode 100644 index 00000000..82300a0f --- /dev/null +++ b/plugins/b64.py @@ -0,0 +1,70 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright © 2019 Maxime “pep” Buquet <pep@bouah.net> +# +# Distributed under terms of the GPL-3.0+ license. + +""" +Usage +----- + +Base64 encryption plugin. + +This plugin also respects security guidelines listed in XEP-0419. + +.. glossary:: + /b64 + **Usage:** ``/b64`` + + This command enables encryption of outgoing messages for the current + tab. +""" + +from base64 import b64decode, b64encode +from typing import List, Optional +from slixmpp import Message, JID + +from poezio.plugin_e2ee import E2EEPlugin +from poezio.tabs import ( + ChatTab, + MucTab, + PrivateTab, + DynamicConversationTab, + StaticConversationTab, +) + + +class Plugin(E2EEPlugin): + """Base64 Plugin""" + + encryption_name = 'base64' + encryption_short_name = 'b64' + eme_ns = 'urn:xmpps:base64:0' + + # This encryption mechanism is using <body/> as a container + replace_body_with_eme = False + + # In what tab is it ok to use this plugin. Here we want all of them + supported_tab_types = ( + MucTab, + PrivateTab, + DynamicConversationTab, + StaticConversationTab, + ) + + async def decrypt(self, message: Message, jid: Optional[JID], _tab: Optional[ChatTab]) -> None: + """ + Decrypt base64 + """ + body = message['body'] + message['body'] = b64decode(body.encode()).decode() + + async def encrypt(self, message: Message, _jid: Optional[List[JID]], _tab: ChatTab) -> None: + """ + Encrypt to base64 + """ + # TODO: Stop using <body/> for this. Put the encoded payload in another element. + body = message['body'] + message['body'] = b64encode(body.encode()).decode() diff --git a/plugins/bob.py b/plugins/bob.py index be56ef4a..98c62901 100644 --- a/plugins/bob.py +++ b/plugins/bob.py @@ -37,7 +37,7 @@ class Plugin(BasePlugin): default_config = {'bob': {'max_size': 2048, 'max_age': 86400}} def init(self): - for tab in tabs.ConversationTab, tabs.PrivateTab, tabs.MucTab: + for tab in tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab, tabs.MucTab: self.api.add_tab_command( tab, 'bob', @@ -47,7 +47,7 @@ class Plugin(BasePlugin): short='Send a short image', completion=self.completion_bob) - def command_bob(self, filename): + async def command_bob(self, filename): path = Path(expanduser(filename)) try: size = path.stat().st_size @@ -67,7 +67,7 @@ class Plugin(BasePlugin): with open(path.as_posix(), 'rb') as file: data = file.read() max_age = self.config.get('max_age') - cid = self.core.xmpp.plugin['xep_0231'].set_bob( + cid = await self.core.xmpp.plugin['xep_0231'].set_bob( data, mime_type, max_age=max_age) self.api.run_command( '/xhtml <img src="cid:%s" alt="%s"/>' % (cid, path.name)) diff --git a/plugins/code.py b/plugins/code.py index 1c6dfab0..8d9c57a3 100644 --- a/plugins/code.py +++ b/plugins/code.py @@ -41,7 +41,11 @@ class Plugin(BasePlugin): help='Sends syntax-highlighted code in the current tab') def command_code(self, args): - language, code = args.split(None, 1) + split = args.split(None, 1) + if len(split) != 2: + self.api.information('Usage: /code <language> <code>', 'Error') + return None + language, code = split lexer = get_lexer_by_name(language) tab = self.api.current_tab() code = highlight(code, lexer, FORMATTER) diff --git a/plugins/contact.py b/plugins/contact.py index ebe4dcc4..13dcc42f 100644 --- a/plugins/contact.py +++ b/plugins/contact.py @@ -13,6 +13,7 @@ Usage """ from poezio.plugin import BasePlugin +from slixmpp.exceptions import IqError, IqTimeout from slixmpp.jid import InvalidJID CONTACT_TYPES = ['abuse', 'admin', 'feedback', 'sales', 'security', 'support'] @@ -25,38 +26,35 @@ class Plugin(BasePlugin): help='Get the Contact Addresses of a JID') def on_disco(self, iq): - if iq['type'] == 'error': - error_condition = iq['error']['condition'] - error_text = iq['error']['text'] - message = 'Error getting Contact Addresses from %s: %s: %s' % (iq['from'], error_condition, error_text) - self.api.information(message, 'Error') - return info = iq['disco_info'] - title = 'Contact Info' contacts = [] - for field in info['form']: - var = field['var'] - if field['type'] == 'hidden' and var == 'FORM_TYPE': - form_type = field['value'][0] - if form_type != 'http://jabber.org/network/serverinfo': - self.api.information('Not a server: “%s”: %s' % (iq['from'], form_type), 'Error') - return - continue - if not var.endswith('-addresses'): - continue - var = var[:-10] # strip '-addresses' - sep = '\n ' + len(var) * ' ' - field_value = field.get_value(convert=False) - value = sep.join(field_value) if isinstance(field_value, list) else field_value - contacts.append('%s: %s' % (var, value)) + # iterate all data forms, in case there are multiple + for form in iq['disco_info']: + values = form.get_values() + if values['FORM_TYPE'][0] == 'http://jabber.org/network/serverinfo': + for var in values: + if not var.endswith('-addresses'): + continue + title = var[:-10] # strip '-addresses' + sep = '\n ' + len(title) * ' ' + field_value = values[var] + if field_value: + value = sep.join(field_value) if isinstance(field_value, list) else field_value + contacts.append(f'{title}: {value}') if contacts: - self.api.information('\n'.join(contacts), title) + self.api.information('\n'.join(contacts), 'Contact Info') else: - self.api.information('No Contact Addresses for %s' % iq['from'], 'Error') + self.api.information(f'No Contact Addresses for {iq["from"]}', 'Error') - def command_disco(self, jid): + async def command_disco(self, jid): try: - self.core.xmpp.plugin['xep_0030'].get_info(jid=jid, cached=False, - callback=self.on_disco) - except InvalidJID as e: - self.api.information('Invalid JID “%s”: %s' % (jid, e), 'Error') + iq = await self.core.xmpp.plugin['xep_0030'].get_info(jid=jid, cached=False) + self.on_disco(iq) + except InvalidJID as exn: + self.api.information(f'Invalid JID “{jid}”: {exn}', 'Error') + except (IqError, IqTimeout,) as exn: + ifrom = exn.iq['from'] + condition = exn.iq['error']['condition'] + text = exn.iq['error']['text'] + message = f'Error getting Contact Addresses from {ifrom}: {condition}: {text}' + self.api.information(message, 'Error') diff --git a/plugins/day_change.py b/plugins/day_change.py index 051b447b..5d3ab37c 100644 --- a/plugins/day_change.py +++ b/plugins/day_change.py @@ -4,11 +4,12 @@ date has changed. """ +import datetime from gettext import gettext as _ + +from poezio import timed_events, tabs from poezio.plugin import BasePlugin -import datetime -from poezio import tabs -from poezio import timed_events +from poezio.ui.types import InfoMessage class Plugin(BasePlugin): @@ -30,7 +31,7 @@ class Plugin(BasePlugin): for tab in self.core.tabs: if isinstance(tab, tabs.ChatTab): - tab.add_message(msg) + tab.add_message(InfoMessage(msg)) self.core.refresh_window() self.schedule_event() diff --git a/plugins/dice.py b/plugins/dice.py index 55fd87a7..3b540cbd 100644 --- a/plugins/dice.py +++ b/plugins/dice.py @@ -29,6 +29,7 @@ Configuration """ import random +from typing import Optional from poezio import tabs from poezio.decorators import command_args_parser @@ -40,17 +41,16 @@ DICE = '\u2680\u2681\u2682\u2683\u2684\u2685' class DiceRoll: __slots__ = [ 'duration', 'total_duration', 'dice_number', 'msgtype', 'jid', - 'last_msgid', 'increments' + 'msgid', 'increments' ] - def __init__(self, total_duration, dice_number, is_muc, jid, msgid, - increments): + def __init__(self, total_duration, dice_number, msgtype, jid, msgid, increments): self.duration = 0 self.total_duration = total_duration self.dice_number = dice_number - self.msgtype = "groupchat" if is_muc else "chat" + self.msgtype = msgtype self.jid = jid - self.last_msgid = msgid + self.msgid = msgid self.increments = increments def reroll(self): @@ -60,11 +60,14 @@ class DiceRoll: return self.duration >= self.total_duration +def roll_dice(num_dice: int) -> str: + return ''.join(random.choice(DICE) for _ in range(num_dice)) + class Plugin(BasePlugin): - default_config = {"dice": {"refresh": 0.5, "default_duration": 5}} + default_config = {"dice": {"refresh": 0.75, "default_duration": 7.5}} def init(self): - for tab_t in [tabs.MucTab, tabs.ConversationTab, tabs.PrivateTab]: + for tab_t in [tabs.MucTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab]: self.api.add_tab_command( tab_t, 'roll', @@ -90,13 +93,17 @@ class Plugin(BasePlugin): self.core.command.help("roll") return - firstroll = ''.join(random.choice(DICE) for _ in range(num_dice)) - tab.command_say(firstroll) - is_muctab = isinstance(tab, tabs.MucTab) - msg_id = tab.last_sent_message["id"] + msgtype = 'groupchat' if isinstance(tab, tabs.MucTab) else 'chat' + + message = self.core.xmpp.make_message(tab.jid) + message['type'] = msgtype + message['body'] = roll_dice(num_dice) + message.send() + increment = self.config.get('refresh') - roll = DiceRoll(duration, num_dice, is_muctab, tab.jid, msg_id, - increment) + msgid = message['id'] + + roll = DiceRoll(duration, num_dice, msgtype, tab.jid, msgid, increment) event = self.api.create_delayed_event(increment, self.delayed_event, roll) self.api.add_timed_event(event) @@ -107,11 +114,9 @@ class Plugin(BasePlugin): roll.reroll() message = self.core.xmpp.make_message(roll.jid) message["type"] = roll.msgtype - message["body"] = ''.join( - random.choice(DICE) for _ in range(roll.dice_number)) - message["replace"]["id"] = roll.last_msgid + message["body"] = roll_dice(roll.dice_number) + message["replace"]["id"] = roll.msgid message.send() - roll.last_msgid = message['id'] event = self.api.create_delayed_event(roll.increments, self.delayed_event, roll) self.api.add_timed_event(event) diff --git a/plugins/disco.py b/plugins/disco.py index ec0a04cd..d15235f6 100644 --- a/plugins/disco.py +++ b/plugins/disco.py @@ -16,7 +16,9 @@ Usage """ from poezio.plugin import BasePlugin +from poezio.decorators import command_args_parser from slixmpp.jid import InvalidJID +from slixmpp.exceptions import IqError, IqTimeout class Plugin(BasePlugin): @@ -24,11 +26,11 @@ class Plugin(BasePlugin): self.api.add_command( 'disco', self.command_disco, - usage='<JID>', + usage='<JID> [node] [info|items]', short='Get the disco#info of a JID', help='Get the disco#info of a JID') - def on_disco(self, iq): + def on_info(self, iq): if iq['type'] == 'error': self.api.information(iq['error']['text'] or iq['error']['condition'], 'Error') return @@ -53,9 +55,52 @@ class Plugin(BasePlugin): if server_info: self.api.information('\n'.join(server_info), title) - def command_disco(self, jid): + def on_items(self, iq): + if iq['type'] == 'error': + self.api.information(iq['error']['text'] or iq['error']['condition'], 'Error') + return + + def describe(item): + text = item[0] + node = item[1] + name = item[2] + if node is not None: + text += ', node=' + node + if name is not None: + text += ', name=' + name + return text + + items = iq['disco_items'] + self.api.information('\n'.join(describe(item) for item in items['items']), 'Items') + + @command_args_parser.quoted(1, 3) + async def command_disco(self, args): + if args is None: + self.core.command.help('disco') + return + if len(args) == 1: + jid, = args + node = None + type_ = 'info' + elif len(args) == 2: + jid, node = args + type_ = 'info' + else: + jid, node, type_ = args try: - self.core.xmpp.plugin['xep_0030'].get_info( - jid=jid, cached=False, callback=self.on_disco) + if type_ == 'info': + iq = await self.core.xmpp.plugin['xep_0030'].get_info( + jid=jid, node=node, cached=False + ) + self.on_info(iq) + elif type_ == 'items': + iq = await self.core.xmpp.plugin['xep_0030'].get_items( + jid=jid, node=node + ) + self.on_items(iq) except InvalidJID as e: self.api.information('Invalid JID “%s”: %s' % (jid, e), 'Error') + except IqError as e: + self.api.information('Received iq error while querying “%s”: %s' % (jid, e), 'Error') + except IqTimeout: + self.api.information('Received no reply querying “%s”…' % jid, 'Error') diff --git a/plugins/display_corrections.py b/plugins/display_corrections.py index 22eb196d..cf8107ce 100644 --- a/plugins/display_corrections.py +++ b/plugins/display_corrections.py @@ -25,11 +25,13 @@ Usage from poezio.plugin import BasePlugin from poezio.common import shell_split from poezio import tabs +from poezio.ui.types import Message +from poezio.theming import get_theme class Plugin(BasePlugin): def init(self): - for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.ConversationTab): + for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab): self.api.add_tab_command( tab_type, 'display_corrections', @@ -43,7 +45,9 @@ class Plugin(BasePlugin): messages = self.api.get_conversation_messages() if not messages: return None - for message in messages[::-1]: + for message in reversed(messages): + if not isinstance(message, Message): + continue if message.old_message: if nb == 1: return message @@ -52,6 +56,7 @@ class Plugin(BasePlugin): return None def command_display_corrections(self, args): + theme = get_theme() args = shell_split(args) if len(args) == 1: try: @@ -64,8 +69,9 @@ class Plugin(BasePlugin): if message: display = [] while message: + str_time = message.time.strftime(theme.SHORT_TIME_FORMAT) display.append('%s %s%s%s %s' % - (message.str_time, '* ' + (str_time, '* ' if message.me else '', message.nickname, '' if message.me else '>', message.txt)) message = message.old_message diff --git a/plugins/embed.py b/plugins/embed.py index 0cdc41d2..4a68f035 100644 --- a/plugins/embed.py +++ b/plugins/embed.py @@ -16,6 +16,7 @@ Usage from poezio import tabs from poezio.plugin import BasePlugin from poezio.theming import get_theme +from poezio.ui.types import Message class Plugin(BasePlugin): @@ -28,21 +29,22 @@ class Plugin(BasePlugin): help='Embed an image url into the contact\'s client', usage='<image_url>') - def embed_image_url(self, args): - tab = self.api.current_tab() - message = self.core.xmpp.make_message(tab.name) - message['body'] = args - message['oob']['url'] = args - if isinstance(tab, tabs.MucTab): - message['type'] = 'groupchat' - else: + def embed_image_url(self, url, tab=None): + tab = tab or self.api.current_tab() + message = self.core.xmpp.make_message(tab.jid) + message['body'] = url + message['oob']['url'] = url + message['type'] = 'groupchat' + if not isinstance(tab, tabs.MucTab): message['type'] = 'chat' tab.add_message( - message['body'], - nickname=tab.core.own_nick, - nick_color=get_theme().COLOR_OWN_NICK, - identifier=message['id'], - jid=tab.core.xmpp.boundjid, - typ=1, + Message( + message['body'], + nickname=tab.core.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=message['id'], + jid=tab.core.xmpp.boundjid, + ), ) message.send() + self.core.refresh_window() diff --git a/plugins/emoji_ascii.py b/plugins/emoji_ascii.py new file mode 100644 index 00000000..4beec3b1 --- /dev/null +++ b/plugins/emoji_ascii.py @@ -0,0 +1,60 @@ +# poezio emoji_ascii plugin +# +# Will translate received Emoji to :emoji: for better display on text terminals, +# and outgoing :emoji: into Emoji on the wire. +# +# Requires emojis.json.gz (MIT licensed) from: +# +# git clone https://github.com/vdurmont/emoji-java +# gzip -9 < ./src/main/resources/emojis.json > poezio/plugins/emojis.json.gz + +# TODOs: +# 1. it messes up your log files (doesn't log original message, logs mutilated :emoji: instead) +# 2. Doesn't work on outgoing direct messages +# 3. Doesn't detect pastes, corrupts jabber:x:foobar +# 4. no auto-completion of emoji aliases +# 5. coloring of converted Emojis to be able to differentiate them from incoming ASCII + +import gzip +import json +import os +import re + +from poezio.plugin import BasePlugin +from typing import Dict + + +class Plugin(BasePlugin): + emoji_to_ascii: Dict[str, str] = {} + ascii_to_emoji: Dict[str, str] = {} + emoji_pattern = None + alias_pattern = None + + def init(self): + emoji_map_file_name = os.path.abspath(os.path.dirname(__file__) + '/emojis.json.gz') + emoji_map_data = gzip.open(emoji_map_file_name, 'r').read().decode('utf-8') + emoji_map = json.loads(emoji_map_data) + for e in emoji_map: + self.emoji_to_ascii[e['emoji']] = ':%s:' % e['aliases'][0] + for alias in e['aliases']: + # work around :iq: and similar country code misdetection + flag = re.match('^[a-z][a-z]$', alias) and "flag" in e["tags"] + if not flag: + self.ascii_to_emoji[':%s:' % alias] = e['emoji'] + self.emoji_pattern = re.compile('|'.join(self.emoji_to_ascii.keys()).replace('*', '\*')) + self.alias_pattern = re.compile('|'.join(self.ascii_to_emoji.keys()).replace('+', '\+')) + + self.api.add_event_handler('muc_msg', self.emoji2alias) + self.api.add_event_handler('conversation_msg', self.emoji2alias) + self.api.add_event_handler('private_msg', self.emoji2alias) + + self.api.add_event_handler('muc_say', self.alias2emoji) + self.api.add_event_handler('private_say', self.alias2emoji) + self.api.add_event_handler('conversation_say', self.alias2emoji) + + + def emoji2alias(self, msg, tab): + msg['body'] = self.emoji_pattern.sub(lambda m: self.emoji_to_ascii[m.group()], msg['body']) + + def alias2emoji(self, msg, tab): + msg['body'] = self.alias_pattern.sub(lambda m: self.ascii_to_emoji[m.group()], msg['body']) diff --git a/plugins/exec.py b/plugins/exec.py index 0786c86f..68f24486 100644 --- a/plugins/exec.py +++ b/plugins/exec.py @@ -95,4 +95,4 @@ class Plugin(BasePlugin): else: self.api.run_command('/help exec') return - asyncio.ensure_future(self.async_exec(command, arg)) + asyncio.create_task(self.async_exec(command, arg)) diff --git a/plugins/irc.py b/plugins/irc.py index 9d981c91..f3aa7b63 100644 --- a/plugins/irc.py +++ b/plugins/irc.py @@ -20,9 +20,9 @@ Global configuration :sorted: gateway - **Default:** ``irc.poez.io`` + **Default:** ``irc.jabberfr.org`` - The JID of the IRC gateway to use. If empty, irc.poez.io will be + The JID of the IRC gateway to use. If empty, irc.jabberfr.org will be used. Please try to run your own, though, it’s painless to setup. initial_connect @@ -46,17 +46,6 @@ section name, and the following options: .. glossary:: :sorted: - - login_command - **Default:** ``[empty]`` - - The command used to identify with the services (e.g. ``IDENTIFY mypassword``). - - login_nick - **Default:** ``[empty]`` - - The nickname to whom the auth command will be sent. - nickname **Default:** ``[empty]`` @@ -77,14 +66,6 @@ Commands .. glossary:: :sorted: - /irc_login - **Usage:** ``/irc_login [server1] [server2]…`` - - Authenticate with the specified servers if they are correctly - configured. If no servers are provided, the plugin will try - them all. (You need to set :term:`login_nick` and - :term:`login_command` as well) - /irc_join **Usage:** ``/irc_join <room or server>`` @@ -109,9 +90,9 @@ Example configuration .. code-block:: ini [irc] - gateway = irc.poez.io + gateway = irc.jabberfr.org - [irc.freenode.net] + [irc.libera.chat] nickname = mynick login_nick = nickserv login_command = identify mypassword @@ -129,30 +110,30 @@ Example configuration """ +import asyncio + +from typing import Optional, Tuple, List, Any +from slixmpp.jid import JID, InvalidJID + from poezio.plugin import BasePlugin from poezio.decorators import command_args_parser from poezio.core.structs import Completion -from poezio import common from poezio import tabs class Plugin(BasePlugin): - def init(self): - if self.config.get('initial_connect', True): - self.initial_connect() - - self.api.add_command( - 'irc_login', - self.command_irc_login, - usage='[server] [server]…', - help=('Connect to the specified servers if they ' - 'exist in the configuration and the login ' - 'options are set. If not is given, the ' - 'plugin will try all the sections in the ' - 'configuration.'), - short='Login to irc servers with nickserv', - completion=self.completion_irc_login) - + default_config = { + 'irc': { + "initial_connect": True, + "gateway": "irc.jabberfr.org", + } + } + + def init(self) -> None: + if self.config.getbool('initial_connect'): + asyncio.create_task( + self.initial_connect() + ) self.api.add_command( 'irc_join', self.command_irc_join, @@ -179,22 +160,38 @@ class Plugin(BasePlugin): 'example.com "hi there"`'), short='Open a private conversation with an IRC user') - def join(self, gateway, server): + async def join(self, gateway: str, server: JID) -> None: "Join irc rooms on a server" - nick = self.config.get_by_tabname( + nick: str = self.config.get_by_tabname( 'nickname', server, default='') or self.core.own_nick - rooms = self.config.get_by_tabname( + rooms: List[str] = self.config.get_by_tabname( 'rooms', server, default='').split(':') + joins = [] for room in rooms: room = '{}%{}@{}/{}'.format(room, server, gateway, nick) - self.core.command.join(room) + joins.append(self.core.command.join(room)) - def initial_connect(self): - gateway = self.config.get('gateway', 'irc.poez.io') - sections = self.config.sections() + await asyncio.gather(*joins) - for section in (s for s in sections if s != 'irc'): + async def initial_connect(self) -> None: + gateway: str = self.config.getstr('gateway') + sections: List[str] = self.config.sections() + sections_jid = [] + for sect in sections: + if sect == 'irc': + continue + try: + sect_jid = JID(sect) + if sect_jid != sect_jid.server: + self.api.information(f'Invalid server: {sect}', 'Warning') + continue + except InvalidJID: + self.api.information(f'Invalid server: {sect}', 'Warning') + continue + sections_jid.append(sect_jid) + + for section in sections_jid: room_suffix = '%{}@{}'.format(section, gateway) already_opened = False @@ -203,125 +200,40 @@ class Plugin(BasePlugin): already_opened = True break - login_command = self.config.get_by_tabname( - 'login_command', section, default='') - login_nick = self.config.get_by_tabname( - 'login_nick', section, default='') - nick = self.config.get_by_tabname( - 'nickname', section, default='') or self.core.own_nick - if login_command and login_nick: - - def login(gw, sect, log_nick, log_cmd, room_suff): - dest = '{}%{}'.format(log_nick, room_suff) - self.core.xmpp.send_message( - mto=dest, mbody=log_cmd, mtype='chat') - delayed = self.api.create_delayed_event( - 5, self.join, gw, sect) - self.api.add_timed_event(delayed) - - if not already_opened: - self.core.command.join(room_suffix + '/' + nick) - delayed = self.api.create_delayed_event( - 5, login, gateway, section, login_nick, login_command, - room_suffix[1:]) - self.api.add_timed_event(delayed) - else: - login(gateway, section, login_nick, login_command, - room_suffix[1:]) - elif not already_opened: - self.join(gateway, section) - - @command_args_parser.quoted(0, -1) - def command_irc_login(self, args): - """ - /irc_login [server] [server]… - """ - gateway = self.config.get('gateway', 'irc.poez.io') - if args: - not_present = [] - sections = self.config.sections() - for section in args: - if section not in sections: - not_present.append(section) - continue - login_command = self.config.get_by_tabname( - 'login_command', section, default='') - login_nick = self.config.get_by_tabname( - 'login_nick', section, default='') - if not login_command and not login_nick: - not_present.append(section) - continue - - room_suffix = '%{}@{}'.format(section, gateway) - dest = '{}%{}'.format(login_nick, room_suffix[1:]) - self.core.xmpp.send_message( - mto=dest, mbody=login_command, mtype='chat') - if len(not_present) == 1: - self.api.information( - 'Section %s does not exist or is not configured' % - not_present[0], 'Warning') - elif len(not_present) > 1: - self.api.information( - 'Sections %s do not exist or are not configured' % - ', '.join(not_present), 'Warning') - else: - sections = self.config.sections() - - for section in (s for s in sections if s != 'irc'): - login_command = self.config.get_by_tabname( - 'login_command', section, default='') - login_nick = self.config.get_by_tabname( - 'login_nick', section, default='') - if not login_nick and not login_command: - continue - - room_suffix = '%{}@{}'.format(section, gateway) - dest = '{}%{}'.format(login_nick, room_suffix[1:]) - self.core.xmpp.send_message( - mto=dest, mbody=login_command, mtype='chat') - - def completion_irc_login(self, the_input): - """ - completion for /irc_login - """ - args = the_input.text.split() - if '' in args: - args.remove('') - pos = the_input.get_argument_position() - sections = self.config.sections() - if 'irc' in sections: - sections.remove('irc') - for section in args: - try: - sections.remove(section) - except: - pass - return Completion(the_input.new_completion, sections, pos) + if not already_opened: + await self.join(gateway, section) @command_args_parser.quoted(1, 1) - def command_irc_join(self, args): + async def command_irc_join(self, args: Optional[List[str]]) -> None: """ /irc_join <room or server> """ if not args: - return self.core.command.help('irc_join') - sections = self.config.sections() + self.core.command.help('irc_join') + return + sections: List[str] = self.config.sections() if 'irc' in sections: sections.remove('irc') - if args[0] in sections and self.config.get_by_tabname( - 'rooms', args[0]): - self.join_server_rooms(args[0]) + if args[0] in sections: + try: + section_jid = JID(args[0]) + except InvalidJID: + self.api.information(f'Invalid address: {args[0]}', 'Error') + return + #self.config.get_by_tabname('rooms', section_jid) + await self.join_server_rooms(section_jid) else: - self.join_room(args[0]) + await self.join_room(args[0]) @command_args_parser.quoted(1, 1) - def command_irc_query(self, args): + def command_irc_query(self, args: Optional[List[str]]) -> None: """ Open a private conversation with the given nickname, on the current IRC server. """ if args is None: - return self.core.command.help('irc_query') + self.core.command.help('irc_query') + return current_tab_info = self.get_current_tab_irc_info() if not current_tab_info: return @@ -336,14 +248,14 @@ class Plugin(BasePlugin): else: self.core.command.message('{}'.format(jid)) - def join_server_rooms(self, section): + async def join_server_rooms(self, section: JID) -> None: """ Join all the rooms configured for a section (section = irc server) """ - gateway = self.config.get('gateway', 'irc.poez.io') - rooms = self.config.get_by_tabname('rooms', section).split(':') - nick = self.config.get_by_tabname('nickname', section) + gateway: str = self.config.getstr('gateway') + rooms: List[str] = self.config.get_by_tabname('rooms', section).split(':') + nick: str = self.config.get_by_tabname('nickname', section) if nick: nick = '/' + nick else: @@ -351,9 +263,9 @@ class Plugin(BasePlugin): suffix = '%{}@{}{}'.format(section, gateway, nick) for room in rooms: - self.core.command.join(room + suffix) + await self.core.command.join(room + suffix) - def join_room(self, name): + async def join_room(self, name: str) -> None: """ Join a room with only its name and the current tab """ @@ -361,20 +273,24 @@ class Plugin(BasePlugin): if not current_tab_info: return server, gateway = current_tab_info + try: + server_jid = JID(server) + except InvalidJID: + return room = '{}%{}@{}'.format(name, server, gateway) - if self.config.get_by_tabname('nickname', server): - room += '/' + self.config.get_by_tabname('nickname', server) + if self.config.get_by_tabname('nickname', server_jid.bare): + room += '/' + self.config.get_by_tabname('nickname', server_jid.bare) - self.core.command.join(room) + await self.core.command.join(room) - def get_current_tab_irc_info(self): + def get_current_tab_irc_info(self) -> Optional[Tuple[str, str]]: """ Return a tuple with the irc server and the gateway hostnames of the current tab. If the current tab is not an IRC channel or private conversation, a warning is displayed and None is returned """ - gateway = self.config.get('gateway', 'irc.poez.io') + gateway: str = self.config.getstr('gateway') current = self.api.current_tab() current_jid = current.jid if not current_jid.server == gateway: @@ -397,11 +313,11 @@ class Plugin(BasePlugin): return None return server, gateway - def completion_irc_join(self, the_input): + def completion_irc_join(self, the_input: Any) -> Completion: """ completion for /irc_join """ - sections = self.config.sections() + sections: List[str] = self.config.sections() if 'irc' in sections: sections.remove('irc') return Completion(the_input.new_completion, sections, 1) diff --git a/plugins/lastlog.py b/plugins/lastlog.py new file mode 100644 index 00000000..1c48fa06 --- /dev/null +++ b/plugins/lastlog.py @@ -0,0 +1,61 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright © 2018 Maxime “pep” Buquet +# Copyright © 2019 Madhur Garg +# +# Distributed under terms of the GPL-3.0+ license. See the COPYING file. + +""" + Search provided string in the buffer and return all results on the screen +""" + +import re +from typing import Optional +from datetime import datetime + +from poezio.plugin import BasePlugin +from poezio import tabs +from poezio.text_buffer import TextBuffer +from poezio.ui.types import Message as PMessage, InfoMessage + + +def add_line( + text_buffer: TextBuffer, + text: str, + datetime: Optional[datetime] = None, + ) -> None: + """Adds a textual entry in the TextBuffer""" + text_buffer.add_message(InfoMessage(text, time=datetime)) + + +class Plugin(BasePlugin): + """Lastlog Plugin""" + + def init(self): + for tab in tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab, tabs.MucTab: + self.api.add_tab_command( + tab, + 'lastlog', + self.command_lastlog, + usage='<keyword>', + help='Search <keyword> in the buffer and returns results' + 'on the screen') + + def command_lastlog(self, input_): + """Define lastlog command""" + + text_buffer = self.api.current_tab()._text_buffer + search_re = re.compile(input_, re.I) + + res = [] + add_line(text_buffer, "Lastlog:") + for message in text_buffer.messages: + if isinstance(message, PMessage) and \ + search_re.search(message.txt) is not None: + res.append(message) + add_line(text_buffer, "%s> %s" % (message.nickname, message.txt), message.time) + add_line(text_buffer, "End of Lastlog") + self.api.current_tab().text_win.pos = 0 + self.api.current_tab().core.refresh_window() diff --git a/plugins/link.py b/plugins/link.py index 352d403d..699215ea 100644 --- a/plugins/link.py +++ b/plugins/link.py @@ -76,7 +76,7 @@ Options Set the default browser started by the plugin .. _Unix FIFO: https://en.wikipedia.org/wiki/Named_pipe -.. _daemon.py: http://dev.louiz.org/projects/poezio/repository/revisions/master/raw/poezio/daemon.py +.. _daemon.py: https://lab.louiz.org/poezio/poezio/raw/main/poezio/daemon.py """ import platform @@ -87,8 +87,17 @@ from poezio.xhtml import clean_text from poezio import common from poezio import tabs -url_pattern = re.compile(r'\b(?:http[s]?://(?:\S+))|(?:magnet:\?(?:\S+))\b', - re.I | re.U) +url_pattern = re.compile( + r'\b' + '(?:http[s]?://(?:\S+))|' + '(?:magnet:\?(?:\S+))|' + '(?:aesgcm://(?:\S+))|' + '(?:gopher://(?:\S+))|' + '(?:gemini://(?:\S+))' + '\b', + re.I | re.U +) + app_mapping = { 'Linux': 'xdg-open', 'Darwin': 'open', @@ -97,7 +106,7 @@ app_mapping = { class Plugin(BasePlugin): def init(self): - for _class in (tabs.MucTab, tabs.PrivateTab, tabs.ConversationTab): + for _class in (tabs.MucTab, tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab): self.api.add_tab_command( _class, 'link', diff --git a/plugins/marquee.py b/plugins/marquee.py index 60566b0d..66ec8b70 100644 --- a/plugins/marquee.py +++ b/plugins/marquee.py @@ -34,6 +34,7 @@ Configuration """ +import asyncio from poezio.plugin import BasePlugin from poezio import tabs from poezio import xhtml @@ -41,7 +42,7 @@ from poezio.decorators import command_args_parser def move(text, step, spacing): - new_text = text + (" " * spacing) + new_text = text + ("\u00A0" * spacing) return new_text[-(step % len(new_text)):] + new_text[:-( step % len(new_text))] @@ -56,16 +57,18 @@ class Plugin(BasePlugin): } def init(self): - for tab_t in [tabs.MucTab, tabs.ConversationTab, tabs.PrivateTab]: + for tab_t in [tabs.MucTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab]: self.add_tab_command( tab_t, 'marquee', self.command_marquee, 'Replicate the <marquee/> behavior in a message') @command_args_parser.raw - def command_marquee(self, args): + async def command_marquee(self, args): + if not args: + return None tab = self.api.current_tab() args = xhtml.clean_text(xhtml.convert_simple_to_full_colors(args)) - tab.command_say(args) + await tab.command_say(args) is_muctab = isinstance(tab, tabs.MucTab) msg_id = tab.last_sent_message["id"] jid = tab.jid @@ -85,6 +88,6 @@ class Plugin(BasePlugin): message.send() event = self.api.create_delayed_event( self.config.get("refresh"), self.delayed_event, jid, body, - message["id"], step + 1, duration + self.config.get("refresh"), + msg_id, step + 1, duration + self.config.get("refresh"), is_muctab) self.api.add_timed_event(event) diff --git a/plugins/mirror.py b/plugins/mirror.py index 116d16b1..55c429a3 100644 --- a/plugins/mirror.py +++ b/plugins/mirror.py @@ -16,7 +16,7 @@ from poezio import tabs class Plugin(BasePlugin): def init(self): - for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.ConversationTab): + for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab): self.api.add_tab_command( tab_type, 'mirror', diff --git a/plugins/mpd_client.py b/plugins/mpd_client.py index a8893999..f1eea902 100644 --- a/plugins/mpd_client.py +++ b/plugins/mpd_client.py @@ -57,7 +57,7 @@ import mpd class Plugin(BasePlugin): def init(self): - for _class in (tabs.ConversationTab, tabs.MucTab, tabs.PrivateTab): + for _class in (tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.MucTab, tabs.PrivateTab): self.api.add_tab_command( _class, 'mpd', diff --git a/plugins/otr.py b/plugins/otr.py index 2ddc332b..6c15f3d2 100644 --- a/plugins/otr.py +++ b/plugins/otr.py @@ -184,7 +184,6 @@ and :term:`log` configuration parameters are tab-specific. from gettext import gettext as _ import logging -log = logging.getLogger(__name__) import os import html import curses @@ -194,10 +193,11 @@ import potr from potr.context import NotEncryptedError, UnencryptedMessage, ErrorReceived, NotOTRMessage,\ STATE_ENCRYPTED, STATE_PLAINTEXT, STATE_FINISHED, Context, Account, crypt +from slixmpp import JID, InvalidJID + from poezio import common from poezio import xdg from poezio import xhtml -from poezio.common import safeJID from poezio.config import config from poezio.plugin import BasePlugin from poezio.roster import roster @@ -205,6 +205,9 @@ from poezio.tabs import StaticConversationTab, PrivateTab from poezio.theming import get_theme, dump_tuple from poezio.decorators import command_args_parser from poezio.core.structs import Completion +from poezio.ui.types import InfoMessage, Message + +log = logging.getLogger(__name__) POLICY_FLAGS = { 'ALLOW_V1': False, @@ -344,7 +347,7 @@ class PoezioContext(Context): self.xmpp = xmpp self.core = core self.flags = {} - self.trustName = safeJID(peer).bare + self.trustName = JID(peer).bare self.in_smp = False self.smp_own = False self.log = 0 @@ -374,7 +377,7 @@ class PoezioContext(Context): 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT), 'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT), 'jid': self.peer, - 'bare_jid': safeJID(self.peer).bare + 'bare_jid': JID(self.peer).bare } tab = self.core.tabs.by_name(self.peer) @@ -385,25 +388,28 @@ class PoezioContext(Context): log.debug('OTR conversation with %s refreshed', self.peer) if self.getCurrentTrust(): msg = OTR_REFRESH_TRUSTED % format_dict - tab.add_message(msg, typ=self.log) + tab.add_message(InfoMessage(msg)) else: msg = OTR_REFRESH_UNTRUSTED % format_dict - tab.add_message(msg, typ=self.log) + tab.add_message(InfoMessage(msg)) hl(tab) elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT: log.debug('OTR conversation with %s finished', self.peer) if tab: - tab.add_message(OTR_END % format_dict, typ=self.log) + tab.add_message(InfoMessage(OTR_END % format_dict)) hl(tab) elif newstate == STATE_ENCRYPTED and tab: if self.getCurrentTrust(): - tab.add_message(OTR_START_TRUSTED % format_dict, typ=self.log) + tab.add_message(InfoMessage(OTR_START_TRUSTED % format_dict)) else: format_dict['our_fpr'] = self.user.getPrivkey() format_dict['remote_fpr'] = self.getCurrentKey() - tab.add_message(OTR_TUTORIAL % format_dict, typ=0) tab.add_message( - OTR_START_UNTRUSTED % format_dict, typ=self.log) + InfoMessage(OTR_TUTORIAL % format_dict), + ) + tab.add_message( + InfoMessage(OTR_START_UNTRUSTED % format_dict), + ) hl(tab) log.debug('Set encryption state of %s to %s', self.peer, @@ -455,8 +461,9 @@ class PoezioAccount(Account): if acc != self.name or proto != 'xmpp': continue - jid = safeJID(ctx).bare - if not jid: + try: + jid = JID(ctx).bare + except InvalidJID: continue self.setTrust(jid, fpr, trust) except: @@ -589,7 +596,7 @@ class Plugin(BasePlugin): """ Retrieve or create an OTR context """ - jid = safeJID(jid) + jid = JID(jid) if jid.full not in self.contexts: flags = POLICY_FLAGS.copy() require = self.config.get_by_tabname( @@ -607,6 +614,8 @@ class Plugin(BasePlugin): """ Message received """ + if msg['from'].bare == self.core.xmpp.boundjid.bare: + return format_dict = { 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID), 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT), @@ -639,7 +648,7 @@ class Plugin(BasePlugin): # Received an OTR error proto_error = err.args[0].error # pylint: disable=no-member format_dict['err'] = proto_error.decode('utf-8', errors='replace') - tab.add_message(OTR_ERROR % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_ERROR % format_dict)) del msg['body'] del msg['html'] hl(tab) @@ -649,7 +658,7 @@ class Plugin(BasePlugin): # Encrypted message received, but unreadable as we do not have # an OTR session in place. text = MESSAGE_UNREADABLE % format_dict - tab.add_message(text, jid=msg['from'], typ=0) + tab.add_message(InfoMessage(text)) hl(tab) del msg['body'] del msg['html'] @@ -658,7 +667,7 @@ class Plugin(BasePlugin): except crypt.InvalidParameterError: # Malformed OTR payload and stuff text = MESSAGE_INVALID % format_dict - tab.add_message(text, jid=msg['from'], typ=0) + tab.add_message(InfoMessage(text)) hl(tab) del msg['body'] del msg['html'] @@ -669,7 +678,7 @@ class Plugin(BasePlugin): import traceback exc = traceback.format_exc() format_dict['exc'] = exc - tab.add_message(POTR_ERROR % format_dict, typ=0) + tab.add_message(InfoMessage(POTR_ERROR % format_dict)) log.error('Unspecified error in the OTR plugin', exc_info=True) return # No error, proceed with the message @@ -688,10 +697,10 @@ class Plugin(BasePlugin): abort = get_tlv(tlvs, potr.proto.SMPABORTTLV) if abort: ctx.reset_smp() - tab.add_message(SMP_ABORTED_PEER % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_ABORTED_PEER % format_dict)) elif ctx.in_smp and not ctx.smpIsValid(): ctx.reset_smp() - tab.add_message(SMP_ABORTED % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_ABORTED % format_dict)) elif smp1 or smp1q: # Received an SMP request (with a question or not) if smp1q: @@ -709,22 +718,22 @@ class Plugin(BasePlugin): # we did not initiate it ctx.smp_own = False format_dict['q'] = question - tab.add_message(SMP_REQUESTED % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_REQUESTED % format_dict)) elif smp2: # SMP reply received if not ctx.in_smp: ctx.reset_smp() else: - tab.add_message(SMP_PROGRESS % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_PROGRESS % format_dict)) elif smp3 or smp4: # Type 4 (SMP message 3) or 5 (SMP message 4) TLVs received # in both cases it is the final message of the SMP exchange if ctx.smpIsSuccess(): - tab.add_message(SMP_SUCCESS % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_SUCCESS % format_dict)) if not ctx.getCurrentTrust(): - tab.add_message(SMP_RECIPROCATE % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_RECIPROCATE % format_dict)) else: - tab.add_message(SMP_FAIL % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_FAIL % format_dict)) ctx.reset_smp() hl(tab) self.core.refresh_window() @@ -736,7 +745,13 @@ class Plugin(BasePlugin): """ format_dict['msg'] = err.args[0].decode('utf-8') text = MESSAGE_UNENCRYPTED % format_dict - tab.add_message(text, jid=msg['from'], typ=ctx.log) + tab.add_message( + Message( + text, + nickname=tab.nick, + jid=msg['from'], + ), + ) del msg['body'] del msg['html'] hl(tab) @@ -780,12 +795,14 @@ class Plugin(BasePlugin): if decode_newlines: body = body.replace('<br/>', '\n').replace('<br>', '\n') tab.add_message( - body, - nickname=tab.nick, - jid=msg['from'], - forced_user=user, - typ=ctx.log, - nick_color=nick_color) + Message( + body, + nickname=tab.nick, + jid=msg['from'], + user=user, + nick_color=nick_color + ), + ) hl(tab) self.core.refresh_window() del msg['body'] @@ -795,9 +812,11 @@ class Plugin(BasePlugin): Find an OTR session from a bare JID. """ for ctx in self.contexts: - if safeJID( - ctx - ).bare == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED: + try: + jid = JID(ctx).bare + except InvalidJID: + continue + if jid == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED: return self.contexts[ctx] return None @@ -826,19 +845,21 @@ class Plugin(BasePlugin): tab.send_chat_state('inactive', always_send=True) tab.add_message( - msg['body'], - nickname=self.core.own_nick or tab.own_nick, - nick_color=get_theme().COLOR_OWN_NICK, - identifier=msg['id'], - jid=self.core.xmpp.boundjid, - typ=ctx.log) + Message( + msg['body'], + nickname=self.core.own_nick or tab.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=msg['id'], + jid=self.core.xmpp.boundjid, + ), + ) # remove everything from the message so that it doesn’t get sent del msg['body'] del msg['replace'] del msg['html'] elif is_relevant(tab) and ctx and ctx.getPolicy('REQUIRE_ENCRYPTION'): warning_msg = MESSAGE_NOT_SENT % format_dict - tab.add_message(warning_msg, typ=0) + tab.add_message(InfoMessage(warning_msg)) del msg['body'] del msg['replace'] del msg['html'] @@ -856,7 +877,7 @@ class Plugin(BasePlugin): ('\n - /message %s' % jid) for jid in res) format_dict['help'] = help_msg warning_msg = INCOMPATIBLE_TAB % format_dict - tab.add_message(warning_msg, typ=0) + tab.add_message(InfoMessage(warning_msg)) del msg['body'] del msg['replace'] del msg['html'] @@ -866,7 +887,11 @@ class Plugin(BasePlugin): Returns the text to display in the infobar (the OTR status) """ context = self.get_context(jid) - if safeJID(jid).bare == jid and context.state != STATE_ENCRYPTED: + try: + bare_jid = JID(jid).bare + except InvalidJID: + bare_jid = '' + if bare_jid == jid and context.state != STATE_ENCRYPTED: ctx = self.find_encrypted_context_with_matching(jid) if ctx: context = ctx @@ -900,22 +925,22 @@ class Plugin(BasePlugin): self.otr_start(tab, name, format_dict) elif action == 'ourfpr': format_dict['fpr'] = self.account.getPrivkey() - tab.add_message(OTR_OWN_FPR % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_OWN_FPR % format_dict)) elif action == 'fpr': if name in self.contexts: ctx = self.contexts[name] if ctx.getCurrentKey() is not None: format_dict['fpr'] = ctx.getCurrentKey() - tab.add_message(OTR_REMOTE_FPR % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_REMOTE_FPR % format_dict)) else: - tab.add_message(OTR_NO_FPR % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_NO_FPR % format_dict)) elif action == 'drop': # drop the privkey (and obviously, end the current conversations before that) for context in self.contexts.values(): if context.state not in (STATE_FINISHED, STATE_PLAINTEXT): context.disconnect() self.account.drop_privkey() - tab.add_message(KEY_DROPPED % format_dict, typ=0) + tab.add_message(InfoMessage(KEY_DROPPED % format_dict)) elif action == 'trust': ctx = self.get_context(name) key = ctx.getCurrentKey() @@ -927,7 +952,7 @@ class Plugin(BasePlugin): format_dict['key'] = key ctx.setTrust(fpr, 'verified') self.account.saveTrusts() - tab.add_message(TRUST_ADDED % format_dict, typ=0) + tab.add_message(InfoMessage(TRUST_ADDED % format_dict)) elif action == 'untrust': ctx = self.get_context(name) key = ctx.getCurrentKey() @@ -939,7 +964,7 @@ class Plugin(BasePlugin): format_dict['key'] = key ctx.setTrust(fpr, '') self.account.saveTrusts() - tab.add_message(TRUST_REMOVED % format_dict, typ=0) + tab.add_message(InfoMessage(TRUST_REMOVED % format_dict)) self.core.refresh_window() def otr_start(self, tab, name, format_dict): @@ -954,7 +979,7 @@ class Plugin(BasePlugin): if otr.state != STATE_ENCRYPTED: format_dict['secs'] = secs text = OTR_NOT_ENABLED % format_dict - tab.add_message(text, typ=0) + tab.add_message(InfoMessage(text)) self.core.refresh_window() if secs > 0: @@ -962,7 +987,7 @@ class Plugin(BasePlugin): self.api.add_timed_event(event) body = self.get_context(name).sendMessage(0, b'?OTRv?').decode() self.core.xmpp.send_message(mto=name, mtype='chat', mbody=body) - tab.add_message(OTR_REQUEST % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_REQUEST % format_dict)) @staticmethod def completion_otr(the_input): @@ -1012,13 +1037,13 @@ class Plugin(BasePlugin): ctx.smpInit(secret, question) else: ctx.smpInit(secret) - tab.add_message(SMP_INITIATED % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_INITIATED % format_dict)) elif action == 'answer': ctx.smpGotSecret(secret) elif action == 'abort': if ctx.in_smp: ctx.smpAbort() - tab.add_message(SMP_ABORTED % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_ABORTED % format_dict)) self.core.refresh_window() @staticmethod diff --git a/plugins/ping.py b/plugins/ping.py index b0c115b2..cc987bf0 100644 --- a/plugins/ping.py +++ b/plugins/ping.py @@ -21,12 +21,13 @@ Command In a private or a direct conversation, you can do ``/ping`` to ping the current interlocutor. """ +import asyncio from slixmpp import InvalidJID, JID +from slixmpp.exceptions import IqTimeout from poezio.decorators import command_args_parser from poezio.plugin import BasePlugin from poezio.roster import roster -from poezio.common import safeJID from poezio.contact import Contact, Resource from poezio.core.structs import Completion from poezio import tabs @@ -58,7 +59,7 @@ class Plugin(BasePlugin): help='Send an XMPP ping to jid (see XEP-0199).', short='Send a ping.', completion=self.completion_ping) - for _class in (tabs.PrivateTab, tabs.ConversationTab): + for _class in (tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab): self.api.add_tab_command( _class, 'ping', @@ -70,13 +71,19 @@ class Plugin(BasePlugin): completion=self.completion_ping) @command_args_parser.raw - def command_ping(self, arg): + async def command_ping(self, arg): if not arg: return self.core.command.help('ping') - jid = safeJID(arg) + try: + jid = JID(arg) + except InvalidJID: + return self.api.information('Invalid JID: %s' % arg, 'Error') start = time.time() - def callback(iq): + try: + iq = await self.core.xmpp.plugin['xep_0199'].send_ping( + jid=jid, timeout=10 + ) delay = time.time() - start error = False reply = '' @@ -99,13 +106,11 @@ class Plugin(BasePlugin): message = '%s responded to ping after %ss%s' % ( jid, round(delay, 4), reply) self.api.information(message, 'Info') - - def timeout(iq): + except IqTimeout: self.api.information( - '%s did not respond to ping after 10s: timeout' % jid, 'Info') - - self.core.xmpp.plugin['xep_0199'].send_ping( - jid=jid, callback=callback, timeout=10, timeout_callback=timeout) + '%s did not respond to ping after 10s: timeout' % jid, + 'Info' + ) def completion_muc_ping(self, the_input): users = [user.nick for user in self.api.current_tab().users] @@ -115,9 +120,12 @@ class Plugin(BasePlugin): @command_args_parser.raw def command_private_ping(self, arg): - if arg: - return self.command_ping(arg) - self.command_ping(self.api.current_tab().jid) + jid = arg + if not arg: + jid = self.api.current_tab().jid + asyncio.create_task( + self.command_ping(jid) + ) @command_args_parser.raw def command_muc_ping(self, arg): @@ -132,20 +140,25 @@ class Plugin(BasePlugin): jid = JID(arg) except InvalidJID: return self.api.information('Invalid JID: %s' % arg, 'Error') - self.command_ping(jid.full) + asyncio.create_task( + self.command_ping(jid.full) + ) @command_args_parser.raw def command_roster_ping(self, arg): if arg: - self.command_ping(arg) + jid = arg else: current = self.api.current_tab().selected_row if isinstance(current, Resource): - self.command_ping(current.jid) + jid = current.jid elif isinstance(current, Contact): res = current.get_highest_priority_resource() if res is not None: - self.command_ping(res.jid) + jid =res.jid + asyncio.create_task( + self.command_ping(jid) + ) def resources(self): l = [] diff --git a/plugins/qr.py b/plugins/qr.py new file mode 100755 index 00000000..735c3002 --- /dev/null +++ b/plugins/qr.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 + +import io +import logging +import qrcode + +from typing import Dict, Callable + +from slixmpp import JID, InvalidJID + +from poezio import windows +from poezio.tabs import Tab +from poezio.core.structs import Command +from poezio.decorators import command_args_parser +from poezio.plugin import BasePlugin +from poezio.theming import get_theme, to_curses_attr +from poezio.windows.base_wins import Win + +log = logging.getLogger(__name__) + +class QrWindow(Win): + __slots__ = ('qr', 'invert', 'inverted') + + str_invert = " Invert " + str_close = " Close " + + def __init__(self, qr: str) -> None: + self.qr = qr + self.invert = True + self.inverted = True + + def refresh(self) -> None: + self._win.erase() + # draw QR code + code = qrcode.QRCode() + code.add_data(self.qr) + out = io.StringIO() + code.print_ascii(out, invert=self.inverted) + self.addstr(" " + self.qr + "\n") + self.addstr(out.getvalue(), to_curses_attr((15, 0))) + self.addstr(" ") + + col = to_curses_attr(get_theme().COLOR_TAB_NORMAL) + + if self.invert: + self.addstr(self.str_invert, col) + else: + self.addstr(self.str_invert) + + self.addstr(" ") + + if self.invert: + self.addstr(self.str_close) + else: + self.addstr(self.str_close, col) + + self._refresh() + + def toggle_choice(self) -> None: + self.invert = not self.invert + + def engage(self) -> bool: + if self.invert: + self.inverted = not self.inverted + return False + else: + return True + +class QrTab(Tab): + plugin_commands = {} # type: Dict[str, Command] + plugin_keys = {} # type: Dict[str, Callable] + + def __init__(self, core, qr): + Tab.__init__(self, core) + self.state = 'highlight' + self.text = qr + self._name = qr + self.topic_win = windows.Topic() + self.topic_win.set_message(qr) + self.qr_win = QrWindow(qr) + self.help_win = windows.HelpText( + "Choose with arrow keys and press enter") + self.key_func['^I'] = self.toggle_choice + self.key_func[' '] = self.toggle_choice + self.key_func['KEY_LEFT'] = self.toggle_choice + self.key_func['KEY_RIGHT'] = self.toggle_choice + self.key_func['^M'] = self.engage + self.resize() + self.update_commands() + self.update_keys() + + def resize(self): + self.need_resize = False + self.topic_win.resize(1, self.width, 0, 0) + self.qr_win.resize(self.height-3, self.width, 1, 0) + self.help_win.resize(1, self.width, self.height-1, 0) + + def refresh(self): + if self.need_resize: + self.resize() + log.debug(' TAB Refresh: %s', self.__class__.__name__) + self.refresh_tab_win() + self.info_win.refresh() + self.topic_win.refresh() + self.qr_win.refresh() + self.help_win.refresh() + + def on_input(self, key, raw): + if not raw and key in self.key_func: + return self.key_func[key]() + + def toggle_choice(self): + log.debug(' TAB toggle_choice: %s', self.__class__.__name__) + self.qr_win.toggle_choice() + self.refresh() + self.core.doupdate() + + def engage(self): + log.debug(' TAB engage: %s', self.__class__.__name__) + if self.qr_win.engage(): + self.core.close_tab(self) + else: + self.refresh() + self.core.doupdate() + +class Plugin(BasePlugin): + def init(self): + self.api.add_command( + 'qr', + self.command_qr, + usage='<message>', + short='Display a QR code', + help='Display a QR code of <message> in a new tab') + self.api.add_command( + 'invitation', + self.command_invite, + usage='[<server>]', + short='Invite a user', + help='Generate a XEP-0401 invitation on your server or on <server> and display a QR code') + + def command_qr(self, msg): + t = QrTab(self.core, msg) + self.core.add_tab(t, True) + self.core.doupdate() + + def on_next(self, iq, adhoc_session): + status = iq['command']['status'] + xform = iq.xml.find( + '{http://jabber.org/protocol/commands}command/{jabber:x:data}x') + if xform is not None: + form = self.core.xmpp.plugin['xep_0004'].build_form(xform) + else: + form = None + uri = None + if status == 'completed' and form: + for field in form: + log.debug(' field: %s -> %s', field['var'], field['value']) + if field['var'] == 'landing-url' and field['value']: + uri = field.get_value(convert=False) + if field['var'] == 'uri' and field['value'] and uri is None: + uri = field.get_value(convert=False) + if uri: + t = QrTab(self.core, uri) + self.core.add_tab(t, True) + self.core.doupdate() + else: + self.core.handler.next_adhoc_step(iq, adhoc_session) + + + @command_args_parser.quoted(0, 1, defaults=[]) + def command_invite(self, args): + server = self.core.xmpp.boundjid.domain + if len(args) > 0: + try: + server = JID(args[0]) + except InvalidJID: + self.api.information(f'Invalid JID: {args[0]}', 'Error') + return + session = { + 'next' : self.on_next, + 'error': self.core.handler.adhoc_error + } + self.core.xmpp.plugin['xep_0050'].start_command(server, 'urn:xmpp:invite#invite', session) + diff --git a/plugins/quote.py b/plugins/quote.py index b412cd9a..d7bc1e2a 100644 --- a/plugins/quote.py +++ b/plugins/quote.py @@ -45,8 +45,10 @@ Options """ from poezio.core.structs import Completion +from poezio.ui.types import Message from poezio.plugin import BasePlugin from poezio.xhtml import clean_text +from poezio.theming import get_theme from poezio import common from poezio import tabs @@ -56,7 +58,7 @@ log = logging.getLogger(__name__) class Plugin(BasePlugin): def init(self): - for _class in (tabs.MucTab, tabs.ConversationTab, tabs.PrivateTab): + for _class in (tabs.MucTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab): self.api.add_tab_command( _class, 'quote', @@ -74,13 +76,14 @@ class Plugin(BasePlugin): return self.api.run_command('/help quote') message = self.find_message(message) if message: + str_time = message.time.strftime(get_theme().SHORT_TIME_FORMAT) before = self.config.get('before_quote', '') % { 'nick': message.nickname or '', - 'time': message.str_time + 'time': str_time, } after = self.config.get('after_quote', '') % { 'nick': message.nickname or '', - 'time': message.str_time + 'time': str_time, } self.core.insert_input_text( '%(before)s%(quote)s%(after)s' % { @@ -96,7 +99,7 @@ class Plugin(BasePlugin): if not messages: return None for message in messages[::-1]: - if clean_text(message.txt) == txt: + if isinstance(message, Message) and clean_text(message.txt) == txt: return message return None @@ -114,5 +117,8 @@ class Plugin(BasePlugin): messages = list(filter(message_match, messages)) elif len(args) > 1: return False - return Completion(the_input.auto_completion, - [clean_text(msg.txt) for msg in messages[::-1]], '') + return Completion( + the_input.auto_completion, + [clean_text(msg.txt) for msg in messages[::-1] if isinstance(msg, Message)], + '' + ) diff --git a/plugins/rainbow.py b/plugins/rainbow.py index 4ab0b9ac..e5987089 100644 --- a/plugins/rainbow.py +++ b/plugins/rainbow.py @@ -14,7 +14,7 @@ Usage .. note:: Can create fun things when used with :ref:`The figlet plugin <figlet-plugin>`. -.. _#3273: https://dev.louiz.org/issues/3273 +.. _#3273: https://lab.louiz.org/poezio/poezio/-/issues/3273 """ from poezio.plugin import BasePlugin from poezio import xhtml diff --git a/plugins/remove_get_trackers.py b/plugins/remove_get_trackers.py new file mode 100644 index 00000000..db1e87f3 --- /dev/null +++ b/plugins/remove_get_trackers.py @@ -0,0 +1,24 @@ +""" +Remove GET trackers from URLs in sent messages. +""" +from poezio.plugin import BasePlugin +import re + +class Plugin(BasePlugin): + def init(self): + self.api.information('This plugin is deprecated and will be replaced by \'untrackme\'.', 'Warning') + + self.api.add_event_handler('muc_say', self.remove_get_trackers) + self.api.add_event_handler('conversation_say', self.remove_get_trackers) + self.api.add_event_handler('private_say', self.remove_get_trackers) + + def remove_get_trackers(self, msg, tab): + # fbclid: used globally (Facebook) + # utm_*: used globally https://en.wikipedia.org/wiki/UTM_parameters + # ncid: DoubleClick (Google) + # ref_src, ref_url: twitter + # Others exist but are excluded because they are not common. + # See https://en.wikipedia.org/wiki/UTM_parameters + msg['body'] = re.sub('(https?://[^ ]+)&?(fbclid|dclid|ncid|utm_source|utm_medium|utm_campaign|utm_term|utm_content|ref_src|ref_url)=[^ &#]*', + r'\1', + msg['body']) diff --git a/plugins/reorder.py b/plugins/reorder.py index 32fa6639..158b89bb 100644 --- a/plugins/reorder.py +++ b/plugins/reorder.py @@ -59,6 +59,8 @@ And finally, the ``[tab name]`` must be: - For a type ``static``, the full JID of the contact """ +from slixmpp import InvalidJID, JID + from poezio import tabs from poezio.decorators import command_args_parser from poezio.plugin import BasePlugin @@ -90,7 +92,11 @@ def parse_config(tab_config): if pos in result or pos <= 0: return None - typ, name = tab_config.get(option, default=':').split(':', maxsplit=1) + spec = tab_config.get(option, default=':').split(':', maxsplit=1) + # Gap tabs are recreated automatically if there's a gap in indices. + if spec == 'empty': + return None + typ, name = spec if typ not in TEXT_TO_TAB: return None result[pos] = (TEXT_TO_TAB[typ], name) @@ -111,12 +117,15 @@ def parse_runtime_tablist(tablist): for tab in tablist[1:]: i += 1 result = check_tab(tab) - if result: + # Don't serialize gap tabs as they're recreated automatically + if result != 'empty' and isinstance(tab, tuple(TEXT_TO_TAB.values())): props.append((i, '%s:%s' % (result, tab.jid.full))) return props class Plugin(BasePlugin): + """reorder plugin""" + def init(self): self.api.add_command( 'reorder', @@ -129,20 +138,24 @@ class Plugin(BasePlugin): help='Save the current tab layout') @command_args_parser.ignored - def command_save_order(self): + def command_save_order(self) -> None: + """ + /save_order + """ conf = parse_runtime_tablist(self.core.tabs) for key, value in conf: self.config.set(key, value) self.api.information('Tab order saved', 'Info') @command_args_parser.ignored - def command_reorder(self): + def command_reorder(self) -> None: """ /reorder """ tabs_spec = parse_config(self.config) if not tabs_spec: - return self.api.information('Invalid reorder config', 'Error') + self.api.information('Invalid reorder config', 'Error') + return None old_tabs = self.core.tabs.get_tabs() roster = old_tabs.pop(0) @@ -154,22 +167,37 @@ class Plugin(BasePlugin): for pos in sorted(tabs_spec): if create_gaps and pos > last + 1: new_tabs += [ - tabs.GapTab(self.core) for i in range(pos - last - 1) + tabs.GapTab() for i in range(pos - last - 1) ] - cls, name = tabs_spec[pos] - tab = self.core.tabs.by_name_and_class(name, cls=cls) - if tab and tab in old_tabs: - new_tabs.append(tab) - old_tabs.remove(tab) - else: - self.api.information('Tab %s not found' % name, 'Warning') + cls, jid = tabs_spec[pos] + try: + jid = JID(jid) + tab = self.core.tabs.by_name_and_class(str(jid), cls=cls) + if tab and tab in old_tabs: + new_tabs.append(tab) + old_tabs.remove(tab) + else: + # TODO: Add support for MucTab. Requires nickname. + if cls in (tabs.DynamicConversationTab, tabs.StaticConversationTab): + self.api.information('Tab %s not found. Creating it' % jid, 'Warning') + new_tab = cls(self.core, jid) + new_tabs.append(new_tab) + else: + new_tabs.append(tabs.GapTab()) + except: + self.api.information('Failed to create tab \'%s\'.' % jid, 'Error') if create_gaps: - new_tabs.append(tabs.GapTab(self.core)) - last = pos + new_tabs.append(tabs.GapTab()) + finally: + last = pos for tab in old_tabs: if tab: new_tabs.append(tab) + # TODO: Ensure we don't break poezio and call this with whatever + # tablist we have. The roster tab at least needs to be in there. self.core.tabs.replace_tabs(new_tabs) self.core.refresh_window() + + return None diff --git a/plugins/replace.py b/plugins/replace.py index 9646a817..02059a18 100644 --- a/plugins/replace.py +++ b/plugins/replace.py @@ -91,7 +91,7 @@ def replace_time(message, tab): def replace_date(message, tab): - return datetime.datetime.now().strftime("%x") + return datetime.datetime.now().strftime("%Y-%m-%d") def replace_datetime(message, tab): diff --git a/plugins/screen_detach.py b/plugins/screen_detach.py index 0a2514c4..1f908513 100644 --- a/plugins/screen_detach.py +++ b/plugins/screen_detach.py @@ -43,10 +43,10 @@ DEFAULT_CONFIG = { # overload if this is not how your stuff # is configured try: - LOGIN = os.getlogin() + LOGIN = os.getlogin() or '' LOGIN_TMUX = os.getuid() except Exception: - LOGIN = os.getenv('USER') + LOGIN = os.getenv('USER') or '' LOGIN_TMUX = os.getuid() SCREEN_DIR = '/var/run/screens/S-%s' % LOGIN diff --git a/plugins/send_delayed.py b/plugins/send_delayed.py index 846fccd1..92ed97c1 100644 --- a/plugins/send_delayed.py +++ b/plugins/send_delayed.py @@ -18,6 +18,7 @@ This plugin adds a command to the chat tabs. """ +import asyncio from poezio.plugin import BasePlugin from poezio.core.structs import Completion from poezio.decorators import command_args_parser @@ -28,7 +29,7 @@ from poezio import timed_events class Plugin(BasePlugin): def init(self): - for _class in (tabs.PrivateTab, tabs.ConversationTab, tabs.MucTab): + for _class in (tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.MucTab): self.api.add_tab_command( _class, 'send_delayed', @@ -74,6 +75,6 @@ class Plugin(BasePlugin): tab = args[0] # anything could happen to the tab during the interval try: - tab.command_say(args[1]) + asyncio.ensure_future(tab.command_say(args[1])) except: pass diff --git a/plugins/server_part.py b/plugins/server_part.py index f29b4099..cae2248e 100644 --- a/plugins/server_part.py +++ b/plugins/server_part.py @@ -16,10 +16,10 @@ Command """ +from slixmpp import JID, InvalidJID from poezio.plugin import BasePlugin from poezio.tabs import MucTab from poezio.decorators import command_args_parser -from poezio.common import safeJID from poezio.core.structs import Completion @@ -42,13 +42,15 @@ class Plugin(BasePlugin): jid = current_tab.jid.bare message = None elif len(args) == 1: - jid = safeJID(args[0]).domain - if not jid: + try: + jid = JID(args[0]).domain + except InvalidJID: return self.core.command_help('server_part') message = None else: - jid = safeJID(args[0]).domain - if not jid: + try: + jid = JID(args[0]).domain + except InvalidJID: return self.core.command_help('server_part') message = args[1] diff --git a/plugins/simple_notify.py b/plugins/simple_notify.py index f4dfd2d2..29418f40 100644 --- a/plugins/simple_notify.py +++ b/plugins/simple_notify.py @@ -114,7 +114,8 @@ class Plugin(BasePlugin): def on_conversation_msg(self, message, tab): fro = message['from'].bare - self.do_notify(message, fro) + if fro.bare != self.core.xmpp.boundjid.bare: + self.do_notify(message, fro) def on_muc_msg(self, message, tab): # Don't notify if message is from yourself diff --git a/plugins/sticker.py b/plugins/sticker.py new file mode 100644 index 00000000..c9deacc0 --- /dev/null +++ b/plugins/sticker.py @@ -0,0 +1,97 @@ +''' +This plugin lets the user select and send a sticker from a pack of stickers. + +The protocol used here is based on XEP-0363 and XEP-0066, while a future +version may use XEP-0449 instead. + +Command +------- + +.. glossary:: + /sticker + **Usage:** ``/sticker <pack>`` + + Opens a picker tool, and send the sticker which has been selected. + +Configuration options +--------------------- + +.. glossary:: + sticker_picker + **Default:** ``poezio-sticker-picker`` + + The command to invoke as a sticker picker. A sample one is provided in + tools/sticker-picker. + + stickers_dir + **Default:** ``XDG_DATA_HOME/poezio/stickers`` + + The directory under which the sticker packs can be found. +''' + +import asyncio +import concurrent.futures +from poezio import xdg +from poezio.plugin import BasePlugin +from poezio.config import config +from poezio.decorators import command_args_parser +from poezio.core.structs import Completion +from pathlib import Path +from asyncio.subprocess import PIPE, DEVNULL + +class Plugin(BasePlugin): + dependencies = {'upload'} + + def init(self): + # The command to use as a picker helper. + self.picker_command = config.getstr('sticker_picker') or 'poezio-sticker-picker' + + # Select and create the stickers directory. + directory = config.getstr('stickers_dir') + if directory: + self.directory = Path(directory).expanduser() + else: + self.directory = xdg.DATA_HOME / 'stickers' + self.directory.mkdir(parents=True, exist_ok=True) + + self.upload = self.refs['upload'] + self.api.add_command('sticker', self.command_sticker, + usage='<sticker pack>', + short='Send a sticker', + help='Send a sticker, with a helper GUI sticker picker', + completion=self.completion_sticker) + + def command_sticker(self, pack): + ''' + Sends a sticker + ''' + if not pack: + self.api.information('Missing sticker pack argument.', 'Error') + return + async def run_command(tab, path: Path): + try: + process = await asyncio.create_subprocess_exec( + self.picker_command, path, stdout=PIPE, stderr=PIPE) + sticker, stderr = await process.communicate() + except FileNotFoundError as err: + self.api.information('Failed to launch the sticker picker: %s' % err, 'Error') + return + else: + if process.returncode != 0: + self.api.information('Sticker picker failed: %s' % stderr.decode(), 'Error') + return + if sticker: + filename = sticker.decode().rstrip() + self.api.information('Sending sticker %s' % filename, 'Info') + await self.upload.send_upload(path / filename, tab) + tab = self.api.current_tab() + path = self.directory / pack + asyncio.create_task(run_command(tab, path)) + + def completion_sticker(self, the_input): + ''' + Completion for /sticker + ''' + txt = the_input.get_text()[9:] + directories = [directory.name for directory in self.directory.glob(txt + '*')] + return Completion(the_input.auto_completion, directories, quotify=False) diff --git a/plugins/stoi.py b/plugins/stoi.py index 04d84881..78c4ed70 100644 --- a/plugins/stoi.py +++ b/plugins/stoi.py @@ -28,7 +28,7 @@ char_we_dont_want = string.punctuation + ' ’„“”…«»' class Plugin(BasePlugin): def init(self): - for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.ConversationTab): + for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab): self.api.add_tab_command( tab_type, 'stoi', diff --git a/plugins/tell.py b/plugins/tell.py index 614c1ef5..cd72a9e5 100644 --- a/plugins/tell.py +++ b/plugins/tell.py @@ -25,6 +25,7 @@ This plugin defines two new commands for chatroom tabs: List all queued messages for the current chatroom. """ +import asyncio from poezio.plugin import BasePlugin from poezio.core.structs import Completion from poezio.decorators import command_args_parser @@ -66,7 +67,7 @@ class Plugin(BasePlugin): if nick not in self.tabs[tab]: return for i in self.tabs[tab][nick]: - tab.command_say("%s: %s" % (nick, i)) + asyncio.ensure_future(tab.command_say("%s: %s" % (nick, i))) del self.tabs[tab][nick] @command_args_parser.ignored diff --git a/plugins/time_marker.py b/plugins/time_marker.py index 76f7e589..6ce511a0 100644 --- a/plugins/time_marker.py +++ b/plugins/time_marker.py @@ -31,6 +31,7 @@ Messages like “2 hours, 25 minutes passed…” are automatically displayed in from poezio.plugin import BasePlugin from datetime import datetime, timedelta +from poezio.ui.types import InfoMessage class Plugin(BasePlugin): @@ -72,4 +73,5 @@ class Plugin(BasePlugin): delta = datetime.now() - last_message_date if delta >= timedelta(0, self.config.get('delay', 900)): tab.add_message( - "%s passed…" % (format_timedelta(delta), ), str_time='') + InfoMessage("%s passed…" % (format_timedelta(delta), )) + ) diff --git a/plugins/untrackme.py b/plugins/untrackme.py new file mode 100644 index 00000000..ceddc5c5 --- /dev/null +++ b/plugins/untrackme.py @@ -0,0 +1,140 @@ +""" + UntrackMe wannabe plugin +""" + +from typing import Callable, Dict, List, Tuple, Union + +import re +import logging +from slixmpp import Message +from poezio import tabs +from poezio.plugin import BasePlugin +from urllib.parse import quote as urlquote + + +log = logging.getLogger(__name__) + +ChatTabs = Union[ + tabs.MucTab, + tabs.DynamicConversationTab, + tabs.StaticConversationTab, + tabs.PrivateTab, +] + +RE_URL: re.Pattern = re.compile('https?://(?P<host>[^/]+)(?P<rest>[^ ]*)') + +SERVICES: Dict[str, Tuple[str, bool]] = { # host: (service, proxy) + 'm.youtube.com': ('invidious', False), + 'www.youtube.com': ('invidious', False), + 'youtube.com': ('invidious', False), + 'youtu.be': ('invidious', False), + 'youtube-nocookie.com': ('invidious', False), + 'mobile.twitter.com': ('nitter', False), + 'www.twitter.com': ('nitter', False), + 'twitter.com': ('nitter', False), + 'pic.twitter.com': ('nitter_img', True), + 'pbs.twimg.com': ('nitter_img', True), + 'instagram.com': ('bibliogram', False), + 'www.instagram.com': ('bibliogram', False), + 'm.instagram.com': ('bibliogram', False), +} + +def proxy(service: str) -> Callable[[str], str]: + """Some services require the original url""" + def inner(origin: str) -> str: + return service + urlquote(origin) + return inner + + +class Plugin(BasePlugin): + """UntrackMe""" + + default_config: Dict[str, Dict[str, Union[str, bool]]] = { + 'default': { + 'cleanup': True, + 'redirect': True, + 'display_corrections': False, + }, + 'services': { + 'invidious': 'https://invidious.snopyta.org', + 'nitter': 'https://nitter.net', + 'bibliogram': 'https://bibliogram.art', + }, + } + + def init(self): + nitter_img = self.config.get('nitter', section='services') + '/pic/' + self.config.set('nitter_img', nitter_img, section='services') + + self.api.add_event_handler('muc_say', self.handle_msg) + self.api.add_event_handler('conversation_say', self.handle_msg) + self.api.add_event_handler('private_say', self.handle_msg) + + self.api.add_event_handler('muc_msg', self.handle_msg) + self.api.add_event_handler('conversation_msg', self.handle_msg) + self.api.add_event_handler('private_msg', self.handle_msg) + + def map_services(self, match: re.Match) -> str: + """ + If it matches a host that we know about, change the domain for the + alternative service. Some hosts needs to be proxied instead (such + as twitter pictures), so they're url encoded and appended to the + proxy service. + """ + + host = match.group('host') + + dest = SERVICES.get(host) + if dest is None: + return match.group(0) + + destname, proxy = dest + replaced = self.config.get(destname, section='services') + result = replaced + match.group('rest') + + if proxy: + url = urlquote(match.group(0)) + result = replaced + url + + # TODO: count parenthesis? + # Removes comma at the end of a link. + if result[-3] == '%2C': + result = result[:-3] + ',' + + return result + + def handle_msg(self, msg: Message, tab: ChatTabs) -> None: + orig = msg['body'] + + if self.config.get('cleanup', section='default'): + msg['body'] = self.cleanup_url(msg['body']) + if self.config.get('redirect', section='default'): + msg['body'] = self.redirect_url(msg['body']) + + if self.config.get('display_corrections', section='default') and \ + msg['body'] != orig: + log.debug( + 'UntrackMe in tab \'%s\':\nOriginal: %s\nModified: %s', + tab.name, orig, msg['body'], + ) + + self.api.information( + 'UntrackMe in tab \'{}\':\nOriginal: {}\nModified: {}'.format( + tab.name, orig, msg['body'] + ), + 'Info', + ) + + def cleanup_url(self, txt: str) -> str: + # fbclid: used globally (Facebook) + # utm_*: used globally https://en.wikipedia.org/wiki/UTM_parameters + # ncid: DoubleClick (Google) + # ref_src, ref_url: twitter + # Others exist but are excluded because they are not common. + # See https://en.wikipedia.org/wiki/UTM_parameters + return re.sub('(https?://[^ ]+)&?(fbclid|dclid|ncid|utm_source|utm_medium|utm_campaign|utm_term|utm_content|ref_src|ref_url)=[^ &#]*', + r'\1', + txt) + + def redirect_url(self, txt: str) -> str: + return RE_URL.sub(self.map_services, txt) diff --git a/plugins/upload.py b/plugins/upload.py index 7e25070e..6926c075 100644 --- a/plugins/upload.py +++ b/plugins/upload.py @@ -16,12 +16,15 @@ This plugin adds a command to the chat tabs. """ + +from typing import Optional + import asyncio import traceback from os.path import expanduser from glob import glob -from slixmpp.plugins.xep_0363.http_upload import UploadServiceNotFound +from slixmpp.plugins.xep_0363.http_upload import FileTooBig, HTTPError, UploadServiceNotFound from poezio.plugin import BasePlugin from poezio.core.structs import Completion @@ -30,9 +33,19 @@ from poezio import tabs class Plugin(BasePlugin): + dependencies = {'embed'} + def init(self): + self.embed = self.refs['embed'] + if not self.core.xmpp['xep_0363']: raise Exception('slixmpp XEP-0363 plugin failed to load') + if not self.core.xmpp['xep_0454']: + self.api.information( + 'slixmpp XEP-0454 plugin failed to load. ' + 'Will not be able to encrypt uploaded files.', + 'Warning', + ) for _class in (tabs.PrivateTab, tabs.StaticConversationTab, tabs.DynamicConversationTab, tabs.MucTab): self.api.add_tab_command( _class, @@ -43,18 +56,29 @@ class Plugin(BasePlugin): short='Upload a file', completion=self.completion_filename) - async def async_upload(self, filename): + async def upload(self, filename, encrypted=False) -> Optional[str]: try: - url = await self.core.xmpp['xep_0363'].upload_file(filename) + upload_file = self.core.xmpp['xep_0363'].upload_file + if encrypted: + upload_file = self.core.xmpp['xep_0454'].upload_file + url = await upload_file(filename) except UploadServiceNotFound: self.api.information('HTTP Upload service not found.', 'Error') - return + return None + except (FileTooBig, HTTPError) as exn: + self.api.information(str(exn), 'Error') + return None except Exception: exception = traceback.format_exc() self.api.information('Failed to upload file: %s' % exception, 'Error') - return - self.core.insert_input_text(url) + return None + return url + + async def send_upload(self, filename, tab, encrypted=False): + url = await self.upload(filename, encrypted) + if url is not None: + self.embed.embed_image_url(url, tab) @command_args_parser.quoted(1) def command_upload(self, args): @@ -63,7 +87,9 @@ class Plugin(BasePlugin): return filename, = args filename = expanduser(filename) - asyncio.ensure_future(self.async_upload(filename)) + tab = self.api.current_tab() + encrypted = self.core.xmpp['xep_0454'] and tab.e2e_encryption is not None + asyncio.create_task(self.send_upload(filename, tab, encrypted)) @staticmethod def completion_filename(the_input): diff --git a/plugins/uptime.py b/plugins/uptime.py index d5a07b7b..a55af970 100644 --- a/plugins/uptime.py +++ b/plugins/uptime.py @@ -12,8 +12,10 @@ Command Retrieve the uptime of the server of ``jid``. """ from poezio.plugin import BasePlugin -from poezio.common import parse_secs_to_str, safeJID +from poezio.common import parse_secs_to_str from slixmpp.xmlstream import ET +from slixmpp import JID, InvalidJID +from slixmpp.exceptions import IqError, IqTimeout class Plugin(BasePlugin): @@ -25,19 +27,23 @@ class Plugin(BasePlugin): help='Ask for the uptime of a server or component (see XEP-0012).', short='Get the uptime') - def command_uptime(self, arg): - def callback(iq): - for query in iq.xml.getiterator('{jabber:iq:last}query'): + async def command_uptime(self, arg): + try: + jid = JID(arg) + except InvalidJID: + return + iq = self.core.xmpp.make_iq_get(ito=jid.server) + iq.append(ET.Element('{jabber:iq:last}query')) + try: + iq = await iq.send() + result = iq.xml.find('{jabber:iq:last}query') + if result is not None: self.api.information( 'Server %s online since %s' % (iq['from'], parse_secs_to_str( - int(query.attrib['seconds']))), 'Info') + int(result.attrib['seconds']))), 'Info') return - self.api.information('Could not retrieve uptime', 'Error') + except (IqError, IqTimeout): + pass + self.api.information('Could not retrieve uptime', 'Error') - jid = safeJID(arg) - if not jid.server: - return - iq = self.core.xmpp.make_iq_get(ito=jid.server) - iq.append(ET.Element('{jabber:iq:last}query')) - iq.send(callback=callback) diff --git a/plugins/user_extras.py b/plugins/user_extras.py new file mode 100644 index 00000000..96559111 --- /dev/null +++ b/plugins/user_extras.py @@ -0,0 +1,634 @@ +""" +This plugin enables rich presence events, such as mood, activity, gaming or tune. + +.. versionadded:: 0.14 + This plugin was previously provided in the poezio core features. + +Command +------- +.. glossary:: + + /activity + **Usage:** ``/activity [<general> [specific] [comment]]`` + + Send your current activity to your contacts (use the completion to cycle + through all the general and specific possible activities). + + Nothing means "stop broadcasting an activity". + + /mood + **Usage:** ``/mood [<mood> [comment]]`` + Send your current mood to your contacts (use the completion to cycle + through all the possible moods). + + Nothing means "stop broadcasting a mood". + + /gaming + **Usage:** ``/gaming [<game name> [server address]]`` + + Send your current gaming activity to your contacts. + + Nothing means "stop broadcasting a gaming activity". + + +Configuration +------------- + +.. glossary:: + + display_gaming_notifications + + **Default value:** ``true`` + + If set to true, notifications about the games your contacts are playing + will be displayed in the info buffer as 'Gaming' messages. + + display_tune_notifications + + **Default value:** ``true`` + + If set to true, notifications about the music your contacts listen to + will be displayed in the info buffer as 'Tune' messages. + + display_mood_notifications + + **Default value:** ``true`` + + If set to true, notifications about the mood of your contacts + will be displayed in the info buffer as 'Mood' messages. + + display_activity_notifications + + **Default value:** ``true`` + + If set to true, notifications about the current activity of your contacts + will be displayed in the info buffer as 'Activity' messages. + + enable_user_activity + + **Default value:** ``true`` + + Set this to ``false`` if you don’t want to receive the activity of your contacts. + + enable_user_gaming + + **Default value:** ``true`` + + Set this to ``false`` if you don’t want to receive the gaming activity of your contacts. + + enable_user_mood + + **Default value:** ``true`` + + Set this to ``false`` if you don’t want to receive the mood of your contacts. + + enable_user_tune + + **Default value:** ``true`` + + If this is set to ``false``, you will no longer be subscribed to tune events, + and the :term:`display_tune_notifications` option will be ignored. + + +""" +import asyncio +from functools import reduce +from typing import Dict + +from slixmpp import InvalidJID, JID, Message +from poezio.decorators import command_args_parser +from poezio.plugin import BasePlugin +from poezio.roster import roster +from poezio.contact import Contact, Resource +from poezio.core.structs import Completion +from poezio import common +from poezio import tabs + + +class Plugin(BasePlugin): + + default_config = { + 'user_extras': { + 'display_gaming_notifications': True, + 'display_mood_notifications': True, + 'display_activity_notifications': True, + 'display_tune_notifications': True, + 'enable_user_activity': True, + 'enable_user_gaming': True, + 'enable_user_mood': True, + 'enable_user_tune': True, + } + } + + def init(self): + for plugin in {'xep_0196', 'xep_0108', 'xep_0107', 'xep_0118'}: + self.core.xmpp.register_plugin(plugin) + self.api.add_command( + 'activity', + self.command_activity, + usage='[<general> [specific] [text]]', + help='Send your current activity to your contacts ' + '(use the completion). Nothing means ' + '"stop broadcasting an activity".', + short='Send your activity.', + completion=self.comp_activity + ) + self.api.add_command( + 'mood', + self.command_mood, + usage='[<mood> [text]]', + help='Send your current mood to your contacts ' + '(use the completion). Nothing means ' + '"stop broadcasting a mood".', + short='Send your mood.', + completion=self.comp_mood, + ) + self.api.add_command( + 'gaming', + self.command_gaming, + usage='[<game name> [server address]]', + help='Send your current gaming activity to ' + 'your contacts. Nothing means "stop ' + 'broadcasting a gaming activity".', + short='Send your gaming activity.', + completion=None + ) + handlers = [ + ('user_mood_publish', self.on_mood_event), + ('user_tune_publish', self.on_tune_event), + ('user_gaming_publish', self.on_gaming_event), + ('user_activity_publish', self.on_activity_event), + ] + for name, handler in handlers: + self.core.xmpp.add_event_handler(name, handler) + + def cleanup(self): + handlers = [ + ('user_mood_publish', self.on_mood_event), + ('user_tune_publish', self.on_tune_event), + ('user_gaming_publish', self.on_gaming_event), + ('user_activity_publish', self.on_activity_event), + ] + for name, handler in handlers: + self.core.xmpp.del_event_handler(name, handler) + asyncio.create_task(self._stop()) + + async def _stop(self): + await asyncio.gather( + self.core.xmpp.plugin['xep_0108'].stop(), + self.core.xmpp.plugin['xep_0107'].stop(), + self.core.xmpp.plugin['xep_0196'].stop(), + ) + + + @command_args_parser.quoted(0, 2) + async def command_mood(self, args): + """ + /mood [<mood> [text]] + """ + if not args: + return await self.core.xmpp.plugin['xep_0107'].stop() + mood = args[0] + if mood not in MOODS: + return self.core.information( + '%s is not a correct value for a mood.' % mood, 'Error') + if len(args) == 2: + text = args[1] + else: + text = None + await self.core.xmpp.plugin['xep_0107'].publish_mood( + mood, text + ) + + @command_args_parser.quoted(0, 3) + async def command_activity(self, args): + """ + /activity [<general> [specific] [text]] + """ + length = len(args) + if not length: + return await self.core.xmpp.plugin['xep_0108'].stop() + + general = args[0] + if general not in ACTIVITIES: + return self.api.information( + '%s is not a correct value for an activity' % general, 'Error') + specific = None + text = None + if length == 2: + if args[1] in ACTIVITIES[general]: + specific = args[1] + else: + text = args[1] + elif length == 3: + specific = args[1] + text = args[2] + if specific and specific not in ACTIVITIES[general]: + return self.core.information( + '%s is not a correct value ' + 'for an activity' % specific, 'Error') + await self.core.xmpp.plugin['xep_0108'].publish_activity( + general, specific, text + ) + + @command_args_parser.quoted(0, 2) + async def command_gaming(self, args): + """ + /gaming [<game name> [server address]] + """ + if not args: + return await self.core.xmpp.plugin['xep_0196'].stop() + + name = args[0] + if len(args) > 1: + address = args[1] + else: + address = None + return await self.core.xmpp.plugin['xep_0196'].publish_gaming( + name=name, server_address=address + ) + + def comp_activity(self, the_input): + """Completion for /activity""" + n = the_input.get_argument_position(quoted=True) + args = common.shell_split(the_input.text) + if n == 1: + return Completion( + the_input.new_completion, + sorted(ACTIVITIES.keys()), + n, + quotify=True) + elif n == 2: + if args[1] in ACTIVITIES: + l = list(ACTIVITIES[args[1]]) + l.remove('category') + l.sort() + return Completion(the_input.new_completion, l, n, quotify=True) + + def comp_mood(self, the_input): + """Completion for /mood""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + return Completion( + the_input.new_completion, + sorted(MOODS.keys()), + 1, + quotify=True) + + def on_gaming_event(self, message: Message): + """ + Called when a pep notification for user gaming + is received + """ + contact = roster[message['from'].bare] + if not contact: + return + item = message['pubsub_event']['items']['item'] + old_gaming = contact.rich_presence['gaming'] + xml_node = item.xml.find('{urn:xmpp:gaming:0}game') + # list(xml_node) checks whether there are children or not. + if xml_node is not None and list(xml_node): + item = item['gaming'] + # only name and server_address are used for now + contact.rich_presence['gaming'] = { + 'character_name': item['character_name'], + 'character_profile': item['character_profile'], + 'name': item['name'], + 'level': item['level'], + 'uri': item['uri'], + 'server_name': item['server_name'], + 'server_address': item['server_address'], + } + else: + contact.rich_presence['gaming'] = {} + + if old_gaming != contact.rich_presence['gaming'] and self.config.get( + 'display_gaming_notifications'): + if contact.rich_presence['gaming']: + self.core.information( + '%s is playing %s' % (contact.bare_jid, + common.format_gaming_string( + contact.rich_presence['gaming'])), 'Gaming') + else: + self.core.information(contact.bare_jid + ' stopped playing.', + 'Gaming') + + def on_mood_event(self, message: Message): + """ + Called when a pep notification for a user mood + is received. + """ + contact = roster[message['from'].bare] + if not contact: + return + item = message['pubsub_event']['items']['item'] + old_mood = contact.rich_presence.get('mood') + plugin = item.get_plugin('mood', check=True) + if plugin: + mood = item['mood']['value'] + else: + mood = '' + if mood: + mood = MOODS.get(mood, mood) + text = item['mood']['text'] + if text: + mood = '%s (%s)' % (mood, text) + contact.rich_presence['mood'] = mood + else: + contact.rich_presence['mood'] = '' + + if old_mood != contact.rich_presence['mood'] and self.config.get( + 'display_mood_notifications'): + if contact.rich_presence['mood']: + self.core.information( + 'Mood from ' + contact.bare_jid + ': ' + contact.rich_presence['mood'], + 'Mood') + else: + self.core.information( + contact.bare_jid + ' stopped having their mood.', 'Mood') + + def on_activity_event(self, message: Message): + """ + Called when a pep notification for a user activity + is received. + """ + contact = roster[message['from'].bare] + if not contact: + return + item = message['pubsub_event']['items']['item'] + old_activity = contact.rich_presence['activity'] + xml_node = item.xml.find('{http://jabber.org/protocol/activity}activity') + # list(xml_node) checks whether there are children or not. + if xml_node is not None and list(xml_node): + try: + activity = item['activity']['value'] + except ValueError: + return + if activity[0]: + general = ACTIVITIES.get(activity[0]) + if general is None: + return + s = general['category'] + if activity[1]: + s = s + '/' + general.get(activity[1], 'other') + text = item['activity']['text'] + if text: + s = '%s (%s)' % (s, text) + contact.rich_presence['activity'] = s + else: + contact.rich_presence['activity'] = '' + else: + contact.rich_presence['activity'] = '' + + if old_activity != contact.rich_presence['activity'] and self.config.get( + 'display_activity_notifications'): + if contact.rich_presence['activity']: + self.core.information( + 'Activity from ' + contact.bare_jid + ': ' + + contact.rich_presence['activity'], 'Activity') + else: + self.core.information( + contact.bare_jid + ' stopped doing their activity.', + 'Activity') + + def on_tune_event(self, message: Message): + """ + Called when a pep notification for a user tune + is received + """ + contact = roster[message['from'].bare] + if not contact: + return + roster.modified() + item = message['pubsub_event']['items']['item'] + old_tune = contact.rich_presence['tune'] + xml_node = item.xml.find('{http://jabber.org/protocol/tune}tune') + # list(xml_node) checks whether there are children or not. + if xml_node is not None and list(xml_node): + item = item['tune'] + contact.rich_presence['tune'] = { + 'artist': item['artist'], + 'length': item['length'], + 'rating': item['rating'], + 'source': item['source'], + 'title': item['title'], + 'track': item['track'], + 'uri': item['uri'] + } + else: + contact.rich_presence['tune'] = {} + + if old_tune != contact.rich_presence['tune'] and self.config.get( + 'display_tune_notifications'): + if contact.rich_presence['tune']: + self.core.information( + 'Tune from ' + message['from'].bare + ': ' + + common.format_tune_string(contact.rich_presence['tune']), 'Tune') + else: + self.core.information( + contact.bare_jid + ' stopped listening to music.', 'Tune') + + +# Collection of mappings for PEP moods/activities +# extracted directly from the XEP + +MOODS: Dict[str, str] = { + 'afraid': 'Afraid', + 'amazed': 'Amazed', + 'angry': 'Angry', + 'amorous': 'Amorous', + 'annoyed': 'Annoyed', + 'anxious': 'Anxious', + 'aroused': 'Aroused', + 'ashamed': 'Ashamed', + 'bored': 'Bored', + 'brave': 'Brave', + 'calm': 'Calm', + 'cautious': 'Cautious', + 'cold': 'Cold', + 'confident': 'Confident', + 'confused': 'Confused', + 'contemplative': 'Contemplative', + 'contented': 'Contented', + 'cranky': 'Cranky', + 'crazy': 'Crazy', + 'creative': 'Creative', + 'curious': 'Curious', + 'dejected': 'Dejected', + 'depressed': 'Depressed', + 'disappointed': 'Disappointed', + 'disgusted': 'Disgusted', + 'dismayed': 'Dismayed', + 'distracted': 'Distracted', + 'embarrassed': 'Embarrassed', + 'envious': 'Envious', + 'excited': 'Excited', + 'flirtatious': 'Flirtatious', + 'frustrated': 'Frustrated', + 'grumpy': 'Grumpy', + 'guilty': 'Guilty', + 'happy': 'Happy', + 'hopeful': 'Hopeful', + 'hot': 'Hot', + 'humbled': 'Humbled', + 'humiliated': 'Humiliated', + 'hungry': 'Hungry', + 'hurt': 'Hurt', + 'impressed': 'Impressed', + 'in_awe': 'In awe', + 'in_love': 'In love', + 'indignant': 'Indignant', + 'interested': 'Interested', + 'intoxicated': 'Intoxicated', + 'invincible': 'Invincible', + 'jealous': 'Jealous', + 'lonely': 'Lonely', + 'lucky': 'Lucky', + 'mean': 'Mean', + 'moody': 'Moody', + 'nervous': 'Nervous', + 'neutral': 'Neutral', + 'offended': 'Offended', + 'outraged': 'Outraged', + 'playful': 'Playful', + 'proud': 'Proud', + 'relaxed': 'Relaxed', + 'relieved': 'Relieved', + 'remorseful': 'Remorseful', + 'restless': 'Restless', + 'sad': 'Sad', + 'sarcastic': 'Sarcastic', + 'serious': 'Serious', + 'shocked': 'Shocked', + 'shy': 'Shy', + 'sick': 'Sick', + 'sleepy': 'Sleepy', + 'spontaneous': 'Spontaneous', + 'stressed': 'Stressed', + 'strong': 'Strong', + 'surprised': 'Surprised', + 'thankful': 'Thankful', + 'thirsty': 'Thirsty', + 'tired': 'Tired', + 'undefined': 'Undefined', + 'weak': 'Weak', + 'worried': 'Worried' +} + +ACTIVITIES: Dict[str, Dict[str, str]] = { + 'doing_chores': { + 'category': 'Doing_chores', + 'buying_groceries': 'Buying groceries', + 'cleaning': 'Cleaning', + 'cooking': 'Cooking', + 'doing_maintenance': 'Doing maintenance', + 'doing_the_dishes': 'Doing the dishes', + 'doing_the_laundry': 'Doing the laundry', + 'gardening': 'Gardening', + 'running_an_errand': 'Running an errand', + 'walking_the_dog': 'Walking the dog', + 'other': 'Other', + }, + 'drinking': { + 'category': 'Drinking', + 'having_a_beer': 'Having a beer', + 'having_coffee': 'Having coffee', + 'having_tea': 'Having tea', + 'other': 'Other', + }, + 'eating': { + 'category': 'Eating', + 'having_breakfast': 'Having breakfast', + 'having_a_snack': 'Having a snack', + 'having_dinner': 'Having dinner', + 'having_lunch': 'Having lunch', + 'other': 'Other', + }, + 'exercising': { + 'category': 'Exercising', + 'cycling': 'Cycling', + 'dancing': 'Dancing', + 'hiking': 'Hiking', + 'jogging': 'Jogging', + 'playing_sports': 'Playing sports', + 'running': 'Running', + 'skiing': 'Skiing', + 'swimming': 'Swimming', + 'working_out': 'Working out', + 'other': 'Other', + }, + 'grooming': { + 'category': 'Grooming', + 'at_the_spa': 'At the spa', + 'brushing_teeth': 'Brushing teeth', + 'getting_a_haircut': 'Getting a haircut', + 'shaving': 'Shaving', + 'taking_a_bath': 'Taking a bath', + 'taking_a_shower': 'Taking a shower', + 'other': 'Other', + }, + 'having_appointment': { + 'category': 'Having appointment', + 'other': 'Other', + }, + 'inactive': { + 'category': 'Inactive', + 'day_off': 'Day_off', + 'hanging_out': 'Hanging out', + 'hiding': 'Hiding', + 'on_vacation': 'On vacation', + 'praying': 'Praying', + 'scheduled_holiday': 'Scheduled holiday', + 'sleeping': 'Sleeping', + 'thinking': 'Thinking', + 'other': 'Other', + }, + 'relaxing': { + 'category': 'Relaxing', + 'fishing': 'Fishing', + 'gaming': 'Gaming', + 'going_out': 'Going out', + 'partying': 'Partying', + 'reading': 'Reading', + 'rehearsing': 'Rehearsing', + 'shopping': 'Shopping', + 'smoking': 'Smoking', + 'socializing': 'Socializing', + 'sunbathing': 'Sunbathing', + 'watching_a_movie': 'Watching a movie', + 'watching_tv': 'Watching tv', + 'other': 'Other', + }, + 'talking': { + 'category': 'Talking', + 'in_real_life': 'In real life', + 'on_the_phone': 'On the phone', + 'on_video_phone': 'On video phone', + 'other': 'Other', + }, + 'traveling': { + 'category': 'Traveling', + 'commuting': 'Commuting', + 'driving': 'Driving', + 'in_a_car': 'In a car', + 'on_a_bus': 'On a bus', + 'on_a_plane': 'On a plane', + 'on_a_train': 'On a train', + 'on_a_trip': 'On a trip', + 'walking': 'Walking', + 'cycling': 'Cycling', + 'other': 'Other', + }, + 'undefined': { + 'category': 'Undefined', + 'other': 'Other', + }, + 'working': { + 'category': 'Working', + 'coding': 'Coding', + 'in_a_meeting': 'In a meeting', + 'writing': 'Writing', + 'studying': 'Studying', + 'other': 'Other', + } +} diff --git a/plugins/vcard.py b/plugins/vcard.py index 09dcda28..b0c8e396 100644 --- a/plugins/vcard.py +++ b/plugins/vcard.py @@ -25,15 +25,16 @@ Command vcard from the current interlocutor, and in the contact list to do it on the currently selected contact. """ +import asyncio from poezio.decorators import command_args_parser from poezio.plugin import BasePlugin from poezio.roster import roster -from poezio.common import safeJID from poezio.contact import Contact, Resource from poezio.core.structs import Completion from poezio import tabs from slixmpp.jid import JID, InvalidJID +from slixmpp.exceptions import IqTimeout class Plugin(BasePlugin): @@ -61,7 +62,7 @@ class Plugin(BasePlugin): help='Send an XMPP vcard request to jid (see XEP-0054).', short='Send a vcard request.', completion=self.completion_vcard) - for _class in (tabs.PrivateTab, tabs.ConversationTab): + for _class in (tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab): self.api.add_tab_command( _class, 'vcard', @@ -240,19 +241,18 @@ class Plugin(BasePlugin): on_cancel = lambda form: self.core.close_tab() self.core.open_new_form(form, on_cancel, on_validate) - def _get_vcard(self, jid): + async def _get_vcard(self, jid): '''Send an iq to ask the vCard.''' - - def timeout_cb(iq): + try: + vcard = await self.core.xmpp.plugin['xep_0054'].get_vcard( + jid=jid, + timeout=30, + ) + self._handle_vcard(vcard) + except IqTimeout: self.api.information('Timeout while retrieving vCard for %s' % jid, 'Error') - return - self.core.xmpp.plugin['xep_0054'].get_vcard( - jid=jid, - timeout=30, - callback=self._handle_vcard, - timeout_callback=timeout_cb) @command_args_parser.raw def command_vcard(self, arg): @@ -266,7 +266,9 @@ class Plugin(BasePlugin): self.api.information('Invalid JID: %s' % arg, 'Error') return - self._get_vcard(jid) + asyncio.create_task( + self._get_vcard(jid) + ) @command_args_parser.raw def command_private_vcard(self, arg): @@ -285,10 +287,12 @@ class Plugin(BasePlugin): jid = self.api.current_tab().jid.bare + '/' + user.nick else: try: - jid = safeJID(arg) + jid = JID(arg) except InvalidJID: return self.api.information('Invalid JID: %s' % arg, 'Error') - self._get_vcard(jid) + asyncio.create_task( + self._get_vcard(jid) + ) @command_args_parser.raw def command_roster_vcard(self, arg): @@ -297,9 +301,13 @@ class Plugin(BasePlugin): return current = self.api.current_tab().selected_row if isinstance(current, Resource): - self._get_vcard(JID(current.jid).bare) + asyncio.create_task( + self._get_vcard(JID(current.jid).bare) + ) elif isinstance(current, Contact): - self._get_vcard(current.bare_jid) + asyncio.create_task( + self._get_vcard(current.bare_jid) + ) def completion_vcard(self, the_input): contacts = [contact.bare_jid for contact in roster.get_contacts()] |