helpers.py 43.9 KB
Newer Older
Philipp Hörist's avatar
Philipp Hörist committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Copyright (C) 2003-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2005-2006 Dimitur Kirov <dkirov AT gmail.com>
#                         Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2006 Alex Mauer <hawke AT hawkesnest.net>
# Copyright (C) 2006-2007 Travis Shirk <travis AT pobox.com>
# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2007 Lukas Petrovicky <lukas AT petrovicky.net>
#                    James Newton <redshodan AT gmail.com>
#                    Julien Pivotto <roidelapluie AT gmail.com>
# Copyright (C) 2007-2008 Stephan Erb <steve-e AT h3c.de>
# Copyright (C) 2008 Brendan Taylor <whateley AT gmail.com>
#                    Jonathan Schleifer <js-gajim AT webkeks.org>
#
# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
27

28
29
30
from __future__ import annotations

from typing import Any
31
from typing import Callable
32
from typing import Dict
Daniel Brötzmann's avatar
Daniel Brötzmann committed
33
34
35
36
from typing import Optional
from typing import List
from typing import Tuple
from typing import Union
Philipp Hörist's avatar
Philipp Hörist committed
37

Alexander Cherniuk's avatar
Alexander Cherniuk committed
38
import sys
39
import re
40
import os
41
import subprocess
42
import base64
43
import hashlib
44
import shlex
45
import socket
46
import logging
47
import json
Philipp Hörist's avatar
Philipp Hörist committed
48
import copy
49
import collections
Philipp Hörist's avatar
Philipp Hörist committed
50
51
import platform
import functools
Philipp Hörist's avatar
Philipp Hörist committed
52
from collections import defaultdict
53
import random
Philipp Hörist's avatar
Philipp Hörist committed
54
import weakref
55
import inspect
56
import string
57
import webbrowser
58
from string import Template
59
import urllib
60
61
from datetime import datetime
from datetime import timedelta
62
from urllib.parse import unquote
63
from encodings.punycode import punycode_encode
64
from functools import wraps
André's avatar
André committed
65
from pathlib import Path
66
from packaging.version import Version as V
67

Philipp Hörist's avatar
Philipp Hörist committed
68
from nbxmpp.namespaces import Namespace
69
from nbxmpp.const import Role
Philipp Hörist's avatar
Philipp Hörist committed
70
71
from nbxmpp.const import ConnectionProtocol
from nbxmpp.const import ConnectionType
Daniel Brötzmann's avatar
Daniel Brötzmann committed
72
from nbxmpp.const import Affiliation
73
from nbxmpp.errors import StanzaError
74
from nbxmpp.structs import ProxyData
Philipp Hörist's avatar
Philipp Hörist committed
75
76
from nbxmpp.protocol import JID
from nbxmpp.protocol import InvalidJid
Daniel Brötzmann's avatar
Daniel Brötzmann committed
77
from OpenSSL.crypto import X509, load_certificate
Philipp Hörist's avatar
Philipp Hörist committed
78
from OpenSSL.crypto import FILETYPE_PEM
André's avatar
André committed
79
from gi.repository import Gio
80
from gi.repository import GLib
81
import precis_i18n.codec  # pylint: disable=unused-import
82

83
from gajim.common import app
84
from gajim.common import configpaths
André's avatar
André committed
85
from gajim.common.i18n import Q_
86
from gajim.common.i18n import _
André's avatar
André committed
87
from gajim.common.i18n import ngettext
88
from gajim.common.i18n import get_rfc5646_lang
89
from gajim.common.const import ShowConstant
90
91
from gajim.common.const import URIType
from gajim.common.const import URIAction
92
from gajim.common.const import GIO_TLS_ERRORS
93
from gajim.common.const import SHOW_LIST
94
95
from gajim.common.regex import INVALID_XML_CHARS_REGEX
from gajim.common.regex import STH_AT_STH_DOT_STH_REGEX
96
from gajim.common.structs import URI
97
from gajim.common import types
nkour's avatar
nkour committed
98

Daniel Brötzmann's avatar
Daniel Brötzmann committed
99
100
101
102
103
104
105
106
107
HAS_PYWIN32 = False
if os.name == 'nt':
    try:
        import win32file
        import win32con
        import pywintypes
        HAS_PYWIN32 = True
    except ImportError:
        pass
Philipp Hörist's avatar
Philipp Hörist committed
108

109
110
log = logging.getLogger('gajim.c.helpers')

111
112
113
URL_REGEX = re.compile(
    r"(www\.(?!\.)|[a-z][a-z0-9+.-]*://)[^\s<>'\"]+[^!,\.\s<>\)'\"\]]")

114

115
class InvalidFormat(Exception):
116
    pass
117

118

Daniel Brötzmann's avatar
Daniel Brötzmann committed
119
def parse_jid(jidstring: str) -> str:
120
    try:
121
        return str(validate_jid(jidstring))
122
123
    except Exception as error:
        raise InvalidFormat(error)
124

Daniel Brötzmann's avatar
Daniel Brötzmann committed
125
def idn_to_ascii(host: str) -> str:
126
127
128
129
130
131
    """
    Convert IDN (Internationalized Domain Names) to ACE (ASCII-compatible
    encoding)
    """
    from encodings import idna
    labels = idna.dots.split(host)
Daniel Brötzmann's avatar
Daniel Brötzmann committed
132
    converted_labels: list[str] = []
133
    for label in labels:
134
135
136
137
        if label:
            converted_labels.append(idna.ToASCII(label).decode('utf-8'))
        else:
            converted_labels.append('')
138
    return ".".join(converted_labels)
139

Daniel Brötzmann's avatar
Daniel Brötzmann committed
140
def ascii_to_idn(host: str) -> str:
141
142
143
144
145
146
    """
    Convert ACE (ASCII-compatible encoding) to IDN (Internationalized Domain
    Names)
    """
    from encodings import idna
    labels = idna.dots.split(host)
Daniel Brötzmann's avatar
Daniel Brötzmann committed
147
    converted_labels: list[str] = []
148
149
150
    for label in labels:
        converted_labels.append(idna.ToUnicode(label))
    return ".".join(converted_labels)
151

Daniel Brötzmann's avatar
Daniel Brötzmann committed
152
def puny_encode_url(url: str) -> Optional[str]:
153
154
155
    _url = url
    if '//' not in _url:
        _url = '//' + _url
156
157
    try:
        o = urllib.parse.urlparse(_url)
André's avatar
André committed
158
        p_loc = idn_to_ascii(o.hostname)
159
160
    except Exception:
        log.debug('urlparse failed: %s', url)
Daniel Brötzmann's avatar
Daniel Brötzmann committed
161
        return None
André's avatar
André committed
162
    return url.replace(o.hostname, p_loc)
163

Daniel Brötzmann's avatar
Daniel Brötzmann committed
164
def parse_resource(resource: str) -> Optional[str]:
165
166
167
    """
    Perform stringprep on resource and return it
    """
Philipp Hörist's avatar
Philipp Hörist committed
168
169
170
171
172
173
174
    if not resource:
        return None

    try:
        return resource.encode('OpaqueString').decode('utf-8')
    except UnicodeError:
        raise InvalidFormat('Invalid character in resource.')
175

Daniel Brötzmann's avatar
Daniel Brötzmann committed
176
def windowsify(word: str) -> str:
177
    if os.name == 'nt':
Philipp Hörist's avatar
Philipp Hörist committed
178
179
        return word.capitalize()
    return word
180

Daniel Brötzmann's avatar
Daniel Brötzmann committed
181
182
def get_uf_show(show: Union[ShowConstant, str],
                use_mnemonic: bool = False) -> str:
183
184
185
186
187
188
189
    """
    Return a userfriendly string for dnd/xa/chat and make all strings
    translatable

    If use_mnemonic is True, it adds _ so GUI should call with True for
    accessibility issues
    """
190
191
192
    if isinstance(show, ShowConstant):
        show = show.name.lower()

193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
    if show == 'dnd':
        if use_mnemonic:
            uf_show = _('_Busy')
        else:
            uf_show = _('Busy')
    elif show == 'xa':
        if use_mnemonic:
            uf_show = _('_Not Available')
        else:
            uf_show = _('Not Available')
    elif show == 'chat':
        if use_mnemonic:
            uf_show = _('_Free for Chat')
        else:
            uf_show = _('Free for Chat')
    elif show == 'online':
        if use_mnemonic:
210
            uf_show = Q_('?user status:_Available')
211
        else:
212
            uf_show = Q_('?user status:Available')
213
214
215
216
217
218
219
220
221
222
223
224
225
    elif show == 'connecting':
        uf_show = _('Connecting')
    elif show == 'away':
        if use_mnemonic:
            uf_show = _('A_way')
        else:
            uf_show = _('Away')
    elif show == 'offline':
        if use_mnemonic:
            uf_show = _('_Offline')
        else:
            uf_show = _('Offline')
    elif show == 'not in roster':
226
        uf_show = _('Not in contact list')
227
228
229
230
    elif show == 'requested':
        uf_show = Q_('?contact has status:Unknown')
    else:
        uf_show = Q_('?contact has status:Has errors')
231
    return uf_show
nkour's avatar
nkour committed
232

Daniel Brötzmann's avatar
Daniel Brötzmann committed
233
def get_uf_sub(sub: str) -> str:
234
235
236
237
238
239
240
241
242
    if sub == 'none':
        uf_sub = Q_('?Subscription we already have:None')
    elif sub == 'to':
        uf_sub = _('To')
    elif sub == 'from':
        uf_sub = _('From')
    elif sub == 'both':
        uf_sub = _('Both')
    else:
243
        uf_sub = _('Unknown')
244

245
    return uf_sub
nkour's avatar
nkour committed
246

Daniel Brötzmann's avatar
Daniel Brötzmann committed
247
def get_uf_ask(ask: Union[str, None]) -> str:
248
249
250
251
252
253
    if ask is None:
        uf_ask = Q_('?Ask (for Subscription):None')
    elif ask == 'subscribe':
        uf_ask = _('Subscribe')
    else:
        uf_ask = ask
nkour's avatar
nkour committed
254

255
    return uf_ask
256

Daniel Brötzmann's avatar
Daniel Brötzmann committed
257
def get_uf_role(role: Union[Role, str], plural: bool = False) -> str:
258
    ''' plural determines if you get Moderators or Moderator'''
259
260
261
262
    if not isinstance(role, str):
        role = role.value

    if role == 'none':
Daniel Brötzmann's avatar
Daniel Brötzmann committed
263
264
        return Q_('?Group Chat Contact Role:None')
    if role == 'moderator':
265
        if plural:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
266
267
268
            return _('Moderators')
        return _('Moderator')
    if role == 'participant':
269
        if plural:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
270
271
272
            return _('Participants')
        return _('Participant')
    if role == 'visitor':
273
        if plural:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
274
275
276
            return _('Visitors')
        return _('Visitor')
    return ''
277

Daniel Brötzmann's avatar
Daniel Brötzmann committed
278
def get_uf_affiliation(affiliation: Union[Affiliation, str],
Daniel Brötzmann's avatar
Daniel Brötzmann committed
279
280
                       plural: bool = False
                       ) -> str:
281
    '''Get a nice and translated affilition for muc'''
282
283
284
285
    if not isinstance(affiliation, str):
        affiliation = affiliation.value

    if affiliation == 'none':
Daniel Brötzmann's avatar
Daniel Brötzmann committed
286
287
        return Q_('?Group Chat Contact Affiliation:None')
    if affiliation == 'owner':
288
        if plural:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
289
290
291
            return _('Owners')
        return _('Owner')
    if affiliation == 'admin':
292
        if plural:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
293
294
295
            return _('Administrators')
        return _('Administrator')
    if affiliation == 'member':
296
        if plural:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
297
298
299
            return _('Members')
        return _('Member')
    return ''
300

Daniel Brötzmann's avatar
Daniel Brötzmann committed
301
def get_uf_relative_time(timestamp: float) -> str:
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
    date_time = datetime.fromtimestamp(timestamp)
    now = datetime.now()
    timespan = now - date_time

    if timespan > timedelta(days=365):
        return str(date_time.year)
    if timespan > timedelta(days=7):
        return date_time.strftime('%b %d')
    if timespan > timedelta(days=2):
        return date_time.strftime('%a')
    if date_time.strftime('%d') != now.strftime('%d'):
        return _('Yesterday')
    if timespan > timedelta(minutes=15):
        return date_time.strftime('%H:%M')
    if timespan > timedelta(minutes=1):
        minutes = int(timespan.seconds / 60)
        return ngettext('%i min ago',
                        '%i mins ago',
                        minutes,
                        minutes,
                        minutes)
    return _('Just now')

325
def get_sorted_keys(adict):
326
327
    keys = sorted(adict.keys())
    return keys
328

Daniel Brötzmann's avatar
Daniel Brötzmann committed
329
def to_one_line(msg: str) -> str:
330
331
332
333
334
335
336
337
    msg = msg.replace('\\', '\\\\')
    msg = msg.replace('\n', '\\n')
    # s1 = 'test\ntest\\ntest'
    # s11 = s1.replace('\\', '\\\\')
    # s12 = s11.replace('\n', '\\n')
    # s12
    # 'test\\ntest\\\\ntest'
    return msg
338

Daniel Brötzmann's avatar
Daniel Brötzmann committed
339
def from_one_line(msg: str) -> str:
340
341
342
343
344
345
346
347
348
349
350
351
352
    # (?<!\\) is a lookbehind assertion which asks anything but '\'
    # to match the regexp that follows it

    # So here match '\\n' but not if you have a '\' before that
    expr = re.compile(r'(?<!\\)\\n')
    msg = expr.sub('\n', msg)
    msg = msg.replace('\\\\', '\\')
    # s12 = 'test\\ntest\\\\ntest'
    # s13 = re.sub('\n', s12)
    # s14 s13.replace('\\\\', '\\')
    # s14
    # 'test\ntest\\ntest'
    return msg
353

Daniel Brötzmann's avatar
Daniel Brötzmann committed
354
def get_uf_chatstate(chatstate: str) -> str:
355
356
357
358
359
    """
    Remove chatstate jargon and returns user friendly messages
    """
    if chatstate == 'active':
        return _('is paying attention to the conversation')
360
    if chatstate == 'inactive':
361
        return _('is doing something else')
362
    if chatstate == 'composing':
363
        return _('is composing a message…')
364
    if chatstate == 'paused':
365
366
        #paused means he or she was composing but has stopped for a while
        return _('paused composing a message')
367
    if chatstate == 'gone':
368
369
        return _('has closed the chat window or tab')
    return ''
370

Daniel Brötzmann's avatar
Daniel Brötzmann committed
371
372
373
374
def exec_command(command: str,
                 use_shell: bool = False,
                 posix: bool = True
                 ) -> None:
375
376
377
378
379
380
    """
    execute a command. if use_shell is True, we run the command as is it was
    typed in a console. So it may be dangerous if you are not sure about what
    is executed.
    """
    if use_shell:
381
        subprocess.Popen(f'{command} &', shell=True).wait()
382
    else:
383
        args = shlex.split(command, posix=posix)
Philipp Hörist's avatar
Philipp Hörist committed
384
385
        process = subprocess.Popen(args)
        app.thread_interface(process.wait)
386

Daniel Brötzmann's avatar
Daniel Brötzmann committed
387
def build_command(executable: str, parameter: str) -> str:
388
389
    # we add to the parameter (can hold path with spaces)
    # "" so we have good parsing from shell
Daniel Brötzmann's avatar
Daniel Brötzmann committed
390
    parameter = parameter.replace('"', '\\"')  # but first escape "
391
    command = f'{executable} "{parameter}"'
392
    return command
393

394
def get_file_path_from_dnd_dropped_uri(uri: str) -> str:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
395
396
    path = urllib.parse.unquote(uri)  # escape special chars
    path = path.strip('\r\n\x00')  # remove \r\n and NULL
397
    # get the path to file
Daniel Brötzmann's avatar
Daniel Brötzmann committed
398
399
400
401
402
403
    if re.match('^file:///[a-zA-Z]:/', path):  # windows
        path = path[8:]  # 8 is len('file:///')
    elif path.startswith('file://'):  # nautilus, rox
        path = path[7:]  # 7 is len('file://')
    elif path.startswith('file:'):  # xffm
        path = path[5:]  # 5 is len('file:')
404
    return path
405

Daniel Brötzmann's avatar
Daniel Brötzmann committed
406
def sanitize_filename(filename: str) -> str:
407
408
409
410
411
412
    """
    Make sure the filename we will write does contain only acceptable and latin
    characters, and is not too long (in that case hash it)
    """
    # 48 is the limit
    if len(filename) > 48:
413
414
        hash_ = hashlib.md5(filename.encode('utf-8'))
        filename = base64.b64encode(hash_.digest()).decode('utf-8')
415

416
417
    # make it latin chars only
    filename = punycode_encode(filename).decode('utf-8')
418
419
420
421
422
423
424
    filename = filename.replace('/', '_')
    if os.name == 'nt':
        filename = filename.replace('?', '_').replace(':', '_')\
                .replace('\\', '_').replace('"', "'").replace('|', '_')\
                .replace('*', '_').replace('<', '_').replace('>', '_')

    return filename
425

Daniel Brötzmann's avatar
Daniel Brötzmann committed
426
427
def reduce_chars_newlines(text: str, max_chars: int = 0,
                          max_lines: int = 0) -> str:
428
429
430
431
432
433
434
    """
    Cut the chars after 'max_chars' on each line and show only the first
    'max_lines'

    If any of the params is not present (None or 0) the action on it is not
    performed
    """
Daniel Brötzmann's avatar
Daniel Brötzmann committed
435
    def _cut_if_long(string_: str) -> str:
436
437
438
        if len(string_) > max_chars:
            string_ = string_[:max_chars - 3] + '…'
        return string_
439
440
441
442
443
444
445
446
447
448
449

    if max_lines == 0:
        lines = text.split('\n')
    else:
        lines = text.split('\n', max_lines)[:max_lines]
    if max_chars > 0:
        if lines:
            lines = [_cut_if_long(e) for e in lines]
    if lines:
        reduced_text = '\n'.join(lines)
        if reduced_text != text:
450
            reduced_text += '…'
451
452
453
    else:
        reduced_text = ''
    return reduced_text
454

455

Daniel Brötzmann's avatar
Daniel Brötzmann committed
456
def get_contact_dict_for_account(account: str) -> dict[str, types.BareContact]:
457
    """
458
    Creates a dict of jid -> contact with all contacts of account
459
460
    Can be used for completion lists
    """
Daniel Brötzmann's avatar
Daniel Brötzmann committed
461
    contacts_dict: dict[str, types.BareContact] = {}
462
463
464
    client = app.get_client(account)
    for contact in client.get_module('Roster').iter_contacts():
        contacts_dict[str(contact.jid)] = contact
465
    return contacts_dict
466

467

emil's avatar
emil committed
468
def play_sound(sound_event: str,
469
               account: Optional[str] = None,
emil's avatar
emil committed
470
471
472
               force: bool = False,
               loop: bool = False) -> None:

473
474
    if sound_event is None:
        return
475
476
    if (force or account is None or
            allow_sound_notification(account, sound_event)):
477
        play_sound_file(
emil's avatar
emil committed
478
            app.settings.get_soundevent_settings(sound_event)['path'], loop)
479

480

Philipp Hörist's avatar
Philipp Hörist committed
481
def check_soundfile_path(file_, dirs=None):
482
483
484
    """
    Check if the sound file exists

Yann Leboulanger's avatar
Yann Leboulanger committed
485
    :param file_: the file to check, absolute or relative to 'dirs' path
486
487
488
489
    :param dirs: list of knows paths to fallback if the file doesn't exists
                                     (eg: ~/.gajim/sounds/, DATADIR/sounds...).
    :return      the path to file or None if it doesn't exists.
    """
André's avatar
André committed
490
491
492
493
494
    if not file_:
        return None
    if Path(file_).exists():
        return Path(file_)

Philipp Hörist's avatar
Philipp Hörist committed
495
    if dirs is None:
496
497
        dirs = [configpaths.get('MY_DATA'),
                configpaths.get('DATA')]
Philipp Hörist's avatar
Philipp Hörist committed
498

Philipp Hörist's avatar
Philipp Hörist committed
499
    for dir_ in dirs:
André's avatar
André committed
500
501
        dir_ = dir_ / 'sounds' / file_
        if dir_.exists():
Philipp Hörist's avatar
Philipp Hörist committed
502
            return dir_
503
    return None
504

505
def strip_soundfile_path(file_, dirs=None, abs_=True):
506
507
508
    """
    Remove knowns paths from a sound file

Philipp Hörist's avatar
Philipp Hörist committed
509
510
511
512
513
514
515
    Filechooser returns an absolute path.
    If path is a known fallback path, we remove it.
    So config has no hardcoded path to DATA_DIR and text in textfield is
    shorther.
    param: file_: the filename to strip
    param: dirs: list of knowns paths from which the filename should be stripped
    param: abs_: force absolute path on dirs
516
    """
Philipp Hörist's avatar
Philipp Hörist committed
517

Yann Leboulanger's avatar
Yann Leboulanger committed
518
    if not file_:
519
520
        return None

Philipp Hörist's avatar
Philipp Hörist committed
521
    if dirs is None:
522
523
        dirs = [configpaths.get('MY_DATA'),
                configpaths.get('DATA')]
Philipp Hörist's avatar
Philipp Hörist committed
524

André's avatar
André committed
525
526
    file_ = Path(file_)
    name = file_.name
Philipp Hörist's avatar
Philipp Hörist committed
527
    for dir_ in dirs:
André's avatar
André committed
528
        dir_ = dir_ / 'sounds' / name
529
        if abs_:
André's avatar
André committed
530
            dir_ = dir_.absolute()
Philipp Hörist's avatar
Philipp Hörist committed
531
        if file_ == dir_:
532
            return name
Yann Leboulanger's avatar
Yann Leboulanger committed
533
    return file_
534

Daniel Brötzmann's avatar
Daniel Brötzmann committed
535
def play_sound_file(path_to_soundfile: str, loop: bool = False) -> None:
536
537
538
    path_to_soundfile = check_soundfile_path(path_to_soundfile)
    if path_to_soundfile is None:
        return
539

Philipp Hörist's avatar
Philipp Hörist committed
540
    from gajim.common import sound
emil's avatar
emil committed
541
    sound.play(path_to_soundfile, loop)
542

Daniel Brötzmann's avatar
Daniel Brötzmann committed
543
def get_connection_status(account: str) -> str:
544
545
    if not app.account_is_available(account):
        return 'error'
Philipp Hörist's avatar
Philipp Hörist committed
546
547
548
549
    con = app.connections[account]
    if con.state.is_reconnect_scheduled:
        return 'error'

550
    if con.state.is_connecting or con.state.is_connected:
Philipp Hörist's avatar
Philipp Hörist committed
551
552
553
554
555
556
        return 'connecting'

    if con.state.is_disconnected:
        return 'offline'
    return con.status

Daniel Brötzmann's avatar
Daniel Brötzmann committed
557
def get_global_show() -> str:
558
    maxi = 0
559
    for account in app.connections:
560
561
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
562
            continue
Philipp Hörist's avatar
Philipp Hörist committed
563
        status = get_connection_status(account)
564
        index = SHOW_LIST.index(status)
Philipp Hörist's avatar
Philipp Hörist committed
565
566
        if index > maxi:
            maxi = index
567
    return SHOW_LIST[maxi]
nkour's avatar
nkour committed
568

Daniel Brötzmann's avatar
Daniel Brötzmann committed
569
def get_global_status_message() -> str:
570
    maxi = 0
Daniel Brötzmann's avatar
Daniel Brötzmann committed
571
    status_message = ''
Philipp Hörist's avatar
Philipp Hörist committed
572
    for account, con in app.connections.items():
573
574
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
575
            continue
Philipp Hörist's avatar
Philipp Hörist committed
576
        index = SHOW_LIST.index(con.status)
Philipp Hörist's avatar
Philipp Hörist committed
577
578
        if index > maxi:
            maxi = index
Philipp Hörist's avatar
Philipp Hörist committed
579
            status_message = con.status_message
Philipp Hörist's avatar
Philipp Hörist committed
580
    return status_message
581

Daniel Brötzmann's avatar
Daniel Brötzmann committed
582
def statuses_unified() -> bool:
583
584
585
586
    """
    Test if all statuses are the same
    """
    reference = None
Philipp Hörist's avatar
Philipp Hörist committed
587
    for account, con in app.connections.items():
588
589
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
590
591
            continue
        if reference is None:
Philipp Hörist's avatar
Philipp Hörist committed
592
593
            reference = con.status
        elif reference != con.status:
594
595
            return False
    return True
596

nkour's avatar
nkour committed
597

598
def get_full_jid_from_iq(iq_obj):
599
    """
Yann Leboulanger's avatar
Yann Leboulanger committed
600
    Return the full jid (with resource) from an iq
601
    """
Philipp Hörist's avatar
Philipp Hörist committed
602
603
604
    jid = iq_obj.getFrom()
    if jid is None:
        return None
Yann Leboulanger's avatar
Yann Leboulanger committed
605
    return parse_jid(str(iq_obj.getFrom()))
606
607

def get_jid_from_iq(iq_obj):
608
    """
Yann Leboulanger's avatar
Yann Leboulanger committed
609
    Return the jid (without resource) from an iq
610
611
    """
    jid = get_full_jid_from_iq(iq_obj)
612
    return app.get_jid_without_resource(jid)
613

Daniel Brötzmann's avatar
Daniel Brötzmann committed
614
def get_auth_sha(sid: str, initiator: str, target: str) -> str:
615
616
617
    """
    Return sha of sid + initiator + target used for proxy auth
    """
618
619
    return hashlib.sha1(
        (f'{sid}{initiator}{target}').encode('utf-8')).hexdigest()
620

Daniel Brötzmann's avatar
Daniel Brötzmann committed
621
def remove_invalid_xml_chars(string_: str) -> str:
622
    if string_:
623
        string_ = re.sub(INVALID_XML_CHARS_REGEX, '', string_)
624
    return string_
625

Daniel Brötzmann's avatar
Daniel Brötzmann committed
626
def get_random_string(count: int = 16) -> str:
627
    """
Philipp Hörist's avatar
Philipp Hörist committed
628
629
630
    Create random string of count length

    WARNING: Don't use this for security purposes
631
    """
Philipp Hörist's avatar
Philipp Hörist committed
632
633
    allowed = string.ascii_uppercase + string.digits
    return ''.join(random.choice(allowed) for char in range(count))
634

Philipp Hörist's avatar
Philipp Hörist committed
635
@functools.lru_cache(maxsize=1)
Daniel Brötzmann's avatar
Daniel Brötzmann committed
636
def get_os_info() -> str:
Philipp Hörist's avatar
Philipp Hörist committed
637
638
639
640
641
    info = 'N/A'
    if sys.platform in ('win32', 'darwin'):
        info = f'{platform.system()} {platform.release()}'

    elif sys.platform == 'linux':
642
643
        try:
            import distro
Philipp Hörist's avatar
Philipp Hörist committed
644
            info = distro.name(pretty=True)
645
        except ImportError:
Philipp Hörist's avatar
Philipp Hörist committed
646
647
            info = platform.system()
    return info
648

Daniel Brötzmann's avatar
Daniel Brötzmann committed
649

Daniel Brötzmann's avatar
Daniel Brötzmann committed
650
def message_needs_highlight(text: str, nickname: str, own_jid: str) -> bool:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
    """
    Check text to see whether any of the words in (muc_highlight_words and
    nick) appear
    """
    special_words = app.settings.get('muc_highlight_words').split(';')
    special_words.append(nickname)
    special_words.append(own_jid)
    # Strip empties: ''.split(';') == [''] and would highlight everything.
    # Also lowercase everything for case insensitive compare.
    special_words = [word.lower() for word in special_words if word]
    text = text.lower()

    for special_word in special_words:
        found_here = text.find(special_word)
        while found_here > -1:
            end_here = found_here + len(special_word)
            if ((found_here == 0 or not text[found_here - 1].isalpha()) and
                    (end_here == len(text) or not text[end_here].isalpha())):
                # It is beginning of text or char before is not alpha AND
                # it is end of text or char after is not alpha
                return True
            # continue searching
            start = found_here + 1
            found_here = text.find(special_word, start)
    return False


Daniel Brötzmann's avatar
Daniel Brötzmann committed
678
def allow_showing_notification(account: str) -> bool:
679
    if not app.settings.get('show_notifications'):
680
        return False
681
    if app.settings.get('show_notifications_away'):
682
        return True
683
684
    client = app.get_client(account)
    if client.status == 'online':
685
686
        return True
    return False
687

688

Daniel Brötzmann's avatar
Daniel Brötzmann committed
689
def allow_sound_notification(account: str, sound_event: str) -> bool:
690
691
692
693
694
695
    if not app.settings.get('sounds_on'):
        return False
    client = app.get_client(account)
    if client.status != 'online' and not app.settings.get('sounddnd'):
        return False
    if app.settings.get_soundevent_settings(sound_event)['enabled']:
696
697
        return True
    return False
698

699

Daniel Brötzmann's avatar
Daniel Brötzmann committed
700
def get_optional_features(account: str) -> List[Namespace]:
701
    features = []
702
703

    if app.settings.get_account_setting(account, 'request_user_data'):
Philipp Hörist's avatar
Philipp Hörist committed
704
        features.append(Namespace.TUNE + '+notify')
Philipp Hörist's avatar
Philipp Hörist committed
705
        features.append(Namespace.LOCATION + '+notify')
706
707
708

    features.append(Namespace.NICK + '+notify')

709
710
711
    if app.connections[account].get_module('Bookmarks').nativ_bookmarks_used:
        features.append(Namespace.BOOKMARKS_1 + '+notify')
    elif app.connections[account].get_module('Bookmarks').pep_bookmarks_used:
Philipp Hörist's avatar
Philipp Hörist committed
712
        features.append(Namespace.BOOKMARKS + '+notify')
713
    if app.is_installed('AV'):
Philipp Hörist's avatar
Philipp Hörist committed
714
715
716
717
        features.append(Namespace.JINGLE_RTP)
        features.append(Namespace.JINGLE_RTP_AUDIO)
        features.append(Namespace.JINGLE_RTP_VIDEO)
        features.append(Namespace.JINGLE_ICE_UDP)
718
719
720
721

    # Give plugins the possibility to add their features
    app.plugin_manager.extension_point('update_caps', account, features)
    return features
722

Daniel Brötzmann's avatar
Daniel Brötzmann committed
723
def jid_is_blocked(account: str, jid: str) -> bool:
724
    con = app.connections[account]
Philipp Hörist's avatar
Philipp Hörist committed
725
    return jid in con.get_module('Blocking').blocked
726

Daniel Brötzmann's avatar
Daniel Brötzmann committed
727
728
729
730
def get_subscription_request_msg(account: Optional[str] = None) -> str:
    message = _('I would like to add you to my contact list.')
    if account is None:
        return message
731
732
733

    message = app.settings.get_account_setting(
        account, 'subscription_request_msg')
Daniel Brötzmann's avatar
Daniel Brötzmann committed
734
735
736
737
738
739
    if message:
        return message

    message = _('Hello, I am $name. %s') % message
    return Template(message).safe_substitute({'name': app.nicks[account]})

740
741
742
743
744
745
746
747
748
749
def get_retraction_text(account: str, moderator_jid: str,
                        reason: Optional[str]) -> str:
    client = app.get_client(account)
    contact = client.get_module('Contacts').get_contact(
        moderator_jid, groupchat=True)
    text = _('This message has been retracted by %s.') % contact.name
    if reason is not None:
        text += ' ' + _('Reason: %s') % reason
    return text

Daniel Brötzmann's avatar
Daniel Brötzmann committed
750
def get_user_proxy(account: str) -> Optional[ProxyData]:
751
    proxy_name = app.settings.get_account_setting(account, 'proxy')
752
753
    if not proxy_name:
        return None
754
    return get_proxy(proxy_name)
755

Daniel Brötzmann's avatar
Daniel Brötzmann committed
756
def get_proxy(proxy_name: str) -> Optional[ProxyData]:
757
758
759
    try:
        settings = app.settings.get_proxy_settings(proxy_name)
    except ValueError:
760
761
762
        return None

    username, password = None, None
763
764
    if settings['useauth']:
        username, password = settings['user'], settings['pass']
765

766
767
    return ProxyData(type=settings['type'],
                     host='%s:%s' % (settings['host'], settings['port']),
768
769
770
                     username=username,
                     password=password)

Daniel Brötzmann's avatar
Daniel Brötzmann committed
771
def version_condition(current_version: str, required_version: str) -> bool:
Philipp Hörist's avatar
Philipp Hörist committed
772
773
774
    if V(current_version) < V(required_version):
        return False
    return True
775

Daniel Brötzmann's avatar
Daniel Brötzmann committed
776
def get_available_emoticon_themes() -> List[str]:
Philipp Hörist's avatar
Philipp Hörist committed
777
    files = []
André's avatar
André committed
778
    for folder in configpaths.get('EMOTICONS').iterdir():
779
780
        if not folder.is_dir():
            continue
André's avatar
André committed
781
782
783
784
785
786
787
788
789
        files += [theme for theme in folder.iterdir() if theme.is_file()]

    my_emots = configpaths.get('MY_EMOTS')
    if my_emots.is_dir():
        files += list(my_emots.iterdir())

    emoticons_themes = ['font']
    emoticons_themes += [file.stem for file in files if file.suffix == '.png']
    return sorted(emoticons_themes)
790

Philipp Hörist's avatar
Philipp Hörist committed
791
792
793
def call_counter(func):
    def helper(self, restart=False):
        if restart:
Philipp Hörist's avatar
Philipp Hörist committed
794
795
            self._connect_machine_calls = 0
        self._connect_machine_calls += 1
Philipp Hörist's avatar
Philipp Hörist committed
796
        return func(self)
Philipp Hörist's avatar
Philipp Hörist committed
797
    return helper
798

799
800
801
def load_json(path: Path,
              key: Optional[str] = None,
              default: Optional[Any] = None) -> Any:
802
    try:
803
        with path.open('r') as file:
804
805
806
807
808
809
810
811
            json_dict = json.loads(file.read())
    except Exception:
        log.exception('Parsing error')
        return default

    if key is None:
        return json_dict
    return json_dict.get(key, default)
812

813

Daniel Brötzmann's avatar
Daniel Brötzmann committed
814
def ignore_contact(account: str, jid: JID) -> bool:
815
    client = app.get_client(account)
816
    contact = client.get_module('Contacts').get_contact(jid)
817
818
819
820
821

    ignore_unknown = app.settings.get_account_setting(
        account, 'ignore_unknown_contacts')
    if ignore_unknown and not contact.is_in_roster:
        log.info('Ignore unknown contact %s', str(jid))
Philipp Hörist's avatar
Philipp Hörist committed
822
823
824
        return True
    return False

825

826
class AdditionalDataDict(collections.UserDict):
827
    data: dict[str, Any]
828
829

    @staticmethod
830
    def _get_path_childs(full_path: str) -> list[str]:
831
832
833
834
835
        path_childs = [full_path]
        if ':' in full_path:
            path_childs = full_path.split(':')
        return path_childs

836
    def set_value(self, full_path: str, key: str, value: Optional[str]) -> None:
837
838
839
840
841
842
843
844
845
846
        path_childs = self._get_path_childs(full_path)
        _dict = self.data
        for path in path_childs:
            try:
                _dict = _dict[path]
            except KeyError:
                _dict[path] = {}
                _dict = _dict[path]
        _dict[key] = value

847
848
849
850
851
    def get_value(self,
                  full_path: str,
                  key: str,
                  default: Optional[str] = None) -> Optional[str]:

852
853
854
855
856
857
858
859
860
861
862
863
        path_childs = self._get_path_childs(full_path)
        _dict = self.data
        for path in path_childs:
            try:
                _dict = _dict[path]
            except KeyError:
                return default
        try:
            return _dict[key]
        except KeyError:
            return default

864
    def remove_value(self, full_path: str, key: str) -> None:
865
866
867
868
869
870
871
872
873
874
875
        path_childs = self._get_path_childs(full_path)
        _dict = self.data
        for path in path_childs:
            try:
                _dict = _dict[path]
            except KeyError:
                return
        try:
            del _dict[key]
        except KeyError:
            return
876

877
    def copy(self) -> AdditionalDataDict:
Philipp Hörist's avatar
Philipp Hörist committed
878
879
        return copy.deepcopy(self)

880

Philipp Hörist's avatar
Philipp Hörist committed
881
class Singleton(type):
Philipp Hörist's avatar
Philipp Hörist committed
882
    _instances = {}  # type: Dict[Any, Any]
Philipp Hörist's avatar
Philipp Hörist committed
883
884
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
Philipp Hörist's avatar
Philipp Hörist committed
885
886
            cls._instances[cls] = super(Singleton, cls).__call__(
                *args, **kwargs)
Philipp Hörist's avatar
Philipp Hörist committed
887
        return cls._instances[cls]
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905


def delay_execution(milliseconds):
    # Delay the first call for `milliseconds`
    # ignore all other calls while the delay is active
    def delay_execution_decorator(func):
        @wraps(func)
        def func_wrapper(*args, **kwargs):
            def timeout_wrapper():
                func(*args, **kwargs)
                delattr(func_wrapper, 'source_id')

            if hasattr(func_wrapper, 'source_id'):
                return
            func_wrapper.source_id = GLib.timeout_add(
                milliseconds, timeout_wrapper)
        return func_wrapper
    return delay_execution_decorator
906
907


Philipp Hörist's avatar
Philipp Hörist committed
908
909
910
911
912
def event_filter(filter_):
    def event_filter_decorator(func):
        @wraps(func)
        def func_wrapper(self, event, *args, **kwargs):
            for attr in filter_:
Philipp Hörist's avatar
Philipp Hörist committed
913
914
915
916
                if '=' in attr:
                    attr1, attr2 = attr.split('=')
                else:
                    attr1, attr2 = attr, attr
917
                try:
Philipp Hörist's avatar
Philipp Hörist committed
918
919
                    if getattr(event, attr1) != getattr(self, attr2):
                        return None
920
                except AttributeError:
921
                    if getattr(event, attr1) != getattr(self, f'_{attr2}'):
Philipp Hörist's avatar
Philipp Hörist committed
922
923
                        return None

Philipp Hörist's avatar
Philipp Hörist committed
924
925
926
927
928
            return func(self, event, *args, **kwargs)
        return func_wrapper
    return event_filter_decorator


Philipp Hörist's avatar
Philipp Hörist committed
929
def catch_exceptions(func):
Philipp Hörist's avatar
Philipp Hörist committed
930
931
932
933
934
935
936
937
938
939
940
    @wraps(func)
    def func_wrapper(self, *args, **kwargs):
        try:
            result = func(self, *args, **kwargs)
        except Exception as error:
            log.exception(error)
            return None
        return result
    return func_wrapper


Daniel Brötzmann's avatar
Daniel Brötzmann committed
941
def parse_uri_actions(uri: str) -> Tuple[str, Dict[str, str]]:
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
    uri = uri[5:]
    if '?' not in uri:
        return 'message', {'jid': uri}

    jid, action = uri.split('?', 1)
    data = {'jid': jid}
    if ';' in action:
        action, keys = action.split(';', 1)
        action_keys = keys.split(';')
        for key in action_keys:
            if key.startswith('subject='):
                data['subject'] = unquote(key[8:])

            elif key.startswith('body='):
                data['body'] = unquote(key[5:])

            elif key.startswith('thread='):
                data['thread'] = key[7:]
    return action, data


Daniel Brötzmann's avatar
Daniel Brötzmann committed
963
def parse_uri(uri: str) -> URI:
964
    if uri.startswith('xmpp:'):
965
966
        action, data = parse_uri_actions(uri)
        try:
Philipp Hörist's avatar
Philipp Hörist committed
967
            validate_jid(data['jid'])