From 2c70acff52d6b900b86869af0e803bcaf0109fef Mon Sep 17 00:00:00 2001 From: ryoskzypu Date: Wed, 7 May 2025 23:35:25 +0000 Subject: [PATCH] colorize_nicks.py 33: add many improvements, features, and fixes Changes: - Remove the VALID_NICK regex to not be dependent on RFC spec and change the loop to split only on spaces, so the nick regex is free to match any suffix. - Add nick_prefixes and nick_suffixes options to configure charset affixes. - Replace the default 'greedy' matching with improved 'lazy' matching, because 'greedy' matches nicks incorrectly and crashes weechat. Thus remove ignore_nicks_in_urls option, since 'lazy' matching does not match nicks in substrings. closes #133 and #197. Rationale: - The greedy matching code recolors duplicated strings because of the way replace() works, so it crashes weechat on multiple duplicated nicks. - #259 does not really prevent that, since it can still be reproduced by mentioning a short nick e.g. F1, repeatedly in a message. - It can be fixed by iterating on unique words, but it still colorizes nicks in substrings if a nick is mentioned before (e.g. alice https://www.alice.com), hence the option ignore_nicks_in_urls fails. - Replace hook_modifier() with hook_line() for a more granular parsing (e.g. prefix is separated from message, get filtered lines, etc). closes #70, closes #135. - Add preserving of message colors logic. closes #49, closes #175. - Add irc_only option to ignore non IRC messages (i.e. set buffer restrictions: plugin = irc, tags = irc_privmsg and irc_notice, type = channel and private). - Add do not colorize nicks in filtered messages, and colorize_filtered option. - Add colorizing of IRC prefixes and nicks on IRC private buffers. - Update colorized_input_cb() with the changes, and add decoding of IRC colors from input. - Update config_init() parsing of options. - Remove 'so,root' from blacklist_nicks default option. (These nicks do not make sense for defaults.) - Change min_nick_length default option to 1. (Single char nicks should not be excluded by default.) - Remove hook_modifier() that has the modifier colorize_nicks. (Is it possible to pass custom modifiers?) - Replace % formatting strings with f-strings, " with ', and some code to use the := operator. - Update identifiers, comments, and add some whitespace in code for readability. - Remove unused global keywords and add error handlings. - Remove utf-8 encoding header, add SPDX copyright + license tags, and add upstream link. --- python/colorize_nicks.py | 1029 ++++++++++++++++++++++++++++---------- 1 file changed, 774 insertions(+), 255 deletions(-) diff --git a/python/colorize_nicks.py b/python/colorize_nicks.py index bab12821..d441420e 100644 --- a/python/colorize_nicks.py +++ b/python/colorize_nicks.py @@ -1,6 +1,7 @@ -# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: 2010 xt +# SPDX-FileCopyrightText: 2025 ryoskzypu # -# Copyright (c) 2010 by xt +# SPDX-License-Identifier: GPL-3.0-or-later # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -20,7 +21,12 @@ # not just in the prefix section. # # +# Bugs: +# https://github.com/ryoskzypu/weechat_scripts +# # History: +# 2025-05-08: ryoskzypu +# version 33: add many improvements, features, and fixes # 2023-10-30: Sébastien Helleu # version 32: revert to info "nick_color" with WeeChat >= 4.1.1 # 2023-10-16: Sébastien Helleu @@ -90,335 +96,848 @@ # version 0.2: use ignore_channels when populating to increase performance. # 2010-02-03, xt # version 0.1: initial (based on ruby script by dominikh) -# -# Known issues: nicks will not get colorized if they begin with a character -# such as ~ (which some irc networks do happen to accept) import weechat import re -w = weechat -SCRIPT_NAME = "colorize_nicks" -SCRIPT_AUTHOR = "xt " -SCRIPT_VERSION = "32" -SCRIPT_LICENSE = "GPL" -SCRIPT_DESC = "Use the weechat nick colors in the chat area" +# Debug data structures. +#from pprint import PrettyPrinter +#pp = PrettyPrinter(indent=4) -# Based on the recommendations in RFC 7613. A valid nick is composed -# of anything but " ,*?.!@". -VALID_NICK = r'([@~&!%+-])?([^\s,\*?\.!@:,]+)' -valid_nick_re = re.compile(VALID_NICK) -ignore_channels = [] -ignore_nicks = [] +w = weechat -# Dict with every nick on every channel with its color as lookup value -colored_nicks = {} +SCRIPT_NAME = 'colorize_nicks' +SCRIPT_AUTHOR = 'xt ' +SCRIPT_VERSION = '33' +SCRIPT_LICENSE = 'GPL' +SCRIPT_DESC = 'Use the weechat nick colors in the chat area' -CONFIG_FILE_NAME = "colorize_nicks" +# Config file/options +config_file = '' # Pointer +config_option = {} +ignore_channels = [] # ignore_channels +ignore_nicks = [] # ignore_nicks -# config file and options -colorize_config_file = "" -colorize_config_option = {} +# Dict with every nick on every channel, with its color and prefix as lookup values. +colored_nicks = {} -def colorize_config_init(): +# Regexes + +colors_rgx = r''' + \031 + (?: + \d{2} # Fixed 'weechat.color.chat.*' codes + | + (?: # Foreground + [F*] + [*!\/_%.|]? # IRC colors (00–15) + \d{2} + | + (?: F@ | \*@) # IRC colors (16–99) and WeeChat colors (16–255) + [*!\/_%.|]? + \d{5} + ) + (?: # Background + ~ + (?: \d{2} | @\d{5}) + )? + ) + ''' +attr_rgx = r''' + (?: \032 | \033) + [\001-\006] + | + \031\034 # Reset color and keep attributes + ''' +reset_rgx = r'\034' +split_rgx = rf''' + ({colors_rgx}) # Colors + | + ({attr_rgx}) # Attributes + | + ({reset_rgx}) # Reset all + | + # Chars + ''' +has_colors_rgx = rf'{colors_rgx} | {attr_rgx}' +is_color_rgx = rf'\A(?: {has_colors_rgx})\Z' +exact_color_rgx = rf'\A{colors_rgx}\Z' + +# Dict of regexes to compile. +regex = { + 'colors': colors_rgx, + 'attr': attr_rgx, + 'reset': reset_rgx, + 'split': split_rgx, + 'has_colors': has_colors_rgx, + 'is_color': is_color_rgx, + 'exact_color': exact_color_rgx, +} + +# Reset color code +reset = w.color('reset') + +# Space hex code +space = '\x20' + +# Unique escape codes +uniq_esc_nick = '\36' # Nick +uniq_esc_pref = '\37' # Prefix + +def config_init(): ''' Initialization of configuration file. Sections: look. ''' - global colorize_config_file, colorize_config_option - colorize_config_file = weechat.config_new(CONFIG_FILE_NAME, - "", "") - if colorize_config_file == "": - return - - # section "look" - section_look = weechat.config_new_section( - colorize_config_file, "look", 0, 0, "", "", "", "", "", "", "", "", "", "") - if section_look == "": - weechat.config_free(colorize_config_file) - return - colorize_config_option["blacklist_channels"] = weechat.config_new_option( - colorize_config_file, section_look, "blacklist_channels", - "string", "Comma separated list of channels", "", 0, 0, - "", "", 0, "", "", "", "", "", "") - colorize_config_option["blacklist_nicks"] = weechat.config_new_option( - colorize_config_file, section_look, "blacklist_nicks", - "string", "Comma separated list of nicks", "", 0, 0, - "so,root", "so,root", 0, "", "", "", "", "", "") - colorize_config_option["min_nick_length"] = weechat.config_new_option( - colorize_config_file, section_look, "min_nick_length", - "integer", "Minimum length nick to colorize", "", - 1, 20, "2", "2", 0, "", "", "", "", "", "") - colorize_config_option["colorize_input"] = weechat.config_new_option( - colorize_config_file, section_look, "colorize_input", - "boolean", "Whether to colorize input", "", 0, - 0, "off", "off", 0, "", "", "", "", "", "") - colorize_config_option["ignore_tags"] = weechat.config_new_option( - colorize_config_file, section_look, "ignore_tags", - "string", "Comma separated list of tags to ignore; i.e. irc_join,irc_part,irc_quit", "", 0, 0, - "", "", 0, "", "", "", "", "", "") - colorize_config_option["greedy_matching"] = weechat.config_new_option( - colorize_config_file, section_look, "greedy_matching", - "boolean", "If off, then use lazy matching instead", "", 0, - 0, "on", "on", 0, "", "", "", "", "", "") - colorize_config_option["match_limit"] = weechat.config_new_option( - colorize_config_file, section_look, "match_limit", - "integer", "Fall back to lazy matching if greedy matches exceeds this number", "", - 20, 1000, "", "", 0, "", "", "", "", "", "") - colorize_config_option["ignore_nicks_in_urls"] = weechat.config_new_option( - colorize_config_file, section_look, "ignore_nicks_in_urls", - "boolean", "If on, don't colorize nicks inside URLs", "", 0, - 0, "off", "off", 0, "", "", "", "", "", "") - -def colorize_config_read(): - ''' Read configuration file. ''' - global colorize_config_file - return weechat.config_read(colorize_config_file) - -def colorize_nick_color(buffer, nick, my_nick): - ''' Retrieve nick color from weechat. ''' + + global config_file + + # Create config. + if (config_file := w.config_new(SCRIPT_NAME, '', '')) == '': + return 'failed to create config file' + + # Create 'look' section. + if (section_look := w.config_new_section( + config_file, 'look', 0, 0, '', '', '', '', '', '', '', '', '', '')) == '': + w.config_free(config_file) + return 'failed to create look section' + + # Create 'look' options. + + opts = [ + { + 'option': 'ignore_channels', + 'opt_type': 'string', + 'desc': 'comma separated list of channels to ignore', + 'str_val': '', + 'min_val': 0, + 'max_val': 0, + 'default': '', + 'value': '', + 'null_val': 0, + 'check_val_cb': '', + }, + { + 'option': 'ignore_nicks', + 'opt_type': 'string', + 'desc': 'comma separated list of nicks to ignore', + 'str_val': '', + 'min_val': 0, + 'max_val': 0, + 'default': '', + 'value': '', + 'null_val': 0, + 'check_val_cb': '', + }, + { + 'option': 'colorize_filter', + 'opt_type': 'boolean', + 'desc': 'colorize nicks in filtered messages from /filter', + 'str_val': '', + 'min_val': 0, + 'max_val': 0, + 'default': 'off', + 'value': 'off', + 'null_val': 0, + 'check_val_cb': '', + }, + { + 'option': 'colorize_input', + 'opt_type': 'boolean', + 'desc': 'colorize nicks in input', + 'str_val': '', + 'min_val': 0, + 'max_val': 0, + 'default': 'off', + 'value': 'off', + 'null_val': 0, + 'check_val_cb': '', + }, + { + 'option': 'irc_only', + 'opt_type': 'boolean', + 'desc': 'ignore non IRC messages; i.e. set buffer restrictions: plugin = irc, tags = irc_privmsg and irc_notice, type = channel and private', + 'str_val': '', + 'min_val': 0, + 'max_val': 0, + 'default': 'off', + 'value': 'off', + 'null_val': 0, + 'check_val_cb': '', + }, + { + 'option': 'ignore_tags', + 'opt_type': 'string', + 'desc': 'comma separated list of tags to ignore; i.e. irc_join,irc_part,irc_quit', + 'str_val': '', + 'min_val': 0, + 'max_val': 0, + 'default': '', + 'value': '', + 'null_val': 0, + 'check_val_cb': '', + }, + { + 'option': 'min_nick_length', + 'opt_type': 'integer', + 'desc': 'minimum length of nicks to colorize', + 'str_val': '', + 'min_val': 1, + 'max_val': 20, + 'default': '1', + 'value': '1', + 'null_val': 0, + 'check_val_cb': '', + }, + { + 'option': 'nick_suffixes', + 'opt_type': 'string', + 'desc': 'character set of nick suffixes; matches only one out of several characters', + 'str_val': '', + 'min_val': 0, + 'max_val': 0, + 'default': ':,', + 'value': ':,', + 'null_val': 0, + 'check_val_cb': 'check_affix_cb', + }, + # Default charset is based on IRC channel membership prefixes. + { + 'option': 'nick_prefixes', + 'opt_type': 'string', + 'desc': 'character set of nick prefixes; matches only one out of several characters', + 'str_val': '', + 'min_val': 0, + 'max_val': 0, + 'default': '~&@%+', + 'value': '~&@%+', + 'null_val': 0, + 'check_val_cb': 'check_affix_cb', + } + ] + + if (rc := set_options(config_file, section_look, opts)): + return rc + +def set_options(config, section, options): + ''' Creates config file options of a section. ''' + + for i in options: + option = i['option'] + + config_option[option] = w.config_new_option( + config, + section, + option, + i['opt_type'], + i['desc'], + i['str_val'], + i['min_val'], + i['max_val'], + i['default'], + i['value'], + i['null_val'], + i['check_val_cb'], + '', '', '', '', '') + + if not config_option[option]: + return f"failed to create config '{option}' option" + +def config_read(): + ''' Reads the configuration file and updates config pointers. ''' + + rc = w.config_read(config_file) + + try: + if rc == w.WEECHAT_CONFIG_READ_MEMORY_ERROR: + raise ValueError('not enough memory to read config file') + elif rc == w.WEECHAT_CONFIG_READ_FILE_NOT_FOUND: + raise ValueError('config file was not found') + + except ValueError as err: + w.prnt('', f'{SCRIPT_NAME}\t{err.args[0]}') + raise + +def check_affix_cb(data, option, value): + ''' Checks if affix option is empty. Note that it must have a value, and space + (\x20) is ignored. ''' + + if value == '': + return 0 + + return 1 + +def compile_regexes(): + ''' Compiles all script regexes for reuse. ''' + + for k,v in regex.items(): + regex[k] = re.compile(v, flags=re.VERBOSE) + +def debug_str(var, string): + ''' Displays string information for debugging in core.weechat buffer. ''' + + w.prnt('', f'{var}:') + w.command('', f'/debug unicode {string}') + w.prnt('', '') + +def get_nick_color(buffer, nick, my_nick): + ''' Retrieves nick color code from weechat. ''' + if nick == my_nick: return w.color(w.config_string(w.config_get('weechat.color.chat_nick_self'))) else: version = int(w.info_get('version_number', '') or 0) + + # 'irc_nick_color' (deprecated since version 1.5, replaced by 'nick_color') if w.buffer_get_string(buffer, 'plugin') == 'irc' and version == 0x4010000: server = w.buffer_get_string(buffer, 'localvar_server') - return w.info_get('irc_nick_color', '%s,%s' % (server, nick)) + return w.info_get('irc_nick_color', f'{server},{nick}') + return w.info_get('nick_color', nick) -def colorize_cb(data, modifier, modifier_data, line): - ''' Callback that does the colorizing, and returns new line if changed ''' +def colorize_priv_nicks(buffer): + ''' Colorizes nicks on IRC private buffers. ''' - global ignore_nicks, ignore_channels, colored_nicks + # Reset the buffer dict to update nicks changes, since there is no nicklist + # in private buffers. + colored_nicks[buffer] = {} - if modifier_data.startswith('0x'): - # WeeChat >= 2.9 - buffer, tags = modifier_data.split(';', 1) - else: - # WeeChat <= 2.8 - plugin, buffer_name, tags = modifier_data.split(';', 2) - buffer = w.buffer_search(plugin, buffer_name) + my_nick = w.buffer_get_string(buffer, 'localvar_nick') + priv_nick = w.buffer_get_string(buffer, 'localvar_channel') + + for nick in my_nick, priv_nick: + nick_color = get_nick_color(buffer, nick, my_nick) + + colored_nicks[buffer][nick] = { + 'color': nick_color, + 'prefix': '', + } + +def colorize_nicks(buffer, min_len, prefixes, suffixes, has_colors, line): + ''' Finds every nick from the dict of colored nicks, in the line and colorizes + them. ''' + + chop_line = line + chop_match = '' + chop_match_after = '' + color_match = '' + colorized_nicks_line = '' + nick_end = reset + + # Mark the nick's end with a unique escape to identify its position on preserve_colors(). + if has_colors is not None: + nick_end = uniq_esc_nick + + # Split words on spaces, since it is the most common word divider and is not + # valid in 'nicks' on popular protocols like IRC and matrix; thus protocols + # that allow spaces in 'nicks' are limited here. + for word in re.split(f'{space}+', line.strip(f'{space}')): + nick_prefix = '' # Reset nick prefix. + + if word == '': + continue + + # Get possible nick from word. + nicks_rgx = rf''' + [{prefixes}]? # Optional prefix char + (?P [^ ]+) + ''' + if (nick := re.search(nicks_rgx, word, flags=re.VERBOSE)) is not None: + nick = re.escape(nick.group('nick')) + + # If the word is not a known nick and its last character is an option + # suffix (e.g. colon ':' or comma ','), try to match the word without it. + # This is necessary as 'foo:' is a valid nick, which could be addressed + # as 'foo::'. + if nick not in colored_nicks[buffer]: + if (suffix := re.search(rf'[{suffixes}]$', nick)) is not None: + nick = nick[:-1] + + # Nick exists on buffer. + if nick in colored_nicks[buffer]: + if nick in ignore_nicks or len(nick) < min_len: + continue + + # Get its color. + nick_color = colored_nicks[buffer][nick]['color'] + + # Find nick in the line. + line_rgx = rf''' + (?: \A | [ ]) # Boundary + (?P [{prefixes}])? # Optional prefix char + (?P {nick}) + [{suffixes}]? # " suffix char + (?: \Z | [ ]) # Boundary + ''' + + # Nick is found in the line. + if (line_match := re.search(line_rgx, chop_line, flags=re.VERBOSE)) is not None: + # In order to prevent the regex engine to needless find the nicks + # at previous match positions, preserve the state by chopping the + # line at the start and end positions of matches. + + # Start position of nick match. + start = line_match.start('nick') + + # Get the real nick prefix from nicklist. + if (pref_match := line_match.group('pref')) is not None: + nick_prefix = colored_nicks[buffer][nick]['prefix'] + + # If it exists, update the start position match. + if pref_match == w.string_remove_color(nick_prefix, ''): + start = line_match.start('pref') + + # Mark the prefix with a unique escape to idenfity its + # position on preserve_colors(). + if has_colors is not None: + nick_prefix = f'{uniq_esc_pref}{nick_prefix}' + else: + nick_prefix = '' + + # End position of nick match. + end = line_match.end('nick') + + # Chop + chop_till_match = chop_line[:end] + chop_after_match = chop_line[end:] + + # Concat the chopped strings while colorizing the nick, then update + # the chopped line. + nick_str = f'{nick_prefix}{nick_color}{nick}{nick_end}' + color_match += f'{chop_till_match[:start]}{nick_str}{chop_till_match[end:]}' + chop_line = chop_after_match + + if color_match: + colorized_nicks_line = f'{color_match}{chop_after_match}' + + return colorized_nicks_line + +def preserve_colors(line, colorized_nicks_line): + ''' + If the line string is already colored, captures every color code before the nick + match, for restoration after nick colorizing. Otherwise string colors after the + nick are reset. + + Testing: + 1. Create an IRC channel. + /j ##testing-weechat + + 2. Create the nick 'nick111' with perlexec: + /perlexec my $buffer = weechat::buffer_search('==', 'irc.libera.##testing-weechat'); my $group = weechat::nicklist_add_group($buffer, '', 'test_group', 'weechat.color.nicklist_group', 1); weechat::nicklist_add_nick($buffer, $group, 'nick111', 'blue', '@', 'lightgreen', 1) + + 3. Send this message in the channel with script unloaded: + /input insert \x0305<\x03043 \x02\x0307nick111 is awesome\x02 \x0314[0 user] \x0399\x1fhttps://github.com/ \x0305n\x0355i\x0384c\x0302k\x0f\x03921\x03091\x03381 /weechat/ https\x1f\x16:// nick111 .org/ + + 4. Repeat step 3 with the script loaded. It should colorize the nicks and + preserve all colors. + The string is inspired by ##hntop messages and modified to cover some corner cases. + ''' + + new_line = '' + split_line = [] + split_line_nc = [] + color_codes = '' + idx = 0 + match = 0 + + # Split all color codes and bytes from the lines. + split_line = [x for x in regex['split'].split(line) if x is not None and x] + split_line_nc = [y for y in regex['split'].split(colorized_nicks_line) if y is not None and y] + + # Debug split lists. + #w.prnt('', f'split_line:' + pp.pformat(split_line)) + #w.prnt('', f'split_line_nc:' + pp.pformat(split_line_nc)) + + # Iterate through the original split list, comparing every char against the + # uncolored list; while reconstructing the new line with saved color codes. + for i in split_line: + #w.prnt('', f'i: ' + pp.pformat(f'{i}')) + #w.prnt('', f"split_line_nc[{idx}]: " + pp.pformat(f'{split_line_nc[idx]}')) + + # It is a color code, so append its codes to be restored. + if regex['is_color'].search(i) is not None: + color_codes += i + #w.prnt('', f'color_codes: ' + pp.pformat(f'{color_codes}')) + + # Append the codes if not inside a nick match. + if not match: + new_line += i + + continue + # Remove saved codes if a reset code is found. + elif i == reset: + if not match: + new_line += i + + color_codes = '' + continue + elif split_line_nc[idx]: + # It is a char, so compare it against the uncolored's char. + if i == split_line_nc[idx]: + new_line += i + idx += 1 + + continue + # If the char is in a nick match and uncolored's is a unique nick + # escape code, restore the saved codes, then advance the index. + elif match and split_line_nc[idx] == uniq_esc_nick: + #w.prnt('', f"split_line_nc[{idx} + 1]: " + pp.pformat(f'{split_line_nc[idx + 1]}')) + + # If the chars match, advance the index. + if split_line_nc[idx + 1] == i: + new_line += f'{reset}{color_codes}{i}' + idx += 2 + match = 0 + + continue + # It is a unique prefix escape code, so get its color code and char, + # then advance uncolored's index to the start of colorized nick match. + elif split_line_nc[idx] == uniq_esc_pref: + prefix = f'{split_line_nc[idx + 1]}{i}' + new_line += prefix + idx += 3 + + continue + # It is the start of a colorized nick match, so colorize the new line, + # then advance uncolored's index to the current char. + elif (split_match := regex['exact_color'].search(split_line_nc[idx])) is not None: + #w.prnt('', f"split_line_nc[{idx} + 1]: " + pp.pformat(f'{split_line_nc[idx + 1]}')) + + nick_color = split_match.group(0) + idx += 1 + new_line += f'{reset}{nick_color}{split_line_nc[idx]}' + match = 1 + + # If the chars match, advance the index. + if i == split_line_nc[idx]: + idx += 1 + continue + + return new_line + +def init_colorize(buffer, message): + ''' Initializes the process of nicks colorizing. ''' + + colorized_nicks_msg = '' + new_msg = '' + + # Get options. + min_len = w.config_integer(config_option['min_nick_length']) + pref_charset = re.escape(w.config_string(config_option['nick_prefixes'])) + suff_charset = re.escape(w.config_string(config_option['nick_suffixes'])) + + # Check if message has color codes. + has_colors = regex['has_colors'].search(message) + + # Remove any color codes from message in order to match and colorize the strings correctly. + msg_nocolor = w.string_remove_color(message, '') + + # Find and colorize the nicks. + colorized_nicks_msg = colorize_nicks(buffer, min_len, pref_charset, suff_charset, has_colors, msg_nocolor) + + # Preserve colors from message. + if has_colors is not None and colorized_nicks_msg: + new_msg = preserve_colors(message, colorized_nicks_msg) + + # Debug the message string. + #debug_str('message', message) + + # Update the message. + + if colorized_nicks_msg: + #debug_str('colorized_nicks_msg', colorized_nicks_msg) + message = colorized_nicks_msg + + if new_msg: + #debug_str('new_msg', new_msg) + message = new_msg + + return message + +def colorize_cb(data, hashtable): + ''' + Callback that does the colorizing of nicks from messages and returns a new message. + Testing: + 1. Create an IRC channel: + /j ##testing-weechat + + 2. Create the nicks: alice, :alicee, alicee:, :alicee:, and utf8©nick + with perlexec: + /perlexec my @nicks = qw(alice :alicee alicee: :alicee: utf8©nick); my $buffer = weechat::buffer_search('==', 'irc.libera.##testing-weechat'); my $group = weechat::nicklist_add_group($buffer, '', 'test_group', 'weechat.color.nicklist_group', 1); foreach my $i (@nicks) { weechat::nicklist_add_nick($buffer, $group, $i, 'default', '@', 'lightgreen', 1) } + + 3. Then paste and send this string: + hey alicee and utf8©nickz, how are you? sorry, alice and utf8©nick @alicee: @:alicee @:alicee: aaaliceee @:alicee:: @::alicee:: @alicee:: %alicee:, ~:alicee, Nice to meet you @:alicee,, &:alicee:, @:alicee,: :alicee, :alicee alicee: <3 :alicee: :alicee::: +utf8©nick: :-) bye + + 4. The colors that matter are in the message, so ignore the static nicklist + colors. The nicks in message should be colorized correctly based on weechat's + color algorithm, and respect the script affixes. + Insert a reverse color code (^Cv) at the beggining of string, if having + trouble on seeing the colors. + ''' + + buffer = hashtable['buffer'] + tags = hashtable['tags'].split(',') + displayed = hashtable['displayed'] + message = hashtable['message'] + + plugin = w.buffer_get_string(buffer, 'localvar_plugin') + bufname = w.buffer_get_string(buffer, 'localvar_name') + buftype = w.buffer_get_string(buffer, 'localvar_type') channel = w.buffer_get_string(buffer, 'localvar_channel') - tags = tags.split(',') - # Check if buffer has colorized nicks - if buffer not in colored_nicks: - return line + irc_only = w.config_boolean(config_option['irc_only']) - if channel and channel in ignore_channels: - return line + # Colorize only IRC user messages. + if plugin == 'irc' or irc_only and plugin != 'irc': + # There is no point in colorizing non channel/private buffers, and IRC + # tags other than 'irc_privmsg/notice', since tags i.e. irc_join/part/quit + # are already colored. + if buftype != 'channel' and buftype != 'private' or tags[0] != 'irc_privmsg' and tags[0] != 'irc_notice': + return hashtable + + # Colorize nicks on IRC private buffers. + if plugin == 'irc' and buftype == 'private': + colorize_priv_nicks(buffer) + + # Check if buffer has colorized nicks. + if not colored_nicks.get(buffer): + return hashtable - min_length = w.config_integer(colorize_config_option['min_nick_length']) - reset = w.color('reset') + # Check if channel is ignored. + if channel and channel in ignore_channels: + return hashtable - # Don't colorize if the ignored tag is present in message - tag_ignores = w.config_string(colorize_config_option['ignore_tags']).split(',') + # Do not colorize if an ignored tag is present in message. + tag_ignores = w.config_string(config_option['ignore_tags']).split(',') for tag in tags: if tag in tag_ignores: - return line + return hashtable - for words in valid_nick_re.findall(line): - nick = words[1] + # Do not colorize if message is filtered. + if displayed == '0' and not w.config_boolean(config_option['colorize_filter']): + return hashtable - # If the matched word is not a known nick, we try to match the - # word without its first or last character (if not a letter). - # This is necessary as "foo:" is a valid nick, which could be - # adressed as "foo::". - if nick not in colored_nicks[buffer]: - if not nick[-1].isalpha() and not nick[0].isalpha(): - if nick[1:-1] in colored_nicks[buffer]: - nick = nick[1:-1] - elif not nick[0].isalpha(): - if nick[1:] in colored_nicks[buffer]: - nick = nick[1:] - elif not nick[-1].isalpha(): - if nick[:-1] in colored_nicks[buffer]: - nick = nick[:-1] - - # Check that nick is not ignored and longer than minimum length - if len(nick) < min_length or nick in ignore_nicks: - continue + # Init colorizing process. + message = init_colorize(buffer, message) - # Check that nick is in the dictionary colored_nicks - if nick in colored_nicks[buffer]: - nick_color = colored_nicks[buffer][nick] - - try: - # Let's use greedy matching. Will check against every word in a line. - if w.config_boolean(colorize_config_option['greedy_matching']): - cnt = 0 - limit = w.config_integer(colorize_config_option['match_limit']) - - for word in line.split(): - cnt += 1 - assert cnt < limit - # if cnt > limit: - # raise RuntimeError('Exceeded colorize_nicks.look.match_limit.'); - - if w.config_boolean(colorize_config_option['ignore_nicks_in_urls']) and \ - word.startswith(('http://', 'https://')): - continue - - if nick in word: - # Is there a nick that contains nick and has a greater lenght? - # If so let's save that nick into var biggest_nick - biggest_nick = "" - for i in colored_nicks[buffer]: - cnt += 1 - assert cnt < limit - - if nick in i and nick != i and len(i) > len(nick): - if i in word: - # If a nick with greater len is found, and that word - # also happens to be in word, then let's save this nick - biggest_nick = i - # If there's a nick with greater len, then let's skip this - # As we will have the chance to colorize when biggest_nick - # iterates being nick. - if len(biggest_nick) > 0 and biggest_nick in word: - pass - elif len(word) < len(biggest_nick) or len(biggest_nick) == 0: - new_word = word.replace(nick, '%s%s%s' % (nick_color, nick, reset)) - line = line.replace(word, new_word) - - # Switch to lazy matching - else: - raise AssertionError - - except AssertionError: - # Let's use lazy matching for nick - nick_color = colored_nicks[buffer][nick] - # The two .? are in case somebody writes "nick:", "nick,", etc - # to address somebody - regex = r"(\A|\s).?(%s).?(\Z|\s)" % re.escape(nick) - match = re.search(regex, line) - if match is not None: - new_line = line[:match.start(2)] + nick_color+nick+reset + line[match.end(2):] - line = new_line + # Debug the hashtable. + #w.prnt('', 'hashtable:\n' + pp.pformat(hashtable)) - return line + # Update the hashtable. + hashtable['message'] = message + + return hashtable def colorize_input_cb(data, modifier, modifier_data, line): - ''' Callback that does the colorizing in input ''' + ''' Callback that does the colorizing of nicks from weechat's input. ''' - global ignore_nicks, ignore_channels, colored_nicks + if not w.config_boolean(config_option['colorize_input']): + return line - min_length = w.config_integer(colorize_config_option['min_nick_length']) + buffer = w.current_buffer() + plugin = w.buffer_get_string(buffer, 'localvar_plugin') + buftype = w.buffer_get_string(buffer, 'localvar_type') + channel = w.buffer_get_string(buffer, 'localvar_channel') - if not w.config_boolean(colorize_config_option['colorize_input']): - return line + irc_only = w.config_boolean(config_option['irc_only']) - buffer = w.current_buffer() - # Check if buffer has colorized nicks - if buffer not in colored_nicks: + # Colorize only IRC user messages. + if plugin == 'irc' or irc_only and plugin != 'irc': + # There is no point in colorizing non channel/private buffers. + if buftype != 'channel' and buftype != 'private': + return line + + # Check if current buffer has colorized nicks. + if not colored_nicks.get(buffer): return line - channel = w.buffer_get_string(buffer, 'name') + # Check if current channel is ignored. if channel and channel in ignore_channels: return line - reset = w.color('reset') + # Decode IRC colors from input. + if plugin == 'irc': + line = w.hook_modifier_exec('irc_color_decode', '1', line) - for words in valid_nick_re.findall(line): - nick = words[1] - # Check that nick is not ignored and longer than minimum length - if len(nick) < min_length or nick in ignore_nicks: - continue - if nick in colored_nicks[buffer]: - nick_color = colored_nicks[buffer][nick] - line = line.replace(nick, '%s%s%s' % (nick_color, nick, reset)) + # Init colorizing process. + line = init_colorize(buffer, line) return line -def populate_nicks(*args): - ''' Fills entire dict with all nicks weechat can see and what color it has - assigned to it. ''' - global colored_nicks +def populate_nicks_cb(*args): + ''' Callback that fills the colored nicks dict with all nicks weechat can see, + and what color and prefix it has assigned to it. ''' + + bufname = '' + prefix_color = '' + nick_prefix = '' + irc_only = w.config_boolean(config_option['irc_only']) - colored_nicks = {} + # Get nicks only in IRC buffers. + if irc_only: + bufname = 'irc.*' + + # Get list of buffers. + if not (buffers := w.infolist_get('buffer', '', bufname)): + w.prnt('', f'{SCRIPT_NAME}\tfailed to get list of buffers') + return w.WEECHAT_RC_ERROR - buffers = w.infolist_get('buffer', '', '') while w.infolist_next(buffers): buffer_ptr = w.infolist_pointer(buffers, 'pointer') + channel = w.buffer_get_string(buffer_ptr, 'localvar_channel') + + # Skip non-IRC channel buffers. + if irc_only and not w.info_get('irc_is_channel', channel): + continue + my_nick = w.buffer_get_string(buffer_ptr, 'localvar_nick') - nicklist = w.infolist_get('nicklist', buffer_ptr, '') - while w.infolist_next(nicklist): - if buffer_ptr not in colored_nicks: - colored_nicks[buffer_ptr] = {} - if w.infolist_string(nicklist, 'type') != 'nick': - continue + if (nicklist := w.infolist_get('nicklist', buffer_ptr, '')): + while w.infolist_next(nicklist): + if buffer_ptr not in colored_nicks: + colored_nicks[buffer_ptr] = {} + + # Skip nick groups. + if w.infolist_string(nicklist, 'type') != 'nick': + continue - nick = w.infolist_string(nicklist, 'name') - nick_color = colorize_nick_color(buffer_ptr, nick, my_nick) + # Get nicks colors. + nick = w.infolist_string(nicklist, 'name') + nick_color = get_nick_color(buffer_ptr, nick, my_nick) - colored_nicks[buffer_ptr][nick] = nick_color + # Get nicks prefixes. + prefix = w.infolist_string(nicklist, 'prefix') + if prefix != space: + prefix_color = w.color(w.infolist_string(nicklist, 'prefix_color')) + nick_prefix = f'{prefix_color}{prefix}' + + # Populate + colored_nicks[buffer_ptr][nick] = { + 'color': nick_color, + 'prefix': nick_prefix, + } + nick_prefix = '' w.infolist_free(nicklist) w.infolist_free(buffers) + #w.prnt('', 'colored_nicks:\n' + pp.pformat(colored_nicks)) + return w.WEECHAT_RC_OK -def add_nick(data, signal, type_data): - ''' Add nick to dict of colored nicks ''' - global colored_nicks +def add_nick_cb(data, signal, signal_data): + ''' Callback that adds a nick to the dict of colored nicks, when a nick is + added to the nicklist. ''' + + # Nicks can have ',' in them in some protocols. + buffer, nick = signal_data.split(',', maxsplit=1) + + if buffer not in colored_nicks: + colored_nicks[buffer] = {} + + # Get nick color. + my_nick = w.buffer_get_string(buffer, 'localvar_nick') + nick_color = get_nick_color(buffer, nick, my_nick) + + # Get nick prefix. + nick_prefix = '' + if (nicklist := w.infolist_get('nicklist', buffer, f'nick_{nick}')): + while w.infolist_next(nicklist): + prefix = w.infolist_string(nicklist, 'prefix') - # Nicks can have , in them in some protocols - splitted = type_data.split(',') - pointer = splitted[0] - nick = ",".join(splitted[1:]) - if pointer not in colored_nicks: - colored_nicks[pointer] = {} + if prefix != space: + prefix_color = w.color(w.infolist_string(nicklist, 'prefix_color')) + nick_prefix = f'{prefix_color}{prefix}' - my_nick = w.buffer_get_string(pointer, 'localvar_nick') - nick_color = colorize_nick_color(pointer, nick, my_nick) + # Update + colored_nicks[buffer][nick] = { + 'color': nick_color, + 'prefix': nick_prefix, + } - colored_nicks[pointer][nick] = nick_color + w.infolist_free(nicklist) + w.infolist_free(buffer) + + #w.prnt('', 'colored_nicks:\n' + pp.pformat(colored_nicks)) return w.WEECHAT_RC_OK -def remove_nick(data, signal, type_data): - ''' Remove nick from dict with colored nicks ''' - global colored_nicks +def remove_nick_cb(data, signal, signal_data): + ''' Callback that removes a nick from the dict of colored nicks, when a nick is + removed from the nicklist. ''' + + # Nicks can have ',' in them in some protocols. + buffer, nick = signal_data.split(',', maxsplit=1) - # Nicks can have , in them in some protocols - splitted = type_data.split(',') - pointer = splitted[0] - nick = ",".join(splitted[1:]) + if buffer in colored_nicks and nick in colored_nicks[buffer]: + del colored_nicks[buffer][nick] - if pointer in colored_nicks and nick in colored_nicks[pointer]: - del colored_nicks[pointer][nick] + #w.prnt('', 'colored_nicks:\n' + pp.pformat(colored_nicks)) return w.WEECHAT_RC_OK -def update_blacklist(*args): - ''' Set the blacklist for channels and nicks. ''' +def remove_priv_buffer_cb(data, signal, buffer): + ''' Callback that removes an IRC private buffer from the dict of colored nicks, + when the buffer is closing. ''' + + # For some reason, weechat crashes if the hook signal is set to 'buffer_closed' + # while trying to get the 'localvar_*' strings. + # Perhaps the buffer pointer is not valid anymore because it was closed? + plugin = w.buffer_get_string(buffer, 'localvar_plugin') + buftype = w.buffer_get_string(buffer, 'localvar_type') + + if plugin == 'irc' and buftype == 'private' and buffer in colored_nicks: + del colored_nicks[buffer] + + #w.prnt('', 'colored_nicks:\n' + pp.pformat(colored_nicks)) + + return w.WEECHAT_RC_OK + +def update_blacklist_cb(*args): + ''' Callback that sets the blacklist for channels and nicks. ''' + global ignore_channels, ignore_nicks - ignore_channels = w.config_string(colorize_config_option['blacklist_channels']).split(',') - ignore_nicks = w.config_string(colorize_config_option['blacklist_nicks']).split(',') + + ignore_channels = w.config_string(config_option['ignore_channels']).split(',') + ignore_nicks = w.config_string(config_option['ignore_nicks']).split(',') + return w.WEECHAT_RC_OK -if __name__ == "__main__": - if w.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE, - SCRIPT_DESC, "", ""): - colorize_config_init() - colorize_config_read() - - # Run once to get data ready - update_blacklist() - populate_nicks() - - w.hook_signal('nicklist_nick_added', 'add_nick', '') - w.hook_signal('nicklist_nick_removed', 'remove_nick', '') - w.hook_modifier('weechat_print', 'colorize_cb', '') - # Hook config for changing colors - w.hook_config('weechat.color.chat_nick_colors', 'populate_nicks', '') - w.hook_config('weechat.look.nick_color_hash', 'populate_nicks', '') - # Hook for working togheter with other scripts (like colorize_lines) - w.hook_modifier('colorize_nicks', 'colorize_cb', '') - # Hook for modifying input - w.hook_modifier('250|input_text_display', 'colorize_input_cb', '') - # Hook for updating blacklist (this could be improved to use fnmatch) - weechat.hook_config('%s.look.blacklist*' % SCRIPT_NAME, 'update_blacklist', '') +if __name__ == '__main__': + if w.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE, SCRIPT_DESC, '', ''): + # Initialize config options and regexes. + try: + if (msg := config_init()): + raise ValueError(msg) + + except ValueError as err: + w.prnt('', f'{SCRIPT_NAME}\t{err.args[0]}') + raise + + config_read() + compile_regexes() + + # Run once to get data ready. + update_blacklist_cb() + populate_nicks_cb() + + # Hooks + + # Colorize nicks. + w.hook_line('', '', '', 'colorize_cb', '') # Message + w.hook_modifier('250|input_text_display', 'colorize_input_cb', '') # Input + + # Update nicks. + w.hook_signal('nicklist_nick_added', 'add_nick_cb', '') + w.hook_signal('nicklist_nick_removed', 'remove_nick_cb', '') + w.hook_signal('buffer_closing', 'remove_priv_buffer_cb', '') + + # Repopulate nicks on colors changes from weechat's options. + w.hook_config('weechat.color.chat_nick_colors', 'populate_nicks_cb', '') + w.hook_config('weechat.look.nick_color_hash', 'populate_nicks_cb', '') + w.hook_config('irc.color.nick_prefixes', 'populate_nicks_cb', '') + + # Update blacklists. + w.hook_config(f'{SCRIPT_NAME}.look.ignore_*', 'update_blacklist_cb', '')