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('&', '&')
592 message = message.replace('<', '<')
593 message = message.replace('>', '>')
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', '')