all repos — dotfiles @ 96310581d94f5a292753c5e3e090451ce36109bc

my *nix dotfiles

weechat/.weechat/python/notify_send.py (view raw)

  1# -*- coding: utf-8 -*-
  2#
  3# Project:     weechat-notify-send
  4# Homepage:    https://github.com/s3rvac/weechat-notify-send
  5# Description: Sends highlight and message notifications through notify-send.
  6#              Requires libnotify.
  7# License:     MIT (see below)
  8#
  9# Copyright (c) 2015-2017 by Petr Zemek <s3rvac@gmail.com> and contributors
 10#
 11# Permission is hereby granted, free of charge, to any person obtaining a copy
 12# of this software and associated documentation files (the "Software"), to deal
 13# in the Software without restriction, including without limitation the rights
 14# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 15# copies of the Software, and to permit persons to whom the Software is
 16# furnished to do so, subject to the following conditions:
 17#
 18# The above copyright notice and this permission notice shall be included in
 19# all copies or substantial portions of the Software.
 20#
 21# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 22# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 23# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 24# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 25# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 26# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 27# SOFTWARE.
 28#
 29
 30import os
 31import subprocess
 32import sys
 33import time
 34
 35
 36# Ensure that we are running under WeeChat.
 37try:
 38    import weechat
 39except ImportError:
 40    print('This script has to run under WeeChat (https://weechat.org/).')
 41    sys.exit(1)
 42
 43
 44# Name of the script.
 45SCRIPT_NAME = 'notify_send'
 46
 47# Author of the script.
 48SCRIPT_AUTHOR = 's3rvac'
 49
 50# Version of the script.
 51SCRIPT_VERSION = '0.8 (dev)'
 52
 53# License under which the script is distributed.
 54SCRIPT_LICENSE = 'MIT'
 55
 56# Description of the script.
 57SCRIPT_DESC = 'Sends highlight and message notifications through notify-send.'
 58
 59# Name of a function to be called when the script is unloaded.
 60SCRIPT_SHUTDOWN_FUNC = ''
 61
 62# Used character set (utf-8 by default).
 63SCRIPT_CHARSET = ''
 64
 65# Script options.
 66OPTIONS = {
 67    'notify_on_highlights': (
 68        'on',
 69        'Send notifications on highlights.'
 70    ),
 71    'notify_on_privmsgs': (
 72        'on',
 73        'Send notifications on private messages.'
 74    ),
 75    'notify_on_filtered_messages': (
 76        'off',
 77        'Send notifications also on filtered (hidden) messages.'
 78    ),
 79    'notify_when_away': (
 80        'on',
 81        'Send also notifications when away.'
 82    ),
 83    'notify_for_current_buffer': (
 84        'on',
 85        'Send also notifications for the currently active buffer.'
 86    ),
 87    'notify_on_all_messages_in_buffers': (
 88        '',
 89        'A comma-separated list of buffers for which you want to receive '
 90        'notifications on all messages that appear in them.'
 91    ),
 92    'min_notification_delay': (
 93        '500',
 94        'A minimal delay between successive notifications from the same '
 95        'buffer (in milliseconds; set to 0 to show all notifications).'
 96    ),
 97    'ignore_messages_tagged_with': (
 98        # irc_join:      Joined IRC
 99        # irc_quit:      Quit IRC
100        # irc_part:      Parted a channel
101        # irc_status:    Status messages
102        # irc_nick_back: A nick is back on server
103        # irc_401:       No such nick/channel
104        # irc_402:       No such server
105        'irc_join,irc_quit,irc_part,irc_status,irc_nick_back,irc_401,irc_402',
106        'A comma-separated list of message tags for which no notifications '
107        'should be shown.'
108    ),
109    'ignore_buffers': (
110        '',
111        'A comma-separated list of buffers from which no notifications should '
112        'be shown.'
113    ),
114    'ignore_buffers_starting_with': (
115        '',
116        'A comma-separated list of buffer prefixes from which no '
117        'notifications should be shown.'
118    ),
119    'ignore_nicks': (
120        '',
121        'A comma-separated list of nicks from which no notifications should '
122        'be shown.'
123    ),
124    'ignore_nicks_starting_with': (
125        '',
126        'A comma-separated list of nick prefixes from which no '
127        'notifications should be shown.'
128    ),
129    'nick_separator': (
130        ': ',
131        'A separator between a nick and a message.'
132    ),
133    'escape_html': (
134        'on',
135        "Escapes the '<', '>', and '&' characters in notification messages."
136    ),
137    'max_length': (
138        '72',
139        'Maximal length of a notification (0 means no limit).'
140    ),
141    'ellipsis': (
142        '[..]',
143        'Ellipsis to be used for notifications that are too long.'
144    ),
145    'icon': (
146        '/usr/share/icons/hicolor/32x32/apps/weechat.png',
147        'Path to an icon to be shown in notifications.'
148    ),
149    'timeout': (
150        '5000',
151        'Time after which the notification disappears (in milliseconds; '
152        'set to 0 to disable).'
153    ),
154    'transient': (
155        'on',
156        'When a notification expires or is dismissed, remove it from the '
157        'notification bar.'
158    ),
159    'urgency': (
160        'normal',
161        'Urgency (low, normal, critical).'
162    )
163}
164
165
166class Notification(object):
167    """A representation of a notification."""
168
169    def __init__(self, source, message, icon, timeout, transient, urgency):
170        self.source = source
171        self.message = message
172        self.icon = icon
173        self.timeout = timeout
174        self.transient = transient
175        self.urgency = urgency
176
177
178def default_value_of(option):
179    """Returns the default value of the given option."""
180    return OPTIONS[option][0]
181
182
183def add_default_value_to(description, default_value):
184    """Adds the given default value to the given option description."""
185    # All descriptions end with a period, so do not add another period.
186    return '{} Default: {}.'.format(
187        description,
188        default_value if default_value else '""'
189    )
190
191
192def nick_that_sent_message(tags, prefix):
193    """Returns a nick that sent the message based on the given data passed to
194    the callback.
195    """
196    # 'tags' is a comma-separated list of tags that WeeChat passed to the
197    # callback. It should contain a tag of the following form: nick_XYZ, where
198    # XYZ is the nick that sent the message.
199    for tag in tags:
200        if tag.startswith('nick_'):
201            return tag[5:]
202
203    # There is no nick in the tags, so check the prefix as a fallback.
204    # 'prefix' (str) is the prefix of the printed line with the message.
205    # Usually (but not always), it is a nick with an optional mode (e.g. on
206    # IRC, @ denotes an operator and + denotes a user with voice). We have to
207    # remove the mode (if any) before returning the nick.
208    # Strip also a space as some protocols (e.g. Matrix) may start prefixes
209    # with a space. It probably means that the nick has no mode set.
210    if prefix.startswith(('~', '&', '@', '%', '+', '-', ' ')):
211        return prefix[1:]
212
213    return prefix
214
215
216def parse_tags(tags):
217    """Parses the given "list" of tags (str) from WeeChat into a list."""
218    return tags.split(',')
219
220
221def message_printed_callback(data, buffer, date, tags, is_displayed,
222                             is_highlight, prefix, message):
223    """A callback when a message is printed."""
224    is_displayed = int(is_displayed)
225    is_highlight = int(is_highlight)
226    tags = parse_tags(tags)
227    nick = nick_that_sent_message(tags, prefix)
228
229    if notification_should_be_sent(buffer, tags, nick, is_displayed, is_highlight):
230        notification = prepare_notification(buffer, nick, message)
231        send_notification(notification)
232
233    return weechat.WEECHAT_RC_OK
234
235
236def notification_should_be_sent(buffer, tags, nick, is_displayed, is_highlight):
237    """Should a notification be sent?"""
238    if notification_should_be_sent_disregarding_time(buffer, tags, nick,
239                                                     is_displayed, is_highlight):
240        # The following function should be called only when the notification
241        # should be sent (it updates the last notification time).
242        if not is_below_min_notification_delay(buffer):
243            return True
244    return False
245
246
247def notification_should_be_sent_disregarding_time(buffer, tags, nick,
248                                                  is_displayed, is_highlight):
249    """Should a notification be sent when not considering time?"""
250    if not nick:
251        # A nick is required to form a correct notification source/message.
252        return False
253
254    if i_am_author_of_message(buffer, nick):
255        return False
256
257    if not is_displayed:
258        if not notify_on_filtered_messages():
259            return False
260
261    if buffer == weechat.current_buffer():
262        if not notify_for_current_buffer():
263            return False
264
265    if is_away(buffer):
266        if not notify_when_away():
267            return False
268
269    if ignore_notifications_from_messages_tagged_with(tags):
270        return False
271
272    if ignore_notifications_from_nick(nick):
273        return False
274
275    if ignore_notifications_from_buffer(buffer):
276        return False
277
278    if is_private_message(buffer):
279        return notify_on_private_messages()
280
281    if is_highlight:
282        return notify_on_highlights()
283
284    if notify_on_all_messages_in_buffer(buffer):
285        return True
286
287    return False
288
289
290def is_below_min_notification_delay(buffer):
291    """Is a notification in the given buffer below the minimal delay between
292    successive notifications from the same buffer?
293
294    When called, this function updates the time of the last notification.
295    """
296    # We store the time of the last notification in a buffer-local variable to
297    # make it persistent over the lifetime of this script.
298    LAST_NOTIFICATION_TIME_VAR = 'notify_send_last_notification_time'
299    last_notification_time = buffer_get_float(
300        buffer,
301        'localvar_' + LAST_NOTIFICATION_TIME_VAR
302    )
303
304    min_notification_delay = weechat.config_get_plugin('min_notification_delay')
305    # min_notification_delay is in milliseconds (str). To compare it with
306    # last_notification_time (float in seconds), we have to convert it to
307    # seconds (float).
308    min_notification_delay = float(min_notification_delay) / 1000
309
310    current_time = time.time()
311
312    # We have to update the last notification time before returning the result.
313    buffer_set_float(
314        buffer,
315        'localvar_set_' + LAST_NOTIFICATION_TIME_VAR,
316        current_time
317    )
318
319    return (min_notification_delay > 0 and
320            current_time - last_notification_time < min_notification_delay)
321
322
323def buffer_get_float(buffer, property):
324    """A variant of weechat.buffer_get_x() for floats.
325
326    This variant is needed because WeeChat supports only buffer_get_string()
327    and buffer_get_int().
328    """
329    value = weechat.buffer_get_string(buffer, property)
330    return float(value) if value else 0.0
331
332
333def buffer_set_float(buffer, property, value):
334    """A variant of weechat.buffer_set() for floats.
335
336    This variant is needed because WeeChat supports only integers and strings.
337    """
338    weechat.buffer_set(buffer, property, str(value))
339
340
341def names_for_buffer(buffer):
342    """Returns a list of all names for the given buffer."""
343    # The 'buffer' parameter passed to our callback is actually the buffer's ID
344    # (e.g. '0x2719cf0'). We have to check its name (e.g. 'freenode.#weechat')
345    # and short name (e.g. '#weechat') because these are what users specify in
346    # their configs.
347    buffer_names = []
348
349    full_name = weechat.buffer_get_string(buffer, 'name')
350    if full_name:
351        buffer_names.append(full_name)
352
353    short_name = weechat.buffer_get_string(buffer, 'short_name')
354    if short_name:
355        buffer_names.append(short_name)
356        # Consider >channel and #channel to be equal buffer names. The reason
357        # is that the https://github.com/rawdigits/wee-slack script replaces
358        # '#' with '>' to indicate that someone in the buffer is typing. This
359        # fixes the behavior of several configuration options (e.g.
360        # 'notify_on_all_messages_in_buffers') when weechat_notify_send is used
361        # together with the wee_slack script.
362        #
363        # Note that this is only needed to be done for the short name. Indeed,
364        # the full name always stays unchanged.
365        if short_name.startswith('>'):
366            buffer_names.append('#' + short_name[1:])
367
368    return buffer_names
369
370
371def notify_for_current_buffer():
372    """Should we also send notifications for the current buffer?"""
373    return weechat.config_get_plugin('notify_for_current_buffer') == 'on'
374
375
376def notify_on_highlights():
377    """Should we send notifications on highlights?"""
378    return weechat.config_get_plugin('notify_on_highlights') == 'on'
379
380
381def notify_on_private_messages():
382    """Should we send notifications on private messages?"""
383    return weechat.config_get_plugin('notify_on_privmsgs') == 'on'
384
385
386def notify_on_filtered_messages():
387    """Should we also send notifications for filtered (hidden) messages?"""
388    return weechat.config_get_plugin('notify_on_filtered_messages') == 'on'
389
390
391def notify_when_away():
392    """Should we also send notifications when away?"""
393    return weechat.config_get_plugin('notify_when_away') == 'on'
394
395
396def is_away(buffer):
397    """Is the user away?"""
398    return weechat.buffer_get_string(buffer, 'localvar_away') != ''
399
400
401def is_private_message(buffer):
402    """Has a private message been sent?"""
403    return weechat.buffer_get_string(buffer, 'localvar_type') == 'private'
404
405
406def i_am_author_of_message(buffer, nick):
407    """Am I (the current WeeChat user) the author of the message?"""
408    return weechat.buffer_get_string(buffer, 'localvar_nick') == nick
409
410
411def split_option_value(option, separator=','):
412    """Splits the value of the given plugin option by the given separator and
413    returns the result in a list.
414    """
415    values = weechat.config_get_plugin(option)
416    return [value.strip() for value in values.split(separator)]
417
418
419def ignore_notifications_from_messages_tagged_with(tags):
420    """Should notifications be ignored for a message tagged with the given
421    tags?
422    """
423    ignored_tags = split_option_value('ignore_messages_tagged_with')
424    for ignored_tag in ignored_tags:
425        for tag in tags:
426            if tag == ignored_tag:
427                return True
428    return False
429
430
431def ignore_notifications_from_buffer(buffer):
432    """Should notifications from the given buffer be ignored?"""
433    buffer_names = names_for_buffer(buffer)
434
435    for buffer_name in buffer_names:
436        if buffer_name and buffer_name in ignored_buffers():
437            return True
438
439    for buffer_name in buffer_names:
440        for prefix in ignored_buffer_prefixes():
441            if prefix and buffer_name and buffer_name.startswith(prefix):
442                return True
443
444    return False
445
446
447def ignored_buffers():
448    """A generator of buffers from which notifications should be ignored."""
449    for buffer in split_option_value('ignore_buffers'):
450        yield buffer
451
452
453def ignored_buffer_prefixes():
454    """A generator of buffer prefixes from which notifications should be
455    ignored.
456    """
457    for prefix in split_option_value('ignore_buffers_starting_with'):
458        yield prefix
459
460
461def ignore_notifications_from_nick(nick):
462    """Should notifications from the given nick be ignored?"""
463    if nick in ignored_nicks():
464        return True
465
466    for prefix in ignored_nick_prefixes():
467        if prefix and nick.startswith(prefix):
468            return True
469
470    return False
471
472
473def ignored_nicks():
474    """A generator of nicks from which notifications should be ignored."""
475    for nick in split_option_value('ignore_nicks'):
476        yield nick
477
478
479def ignored_nick_prefixes():
480    """A generator of nick prefixes from which notifications should be
481    ignored.
482    """
483    for prefix in split_option_value('ignore_nicks_starting_with'):
484        yield prefix
485
486
487def buffers_to_notify_on_all_messages():
488    """A generator of buffer names in which the user wants to be notified for
489    all messages.
490    """
491    for buffer in split_option_value('notify_on_all_messages_in_buffers'):
492        yield buffer
493
494
495def notify_on_all_messages_in_buffer(buffer):
496    """Does the user want to be notified for all messages in the given buffer?
497    """
498    buffer_names = names_for_buffer(buffer)
499    for buf in buffers_to_notify_on_all_messages():
500        if buf in buffer_names:
501            return True
502    return False
503
504
505def prepare_notification(buffer, nick, message):
506    """Prepares a notification from the given data."""
507    if is_private_message(buffer):
508        source = nick
509    else:
510        source = (weechat.buffer_get_string(buffer, 'short_name') or
511                  weechat.buffer_get_string(buffer, 'name'))
512        message = nick + nick_separator() + message
513
514    max_length = int(weechat.config_get_plugin('max_length'))
515    if max_length > 0:
516        ellipsis = weechat.config_get_plugin('ellipsis')
517        message = shorten_message(message, max_length, ellipsis)
518
519    if weechat.config_get_plugin('escape_html') == 'on':
520        message = escape_html(message)
521
522    message = escape_slashes(message)
523
524    icon = weechat.config_get_plugin('icon')
525    timeout = weechat.config_get_plugin('timeout')
526    transient = should_notifications_be_transient()
527    urgency = weechat.config_get_plugin('urgency')
528
529    return Notification(source, message, icon, timeout, transient, urgency)
530
531
532def should_notifications_be_transient():
533    """Should the sent notifications be transient, i.e. should they be removed
534    from the notification bar once they expire or are dismissed?
535    """
536    return weechat.config_get_plugin('transient') == 'on'
537
538
539def nick_separator():
540    """Returns a nick separator to be used."""
541    separator = weechat.config_get_plugin('nick_separator')
542    return separator if separator else default_value_of('nick_separator')
543
544
545def shorten_message(message, max_length, ellipsis):
546    """Shortens the message to at most max_length characters by using the given
547    ellipsis.
548    """
549    # In Python 2, we need to decode the message and ellipsis into Unicode to
550    # correctly (1) detect their length and (2) shorten the message. Failing to
551    # do that could make the shortened message invalid and cause notify-send to
552    # fail. For example, when we have bytes, we cannot guarantee that we do not
553    # split the message inside of a multibyte character.
554    if sys.version_info.major == 2:
555        try:
556            message = message.decode('utf-8')
557            ellipsis = ellipsis.decode('utf-8')
558        except UnicodeDecodeError:
559            # Either (or both) of the two cannot be decoded. Continue in a
560            # best-effort manner.
561            pass
562
563    message = shorten_unicode_message(message, max_length, ellipsis)
564
565    if sys.version_info.major == 2:
566        if not isinstance(message, str):
567            message = message.encode('utf-8')
568
569    return message
570
571
572def shorten_unicode_message(message, max_length, ellipsis):
573    """An internal specialized version of shorten_message() when the both the
574    message and ellipsis are str (in Python 3) or unicode (in Python 2).
575    """
576    if max_length <= 0 or len(message) <= max_length:
577        # Nothing to shorten.
578        return message
579
580    if len(ellipsis) >= max_length:
581        # We cannot include any part of the message.
582        return ellipsis[:max_length]
583
584    return message[:max_length - len(ellipsis)] + ellipsis
585
586
587def escape_html(message):
588    """Escapes HTML characters in the given message."""
589    # Only the following characters need to be escaped
590    # (https://wiki.ubuntu.com/NotificationDevelopmentGuidelines).
591    message = message.replace('&', '&amp;')
592    message = message.replace('<', '&lt;')
593    message = message.replace('>', '&gt;')
594    return message
595
596
597def escape_slashes(message):
598    """Escapes slashes in the given message."""
599    # We need to escape backslashes to prevent notify-send from interpreting
600    # them, e.g. we do not want to print a newline when the message contains
601    # '\n'.
602    return message.replace('\\', r'\\')
603
604
605def send_notification(notification):
606    """Sends the given notification to the user."""
607    notify_cmd = ['notify-send', '--app-name', 'weechat']
608    if notification.icon:
609        notify_cmd += ['--icon', notification.icon]
610    if notification.timeout:
611        notify_cmd += ['--expire-time', str(notification.timeout)]
612    if notification.transient:
613        notify_cmd += ['--hint', 'int:transient:1']
614    if notification.urgency:
615        notify_cmd += ['--urgency', notification.urgency]
616    # We need to add '--' before the source and message to ensure that
617    # notify-send considers the remaining parameters as the source and the
618    # message. This prevents errors when a source or message starts with '--'.
619    notify_cmd += [
620        '--',
621        # notify-send fails with "No summary specified." when no source is
622        # specified, so ensure that there is always a non-empty source.
623        notification.source or '-',
624        notification.message
625    ]
626
627    # Prevent notify-send from messing up the WeeChat screen when occasionally
628    # emitting assertion messages by redirecting the output to /dev/null (you
629    # would need to run /redraw to fix the screen).
630    # In Python < 3.3, there is no subprocess.DEVNULL, so we have to use a
631    # workaround.
632    with open(os.devnull, 'wb') as devnull:
633        subprocess.check_call(
634            notify_cmd,
635            stderr=subprocess.STDOUT,
636            stdout=devnull,
637        )
638
639
640if __name__ == '__main__':
641    # Registration.
642    weechat.register(
643        SCRIPT_NAME,
644        SCRIPT_AUTHOR,
645        SCRIPT_VERSION,
646        SCRIPT_LICENSE,
647        SCRIPT_DESC,
648        SCRIPT_SHUTDOWN_FUNC,
649        SCRIPT_CHARSET
650    )
651
652    # Initialization.
653    for option, (default_value, description) in OPTIONS.items():
654        description = add_default_value_to(description, default_value)
655        weechat.config_set_desc_plugin(option, description)
656        if not weechat.config_is_set_plugin(option):
657            weechat.config_set_plugin(option, default_value)
658
659    # Catch all messages on all buffers and strip colors from them before
660    # passing them into the callback.
661    weechat.hook_print('', '', '', 1, 'message_printed_callback', '')