helpers.py 44.5 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

Philipp Hörist's avatar
Philipp Hörist committed
28
29
30
from typing import Any  # pylint: disable=unused-import
from typing import Dict  # pylint: disable=unused-import

Alexander Cherniuk's avatar
Alexander Cherniuk committed
31
import sys
32
import re
33
import os
34
import subprocess
35
import base64
36
import hashlib
37
import shlex
38
import socket
39
import logging
40
import json
Philipp Hörist's avatar
Philipp Hörist committed
41
import copy
42
import collections
Philipp Hörist's avatar
Philipp Hörist committed
43
44
import platform
import functools
Philipp Hörist's avatar
Philipp Hörist committed
45
from collections import defaultdict
46
import random
Philipp Hörist's avatar
Philipp Hörist committed
47
import weakref
48
import inspect
49
import string
50
import webbrowser
51
from string import Template
52
53
import urllib
from urllib.parse import unquote
54
from encodings.punycode import punycode_encode
55
from functools import wraps
André's avatar
André committed
56
from pathlib import Path
57
from packaging.version import Version as V
58

Philipp Hörist's avatar
Philipp Hörist committed
59
from nbxmpp.namespaces import Namespace
60
from nbxmpp.const import Role
Philipp Hörist's avatar
Philipp Hörist committed
61
62
from nbxmpp.const import ConnectionProtocol
from nbxmpp.const import ConnectionType
63
from nbxmpp.structs import ProxyData
Philipp Hörist's avatar
Philipp Hörist committed
64
65
from nbxmpp.protocol import JID
from nbxmpp.protocol import InvalidJid
Philipp Hörist's avatar
Philipp Hörist committed
66
67
from OpenSSL.crypto import load_certificate
from OpenSSL.crypto import FILETYPE_PEM
André's avatar
André committed
68
from gi.repository import Gio
69
from gi.repository import GLib
70
import precis_i18n.codec  # pylint: disable=unused-import
71

72
from gajim.common import app
73
from gajim.common import configpaths
André's avatar
André committed
74
from gajim.common.i18n import Q_
75
from gajim.common.i18n import _
André's avatar
André committed
76
from gajim.common.i18n import ngettext
77
from gajim.common.i18n import get_rfc5646_lang
78
from gajim.common.const import ShowConstant
79
from gajim.common.const import Display
80
81
from gajim.common.const import URIType
from gajim.common.const import URIAction
82
from gajim.common.const import GIO_TLS_ERRORS
83
from gajim.common.const import SHOW_LIST
84
85
from gajim.common.regex import INVALID_XML_CHARS_REGEX
from gajim.common.regex import STH_AT_STH_DOT_STH_REGEX
86
from gajim.common.structs import URI
nkour's avatar
nkour committed
87

Philipp Hörist's avatar
Philipp Hörist committed
88

89
90
log = logging.getLogger('gajim.c.helpers')

Philipp Hörist's avatar
Philipp Hörist committed
91
92
93
94
special_groups = (_('Transports'),
                  _('Not in contact list'),
                  _('Observers'),
                  _('Group chats'))
95

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

99

100
class InvalidFormat(Exception):
101
    pass
102

103

104
def parse_jid(jidstring):
105
    try:
106
        return str(validate_jid(jidstring))
107
108
    except Exception as error:
        raise InvalidFormat(error)
109

110
def idn_to_ascii(host):
111
112
113
114
115
116
117
118
    """
    Convert IDN (Internationalized Domain Names) to ACE (ASCII-compatible
    encoding)
    """
    from encodings import idna
    labels = idna.dots.split(host)
    converted_labels = []
    for label in labels:
119
120
121
122
        if label:
            converted_labels.append(idna.ToASCII(label).decode('utf-8'))
        else:
            converted_labels.append('')
123
    return ".".join(converted_labels)
124

125
def ascii_to_idn(host):
126
127
128
129
130
131
132
133
134
135
    """
    Convert ACE (ASCII-compatible encoding) to IDN (Internationalized Domain
    Names)
    """
    from encodings import idna
    labels = idna.dots.split(host)
    converted_labels = []
    for label in labels:
        converted_labels.append(idna.ToUnicode(label))
    return ".".join(converted_labels)
136

137
138
139
140
def puny_encode_url(url):
    _url = url
    if '//' not in _url:
        _url = '//' + _url
141
142
    try:
        o = urllib.parse.urlparse(_url)
André's avatar
André committed
143
        p_loc = idn_to_ascii(o.hostname)
144
145
146
    except Exception:
        log.debug('urlparse failed: %s', url)
        return False
André's avatar
André committed
147
    return url.replace(o.hostname, p_loc)
148

149
def parse_resource(resource):
150
151
152
    """
    Perform stringprep on resource and return it
    """
Philipp Hörist's avatar
Philipp Hörist committed
153
154
155
156
157
158
159
    if not resource:
        return None

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

Philipp Hörist's avatar
Philipp Hörist committed
161
def windowsify(word):
162
    if os.name == 'nt':
Philipp Hörist's avatar
Philipp Hörist committed
163
164
        return word.capitalize()
    return word
165

166
def get_uf_show(show, use_mnemonic=False):
167
168
169
170
171
172
173
    """
    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
    """
174
175
176
    if isinstance(show, ShowConstant):
        show = show.name.lower()

177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
    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:
194
            uf_show = Q_('?user status:_Available')
195
        else:
196
            uf_show = Q_('?user status:Available')
197
198
199
200
201
202
203
204
205
206
207
208
209
    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':
210
        uf_show = _('Not in contact list')
211
212
213
214
    elif show == 'requested':
        uf_show = Q_('?contact has status:Unknown')
    else:
        uf_show = Q_('?contact has status:Has errors')
215
    return uf_show
nkour's avatar
nkour committed
216

217
def get_uf_sub(sub):
218
219
220
221
222
223
224
225
226
    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:
227
        uf_sub = _('Unknown')
228

229
    return uf_sub
nkour's avatar
nkour committed
230

231
def get_uf_ask(ask):
232
233
234
235
236
237
    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
238

239
    return uf_ask
240

241
def get_uf_role(role, plural=False):
242
    ''' plural determines if you get Moderators or Moderator'''
243
244
245
246
    if not isinstance(role, str):
        role = role.value

    if role == 'none':
247
        role_name = Q_('?Group Chat Contact Role:None')
248
    elif role == 'moderator':
249
250
251
252
        if plural:
            role_name = _('Moderators')
        else:
            role_name = _('Moderator')
253
    elif role == 'participant':
254
255
256
257
        if plural:
            role_name = _('Participants')
        else:
            role_name = _('Participant')
258
    elif role == 'visitor':
259
260
261
262
263
        if plural:
            role_name = _('Visitors')
        else:
            role_name = _('Visitor')
    return role_name
264

265
def get_uf_affiliation(affiliation, plural=False):
266
    '''Get a nice and translated affilition for muc'''
267
268
269
270
    if not isinstance(affiliation, str):
        affiliation = affiliation.value

    if affiliation == 'none':
271
        affiliation_name = Q_('?Group Chat Contact Affiliation:None')
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
    elif affiliation == 'owner':
        if plural:
            affiliation_name = _('Owners')
        else:
            affiliation_name = _('Owner')
    elif affiliation == 'admin':
        if plural:
            affiliation_name = _('Administrators')
        else:
            affiliation_name = _('Administrator')
    elif affiliation == 'member':
        if plural:
            affiliation_name = _('Members')
        else:
            affiliation_name = _('Member')
287
    return affiliation_name
288

289
def get_sorted_keys(adict):
290
291
    keys = sorted(adict.keys())
    return keys
292
293

def to_one_line(msg):
294
295
296
297
298
299
300
301
    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
302
303

def from_one_line(msg):
304
305
306
307
308
309
310
311
312
313
314
315
316
    # (?<!\\) 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
317
318

def get_uf_chatstate(chatstate):
319
320
321
322
323
    """
    Remove chatstate jargon and returns user friendly messages
    """
    if chatstate == 'active':
        return _('is paying attention to the conversation')
324
    if chatstate == 'inactive':
325
        return _('is doing something else')
326
    if chatstate == 'composing':
327
        return _('is composing a message…')
328
    if chatstate == 'paused':
329
330
        #paused means he or she was composing but has stopped for a while
        return _('paused composing a message')
331
    if chatstate == 'gone':
332
333
        return _('has closed the chat window or tab')
    return ''
334

335
def exec_command(command, use_shell=False, posix=True):
336
337
338
339
340
341
342
343
    """
    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:
        subprocess.Popen('%s &' % command, shell=True).wait()
    else:
344
        args = shlex.split(command, posix=posix)
Philipp Hörist's avatar
Philipp Hörist committed
345
346
        process = subprocess.Popen(args)
        app.thread_interface(process.wait)
347
348

def build_command(executable, parameter):
349
350
351
352
353
    # we add to the parameter (can hold path with spaces)
    # "" so we have good parsing from shell
    parameter = parameter.replace('"', '\\"') # but first escape "
    command = '%s "%s"' % (executable, parameter)
    return command
354

355
def get_file_path_from_dnd_dropped_uri(uri: str) -> str:
Yann Leboulanger's avatar
Yann Leboulanger committed
356
    path = urllib.parse.unquote(uri) # escape special chars
357
358
359
360
361
362
363
364
365
    path = path.strip('\r\n\x00') # remove \r\n and NULL
    # get the path to file
    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:')
    return path
366
367

def sanitize_filename(filename):
368
369
370
371
372
373
    """
    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:
374
375
        hash_ = hashlib.md5(filename.encode('utf-8'))
        filename = base64.b64encode(hash_.digest()).decode('utf-8')
376

377
378
    # make it latin chars only
    filename = punycode_encode(filename).decode('utf-8')
379
380
381
382
383
384
385
    filename = filename.replace('/', '_')
    if os.name == 'nt':
        filename = filename.replace('?', '_').replace(':', '_')\
                .replace('\\', '_').replace('"', "'").replace('|', '_')\
                .replace('*', '_').replace('<', '_').replace('>', '_')

    return filename
386

387
def reduce_chars_newlines(text, max_chars=0, max_lines=0):
388
389
390
391
392
393
394
    """
    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
    """
395
396
397
398
    def _cut_if_long(string_):
        if len(string_) > max_chars:
            string_ = string_[:max_chars - 3] + '…'
        return string_
399
400
401
402
403
404
405
406
407
408
409

    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:
410
            reduced_text += '…'
411
412
413
    else:
        reduced_text = ''
    return reduced_text
414
415

def get_account_status(account):
416
417
    status = reduce_chars_newlines(account['status_line'], 100, 1)
    return status
418
419

def get_contact_dict_for_account(account):
420
421
422
423
424
425
    """
    Create a dict of jid, nick -> contact with all contacts of account.

    Can be used for completion lists
    """
    contacts_dict = {}
426
    for jid in app.contacts.get_jid_list(account):
Philipp Hörist's avatar
Philipp Hörist committed
427
        contact = app.contacts.get_contact_with_highest_priority(account, jid)
428
429
430
431
432
433
434
        contacts_dict[jid] = contact
        name = contact.name
        if name in contacts_dict:
            contact1 = contacts_dict[name]
            del contacts_dict[name]
            contacts_dict['%s (%s)' % (name, contact1.jid)] = contact1
            contacts_dict['%s (%s)' % (name, jid)] = contact
435
        elif contact.name:
436
            if contact.name == app.get_nick_from_jid(jid):
437
438
439
                del contacts_dict[jid]
            contacts_dict[name] = contact
    return contacts_dict
440

nkour's avatar
nkour committed
441
def play_sound(event):
Philipp Hörist's avatar
Philipp Hörist committed
442
    if not app.settings.get('sounds_on'):
443
        return
444
    play_sound_file(app.settings.get_soundevent_settings(event)['path'])
445

Philipp Hörist's avatar
Philipp Hörist committed
446
def check_soundfile_path(file_, dirs=None):
447
448
449
    """
    Check if the sound file exists

Yann Leboulanger's avatar
Yann Leboulanger committed
450
    :param file_: the file to check, absolute or relative to 'dirs' path
451
452
453
454
    :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
455
456
457
458
459
    if not file_:
        return None
    if Path(file_).exists():
        return Path(file_)

Philipp Hörist's avatar
Philipp Hörist committed
460
    if dirs is None:
461
462
        dirs = [configpaths.get('MY_DATA'),
                configpaths.get('DATA')]
Philipp Hörist's avatar
Philipp Hörist committed
463

Philipp Hörist's avatar
Philipp Hörist committed
464
    for dir_ in dirs:
André's avatar
André committed
465
466
        dir_ = dir_ / 'sounds' / file_
        if dir_.exists():
Philipp Hörist's avatar
Philipp Hörist committed
467
            return dir_
468
    return None
469

470
def strip_soundfile_path(file_, dirs=None, abs_=True):
471
472
473
    """
    Remove knowns paths from a sound file

Philipp Hörist's avatar
Philipp Hörist committed
474
475
476
477
478
479
480
    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
481
    """
Philipp Hörist's avatar
Philipp Hörist committed
482

Yann Leboulanger's avatar
Yann Leboulanger committed
483
    if not file_:
484
485
        return None

Philipp Hörist's avatar
Philipp Hörist committed
486
    if dirs is None:
487
488
        dirs = [configpaths.get('MY_DATA'),
                configpaths.get('DATA')]
Philipp Hörist's avatar
Philipp Hörist committed
489

André's avatar
André committed
490
491
    file_ = Path(file_)
    name = file_.name
Philipp Hörist's avatar
Philipp Hörist committed
492
    for dir_ in dirs:
André's avatar
André committed
493
        dir_ = dir_ / 'sounds' / name
494
        if abs_:
André's avatar
André committed
495
            dir_ = dir_.absolute()
Philipp Hörist's avatar
Philipp Hörist committed
496
        if file_ == dir_:
497
            return name
Yann Leboulanger's avatar
Yann Leboulanger committed
498
    return file_
499

500
def play_sound_file(path_to_soundfile):
501
502
503
    path_to_soundfile = check_soundfile_path(path_to_soundfile)
    if path_to_soundfile is None:
        return
504

André's avatar
André committed
505
    path_to_soundfile = str(path_to_soundfile)
506
507
    if sys.platform == 'win32':
        import winsound
508
509
        try:
            winsound.PlaySound(path_to_soundfile,
510
                               winsound.SND_FILENAME|winsound.SND_ASYNC)
511
        except Exception:
512
            log.exception('Sound Playback Error')
513

514
    elif sys.platform == 'darwin':
Philipp Hörist's avatar
Philipp Hörist committed
515
516
517
518
        try:
            from AppKit import NSSound
        except ImportError:
            log.exception('Sound Playback Error')
519
            return
Philipp Hörist's avatar
Philipp Hörist committed
520

Philipp Hörist's avatar
Philipp Hörist committed
521
522
523
        sound = NSSound.alloc()
        sound.initWithContentsOfFile_byReference_(path_to_soundfile, True)
        sound.play()
524

André's avatar
André committed
525
    elif app.is_installed('GSOUND'):
Philipp Hörist's avatar
Philipp Hörist committed
526
        try:
André's avatar
André committed
527
528
529
            app.gsound_ctx.play_simple({'media.filename' : path_to_soundfile})
        except GLib.Error as error:
            log.error('Could not play sound: %s', error.message)
530

Philipp Hörist's avatar
Philipp Hörist committed
531
532
533
534
535
def get_connection_status(account):
    con = app.connections[account]
    if con.state.is_reconnect_scheduled:
        return 'error'

536
    if con.state.is_connecting or con.state.is_connected:
Philipp Hörist's avatar
Philipp Hörist committed
537
538
539
540
541
542
        return 'connecting'

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

543
def get_global_show():
544
    maxi = 0
545
    for account in app.connections:
546
547
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
548
            continue
Philipp Hörist's avatar
Philipp Hörist committed
549
        status = get_connection_status(account)
550
        index = SHOW_LIST.index(status)
Philipp Hörist's avatar
Philipp Hörist committed
551
552
        if index > maxi:
            maxi = index
553
    return SHOW_LIST[maxi]
nkour's avatar
nkour committed
554

Philipp Hörist's avatar
Philipp Hörist committed
555
def get_global_status_message():
556
    maxi = 0
557
    for account in app.connections:
558
559
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
560
            continue
Philipp Hörist's avatar
Philipp Hörist committed
561
        status = app.connections[account].status
562
        index = SHOW_LIST.index(status)
Philipp Hörist's avatar
Philipp Hörist committed
563
564
565
566
        if index > maxi:
            maxi = index
            status_message = app.connections[account].status_message
    return status_message
567

568
def statuses_unified():
569
570
571
572
    """
    Test if all statuses are the same
    """
    reference = None
573
    for account in app.connections:
574
575
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
576
577
            continue
        if reference is None:
Philipp Hörist's avatar
Philipp Hörist committed
578
579
            reference = app.connections[account].status
        elif reference != app.connections[account].status:
580
581
            return False
    return True
582

583
def get_icon_name_to_show(contact, account=None):
584
585
586
    """
    Get the icon name to show in online, away, requested, etc
    """
587
    if account and app.events.get_nb_roster_events(account, contact.jid):
588
        return 'event'
Philipp Hörist's avatar
Philipp Hörist committed
589
590
    if account and app.events.get_nb_roster_events(account,
                                                   contact.get_full_jid()):
591
        return 'event'
592
593
    if account and account in app.interface.minimized_controls and \
    contact.jid in app.interface.minimized_controls[account] and app.interface.\
594
595
            minimized_controls[account][contact.jid].get_nb_unread_pm() > 0:
        return 'event'
596
597
    if account and contact.jid in app.gc_connected[account]:
        if app.gc_connected[account][contact.jid]:
598
599
            return 'muc-active'
        return 'muc-inactive'
600
601
602
603
604
605
    if contact.jid.find('@') <= 0: # if not '@' or '@' starts the jid ==> agent
        return contact.show
    if contact.sub in ('both', 'to'):
        return contact.show
    if contact.ask == 'subscribe':
        return 'requested'
606
    transport = app.get_transport_name_from_jid(contact.jid)
607
608
    if transport:
        return contact.show
Philipp Hörist's avatar
Philipp Hörist committed
609
    if contact.show in SHOW_LIST:
610
        return contact.show
611
    return 'notinroster'
nkour's avatar
nkour committed
612

613
def get_full_jid_from_iq(iq_obj):
614
    """
Yann Leboulanger's avatar
Yann Leboulanger committed
615
    Return the full jid (with resource) from an iq
616
    """
Philipp Hörist's avatar
Philipp Hörist committed
617
618
619
    jid = iq_obj.getFrom()
    if jid is None:
        return None
Yann Leboulanger's avatar
Yann Leboulanger committed
620
    return parse_jid(str(iq_obj.getFrom()))
621
622

def get_jid_from_iq(iq_obj):
623
    """
Yann Leboulanger's avatar
Yann Leboulanger committed
624
    Return the jid (without resource) from an iq
625
626
    """
    jid = get_full_jid_from_iq(iq_obj)
627
    return app.get_jid_without_resource(jid)
628
629

def get_auth_sha(sid, initiator, target):
630
631
632
    """
    Return sha of sid + initiator + target used for proxy auth
    """
Yann Leboulanger's avatar
Yann Leboulanger committed
633
634
    return hashlib.sha1(("%s%s%s" % (sid, initiator, target)).encode('utf-8')).\
        hexdigest()
635

636
637
def remove_invalid_xml_chars(string_):
    if string_:
638
        string_ = re.sub(INVALID_XML_CHARS_REGEX, '', string_)
639
    return string_
640

Philipp Hörist's avatar
Philipp Hörist committed
641
def get_random_string(count=16):
642
    """
Philipp Hörist's avatar
Philipp Hörist committed
643
644
645
    Create random string of count length

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

Philipp Hörist's avatar
Philipp Hörist committed
650
@functools.lru_cache(maxsize=1)
651
def get_os_info():
Philipp Hörist's avatar
Philipp Hörist committed
652
653
654
655
656
    info = 'N/A'
    if sys.platform in ('win32', 'darwin'):
        info = f'{platform.system()} {platform.release()}'

    elif sys.platform == 'linux':
657
658
        try:
            import distro
Philipp Hörist's avatar
Philipp Hörist committed
659
            info = distro.name(pretty=True)
660
        except ImportError:
Philipp Hörist's avatar
Philipp Hörist committed
661
662
            info = platform.system()
    return info
663

664
def allow_showing_notification(account):
665
    if not app.settings.get('show_notifications'):
666
        return False
Philipp Hörist's avatar
Philipp Hörist committed
667
    if app.settings.get('autopopupaway'):
668
        return True
669
    if app.account_is_available(account):
670
671
        return True
    return False
672

673
def allow_popup_window(account):
674
675
676
    """
    Is it allowed to popup windows?
    """
Philipp Hörist's avatar
Philipp Hörist committed
677
678
    autopopup = app.settings.get('autopopup')
    autopopupaway = app.settings.get('autopopupaway')
679
    if autopopup and (autopopupaway or \
Philipp Hörist's avatar
Philipp Hörist committed
680
    app.connections[account].status in ('online', 'chat')):
681
682
        return True
    return False
683

684
def allow_sound_notification(account, sound_event):
Philipp Hörist's avatar
Philipp Hörist committed
685
    if (app.settings.get('sounddnd') or
Philipp Hörist's avatar
Philipp Hörist committed
686
            app.connections[account].status != 'dnd' and
687
            app.settings.get_soundevent_settings(sound_event)['enabled']):
688
689
        return True
    return False
690

691
def get_notification_icon_tooltip_dict():
692
693
694
695
    """
    Return a dict of the form {acct: {'show': show, 'message': message,
    'event_lines': [list of text lines to show in tooltip]}
    """
Philipp Hörist's avatar
Philipp Hörist committed
696
    # How many events before we show summarized, not per-user
697
698
699
700
701
702
703
704
705
    max_ungrouped_events = 10

    accounts = get_accounts_info()

    # Gather events. (With accounts, when there are more.)
    for account in accounts:
        account_name = account['name']
        account['event_lines'] = []
        # Gather events per-account
706
        pending_events = app.events.get_events(account=account_name)
Philipp Hörist's avatar
Philipp Hörist committed
707
708
        messages, non_messages = {}, {}
        total_messages, total_non_messages = 0, 0
709
710
711
712
713
714
715
716
717
718
719
720
721
        for jid in pending_events:
            for event in pending_events[jid]:
                if event.type_.count('file') > 0:
                    # This is a non-messagee event.
                    messages[jid] = non_messages.get(jid, 0) + 1
                    total_non_messages = total_non_messages + 1
                else:
                    # This is a message.
                    messages[jid] = messages.get(jid, 0) + 1
                    total_messages = total_messages + 1
        # Display unread messages numbers, if any
        if total_messages > 0:
            if total_messages > max_ungrouped_events:
Philipp Hörist's avatar
Philipp Hörist committed
722
723
724
725
726
                text = ngettext('%d message pending',
                                '%d messages pending',
                                total_messages,
                                total_messages,
                                total_messages)
727
728
                account['event_lines'].append(text)
            else:
729
                for jid in messages:
Philipp Hörist's avatar
Philipp Hörist committed
730
731
732
733
734
                    text = ngettext('%d message pending',
                                    '%d messages pending',
                                    messages[jid],
                                    messages[jid],
                                    messages[jid])
735
                    contact = app.contacts.get_first_contact_from_jid(
Philipp Hörist's avatar
Philipp Hörist committed
736
                        account['name'], jid)
Yann Leboulanger's avatar
Yann Leboulanger committed
737
                    text += ' '
738
                    if jid in app.gc_connected[account['name']]:
739
                        text += _('from group chat %s') % (jid)
740
741
                    elif contact:
                        name = contact.get_shown_name()
Yann Leboulanger's avatar
Yann Leboulanger committed
742
                        text += _('from user %s') % (name)
743
                    else:
Yann Leboulanger's avatar
Yann Leboulanger committed
744
                        text += _('from %s') % (jid)
745
746
747
748
749
                    account['event_lines'].append(text)

        # Display unseen events numbers, if any
        if total_non_messages > 0:
            if total_non_messages > max_ungrouped_events:
Philipp Hörist's avatar
Philipp Hörist committed
750
751
752
753
754
                text = ngettext('%d event pending',
                                '%d events pending',
                                total_non_messages,
                                total_non_messages,
                                total_non_messages)
755
756
                account['event_lines'].append(text)
            else:
757
                for jid in non_messages:
Philipp Hörist's avatar
Philipp Hörist committed
758
759
760
761
762
                    text = ngettext('%d event pending',
                                    '%d events pending',
                                    non_messages[jid],
                                    non_messages[jid],
                                    non_messages[jid])
Yann Leboulanger's avatar
Yann Leboulanger committed
763
                    text += ' ' + _('from user %s') % (jid)
764
765
766
                    account[account]['event_lines'].append(text)

    return accounts
767

nkour's avatar
nkour committed
768
def get_accounts_info():
769
770
771
772
    """
    Helper for notification icon tooltip
    """
    accounts = []
773
    accounts_list = sorted(app.contacts.get_accounts())
774
    for account in accounts_list:
775
776

        status = get_connection_status(account)
Philipp Hörist's avatar
Philipp Hörist committed
777
        message = app.connections[account].status_message
778
779
780
781
782
783
784
        single_line = get_uf_show(status)
        if message is None:
            message = ''
        else:
            message = message.strip()
        if message != '':
            single_line += ': ' + message
785
        account_label = app.get_account_label(account)
786
        accounts.append({'name': account,
787
                         'account_label': account_label,
788
789
790
                         'status_line': single_line,
                         'show': status,
                         'message': message})
791
    return accounts
792

793
794
795
def get_current_show(account):
    if account not in app.connections:
        return 'offline'
Philipp Hörist's avatar
Philipp Hörist committed
796
    return app.connections[account].status
steve-e's avatar
steve-e committed
797

798
799
def get_optional_features(account):
    features = []
800
801

    if app.settings.get_account_setting(account, 'request_user_data'):
Philipp Hörist's avatar
Philipp Hörist committed
802
803
804
        features.append(Namespace.MOOD + '+notify')
        features.append(Namespace.ACTIVITY + '+notify')
        features.append(Namespace.TUNE + '+notify')
Philipp Hörist's avatar
Philipp Hörist committed
805
        features.append(Namespace.LOCATION + '+notify')
806
807
808

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

809
810
811
    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
812
        features.append(Namespace.BOOKMARKS + '+notify')
813
    if app.is_installed('AV'):
Philipp Hörist's avatar
Philipp Hörist committed
814
815
816
817
        features.append(Namespace.JINGLE_RTP)
        features.append(Namespace.JINGLE_RTP_AUDIO)
        features.append(Namespace.JINGLE_RTP_VIDEO)
        features.append(Namespace.JINGLE_ICE_UDP)
818
819
820
821

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

823
def jid_is_blocked(account, jid):
824
    con = app.connections[account]
Philipp Hörist's avatar
Philipp Hörist committed
825
    return jid in con.get_module('Blocking').blocked
826

827
def get_subscription_request_msg(account=None):
828
    s = app.settings.get_account_setting(account, 'subscription_request_msg')
829
830
831
832
833
    if s:
        return s
    s = _('I would like to add you to my contact list.')
    if account:
        s = _('Hello, I am $name.') + ' ' + s
834
        s = Template(s).safe_substitute({'name': app.nicks[account]})
835
        return s
836

837
def get_user_proxy(account):
838
    proxy_name = app.settings.get_account_setting(account, 'proxy')
839
840
    if not proxy_name:
        return None
841
    return get_proxy(proxy_name)
842

843
def get_proxy(proxy_name):
844
845
846
    try:
        settings = app.settings.get_proxy_settings(proxy_name)
    except ValueError:
847
848
849
        return None

    username, password = None, None
850
851
    if settings['useauth']:
        username, password = settings['user'], settings['pass']
852

853
854
    return ProxyData(type=settings['type'],
                     host='%s:%s' % (settings['host'], settings['port']),
855
856
857
                     username=username,
                     password=password)

Philipp Hörist's avatar
Philipp Hörist committed
858
859
860
861
def version_condition(current_version, required_version):
    if V(current_version) < V(required_version):
        return False
    return True
862
863

def get_available_emoticon_themes():
Philipp Hörist's avatar
Philipp Hörist committed
864
    files = []
André's avatar
André committed
865
    for folder in configpaths.get('EMOTICONS').iterdir():
866
867
        if not folder.is_dir():
            continue
André's avatar
André committed
868
869
870
871
872
873
874
875
876
        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)
877

Philipp Hörist's avatar
Philipp Hörist committed
878
879
880
def call_counter(func):
    def helper(self, restart=False):
        if restart:
Philipp Hörist's avatar
Philipp Hörist committed
881
882
            self._connect_machine_calls = 0
        self._connect_machine_calls += 1
Philipp Hörist's avatar
Philipp Hörist committed
883
        return func(self)
Philipp Hörist's avatar
Philipp Hörist committed
884
    return helper
885

886
887
def load_json(path, key=None, default=None):
    try:
888
        with path.open('r') as file:
889
890
891
892
893
894
895
896
            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)
897

Philipp Hörist's avatar
Philipp Hörist committed
898
899
900
def ignore_contact(account, jid):
    jid = str(jid)
    known_contact = app.contacts.get_contacts(account, jid)
901
902
    ignore = app.settings.get_account_setting(account,
                                              'ignore_unknown_contacts')
Philipp Hörist's avatar
Philipp Hörist committed
903
904
905
906
907
    if ignore and not known_contact:
        log.info('Ignore unknown contact %s', jid)
        return True
    return False

908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
class AdditionalDataDict(collections.UserDict):
    def __init__(self, initialdata=None):
        collections.UserDict.__init__(self, initialdata)

    @staticmethod
    def _get_path_childs(full_path):
        path_childs = [full_path]
        if ':' in full_path:
            path_childs = full_path.split(':')
        return path_childs

    def set_value(self, full_path, key, value):
        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

    def get_value(self, full_path, key, default=None):
        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

    def remove_value(self, full_path, key):
        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
955

Philipp Hörist's avatar
Philipp Hörist committed
956
957
958
    def copy(self):
        return copy.deepcopy(self)

959
960

def save_roster_position(window):
Philipp Hörist's avatar
Philipp Hörist committed
961
    if not app.settings.get('save-roster-position'):
962
963
964
965
966
        return
    if app.is_display(Display.WAYLAND):
        return
    x_pos, y_pos = window.get_position()