diff options
-rw-r--r-- | README.md | 15 | ||||
-rw-r--r-- | wee_slack.py | 699 |
2 files changed, 454 insertions, 260 deletions
@@ -3,15 +3,19 @@ wee-slack ========= +**News:** + The 0.98.3+ releases have some big backend changes that should make startup and multi-group much faster. Please report any bugs to the Freenode IRC channel #wee-slack. + A WeeChat native client for Slack.com. Provides supplemental features only available in the web/mobile clients such as: synchronizing read markers, typing notification, search, (and more)! Connects via the Slack API, and maintains a persistent websocket for notification of events. ![animated screenshot](https://dl.dropboxusercontent.com/u/566560/slack.gif) Features -------- - * **New** edited messages work just like the official clients, where the original message changes and has (edited) appended. - * **New** unfurled urls dont generate a new message, but replace the original with more info as it is received. - * **New** regex style message editing (s/oldtext/newtext/) + * **New** Emoji reactions! + * Edited messages work just like the official clients, where the original message changes and has (edited) appended. + * Unfurled urls dont generate a new message, but replace the original with more info as it is received. + * Regex style message editing (s/oldtext/newtext/) * Caches message history, making startup MUCH faster * Smarter redraw of dynamic buffer info (much lower CPU %) * beta UTF-8 support @@ -71,7 +75,10 @@ pip install websocket-client sudo apt-get install curl pip install websocket-client ``` - +##### FreeBSD +``` +pkg install curl py27-websocket-client py27-six +``` ####2. copy wee_slack.py to ~/.weechat/python/autoload ``` diff --git a/wee_slack.py b/wee_slack.py index f1b9646..1a497bc 100644 --- a/wee_slack.py +++ b/wee_slack.py @@ -8,9 +8,9 @@ import pickle import sha import re import urllib -import urlparse import HTMLParser import sys +import traceback from websocket import create_connection # hack to make tests possible.. better way? @@ -21,11 +21,12 @@ except: SCRIPT_NAME = "slack_extension" SCRIPT_AUTHOR = "Ryan Huber <rhuber@gmail.com>" -SCRIPT_VERSION = "0.97.26" +SCRIPT_VERSION = "0.98.4" SCRIPT_LICENSE = "MIT" SCRIPT_DESC = "Extends weechat for typing notification/search/etc on slack.com" BACKLOG_SIZE = 200 +SCROLLBACK_SIZE = 2000 SLACK_API_TRANSLATOR = { "channel": { @@ -53,6 +54,9 @@ NICK_GROUP_HERE = "0|Here" NICK_GROUP_AWAY = "1|Away" def dbg(message, fout=False, main_buffer=False): + """ + send debug output to the slack-debug buffer and optionally write to a file. + """ message = "DEBUG: {}".format(message) #message = message.encode('utf-8', 'replace') if fout: @@ -63,97 +67,33 @@ def dbg(message, fout=False, main_buffer=False): if slack_debug is not None: w.prnt(slack_debug, message) -# hilarious, i know - - -class Meta(list): - - def __init__(self, attribute, search_list): - self.attribute = attribute - self.search_list = search_list - - def __str__(self): - string = '' - for each in self.search_list.get_all(self.attribute): - string += "{} ".format(each) - return string - - def __repr__(self): - self.search_list.get_all(self.attribute) - - def __getitem__(self, index): - things = self.get_all() - return things[index] - - def __iter__(self): - things = self.get_all() - for channel in things: - yield channel - - def get_all(self): - items = [] - items += self.search_list.get_all(self.attribute) - return items - - def find(self, name): - items = self.search_list.find_deep(name, self.attribute) - items = [x for x in items if x is not None] - if len(items) == 1: - return items[0] - elif len(items) == 0: - pass - else: - dbg("probably something bad happened with meta items: {}".format(items)) - return items - #raise AmbiguousProblemError - - def find_first(self, name): - items = self.find(name) - if items.__class__ == list: - return items[0] - else: - return False - - def find_by_class(self, class_name): - items = self.search_list.find_by_class_deep(class_name, self.attribute) - return items - class SearchList(list): + """ + A normal python list with some syntactic sugar for searchability + """ + def __init__(self): + self.hashtable = {} + super(SearchList, self).__init__(self) def find(self, name): - items = [] - for child in self: - if child.__class__ == self.__class__: - items += child.find(name) - else: - if child == name: - items.append(child) - if len(items) == 1: - return items[0] - elif items != []: - return items - - def find_deep(self, name, attribute): - items = [] - for child in self: - if child.__class__ == self.__class__: - if items is not None: - items += child.find_deep(name, attribute) - elif dir(child).count('find') == 1: - if items is not None: - items.append(child.find(name, attribute)) - if items != []: - return items - - def get_all(self, attribute): - items = [] + if name in self.hashtable.keys(): + return self.hashtable[name] + #this is a fallback to __eq__ if the item isn't in the hashtable already + if self.count(name) > 0: + self.update_hashtable() + return self[self.index(name)] + + def append(self, item, aliases=[]): + super(SearchList, self).append(item) + self.update_hashtable() + + def update_hashtable(self): for child in self: - if child.__class__ == self.__class__: - items += child.get_all(attribute) - else: - items += (eval("child." + attribute)) - return items + if hasattr(child, "get_aliases"): + for alias in child.get_aliases(): + if alias is not None: + self.hashtable[alias] = child def find_by_class(self, class_name): items = [] @@ -173,7 +113,9 @@ class SearchList(list): class SlackServer(object): - + """ + Root object used to represent connection and state of the connection to a slack group. + """ def __init__(self, token): self.nick = None self.name = None @@ -206,6 +148,18 @@ class SlackServer(object): def __repr__(self): return "{}".format(self.identifier) + def add_user(self, user): + self.users.append(user, user.get_aliases()) + users.append(user, user.get_aliases()) + + def add_channel(self, channel): + self.channels.append(channel, channel.get_aliases()) + channels.append(channel, channel.get_aliases()) + + def get_aliases(self): + aliases = [self.identifier, self.token, self.buffer] + return aliases + def find(self, name, attribute): attribute = eval("self." + attribute) return attribute.find(name) @@ -297,13 +251,14 @@ class SlackServer(object): self.ws_hook = w.hook_fd(self.ws.sock._sock.fileno(), 1, 0, 0, "slack_websocket_cb", self.identifier) self.ws.sock.setblocking(0) return True - except: + except Exception as e: + print("websocket connection error: {}".format(e)) return False def create_slack_mappings(self, data): for item in data["users"]: - self.users.append(User(self, item["name"], item["id"], item["presence"], item["deleted"])) + self.add_user(User(self, item["name"], item["id"], item["presence"], item["deleted"])) for item in data["channels"]: if "last_read" not in item: @@ -314,17 +269,17 @@ class SlackServer(object): item["topic"] = {} item["topic"]["value"] = "" if not item["is_archived"]: - self.channels.append(Channel(self, item["name"], item["id"], item["is_member"], item["last_read"], "#", item["members"], item["topic"]["value"])) + self.add_channel(Channel(self, item["name"], item["id"], item["is_member"], item["last_read"], "#", item["members"], item["topic"]["value"])) for item in data["groups"]: if "last_read" not in item: item["last_read"] = 0 if not item["is_archived"]: - self.channels.append(GroupChannel(self, item["name"], item["id"], item["is_open"], item["last_read"], "#", item["members"], item["topic"]["value"])) + self.add_channel(GroupChannel(self, item["name"], item["id"], item["is_open"], item["last_read"], "#", item["members"], item["topic"]["value"])) for item in data["ims"]: if "last_read" not in item: item["last_read"] = 0 name = self.users.find(item["user"]).name - self.channels.append(DmChannel(self, name, item["id"], item["is_open"], item["last_read"])) + self.add_channel(DmChannel(self, name, item["id"], item["is_open"], item["last_read"])) for item in self.channels: item.get_history() @@ -341,26 +296,11 @@ class SlackServer(object): pass #w.prnt("", "%s\t%s" % (user, message)) - -class SlackThing(object): - - def __init__(self, name, identifier): - self.name = name - self.identifier = identifier - self.channel_buffer = None - - def __str__(self): - return self.name - - def __repr__(self): - return self.name - - def buffer_input_cb(b, buffer, data): - if not data.startswith('s/'): + if not data.startswith('s/') or data.startswith('+'): channel = channels.find(buffer) channel.send_message(data) - channel.buffer_prnt(channel.server.nick, data) + #channel.buffer_prnt(channel.server.nick, data) elif data.count('/') == 3: old, new = data.split('/')[1:3] channel = channels.find(buffer) @@ -369,33 +309,58 @@ def buffer_input_cb(b, buffer, data): return w.WEECHAT_RC_ERROR -class Channel(SlackThing): - +class Channel(object): + """ + Represents a single channel and is the source of truth + for channel <> weechat buffer + """ def __init__(self, server, name, identifier, active, last_read=0, prepend_name="", members=[], topic=""): - super(Channel, self).__init__(name, identifier) + self.name = prepend_name + name + self.identifier = identifier + self.active = active + self.last_read = float(last_read) + self.members = set(members) + self.topic = topic + + self.members_table = {} + self.channel_buffer = None self.type = "channel" self.server = server - self.name = prepend_name + self.name self.typing = {} - self.active = active self.opening = False - self.members = set(members) - self.topic = topic - self.last_read = float(last_read) self.last_received = None + self.messages = [] + self.scrolling = False if active: self.create_buffer() self.attach_buffer() + self.create_members_table() self.update_nicklist() self.set_topic(self.topic) buffer_list_update_next() + def __str__(self): + return self.name + + def __repr__(self): + return self.name + def __eq__(self, compare_str): if compare_str == self.fullname() or compare_str == self.name or compare_str == self.identifier or compare_str == self.name[1:] or (compare_str == self.channel_buffer and self.channel_buffer is not None): return True else: return False + def get_aliases(self): + aliases = [self.fullname(), self.name, self.identifier, self.name[1:], ] + if self.channel_buffer is not None: + aliases.append(self.channel_buffer) + return aliases + + def create_members_table(self): + for user in self.members: + self.members_table[user] = self.server.users.find(user) + def create_buffer(self): channel_buffer = w.buffer_search("", "{}.{}".format(self.server.domain, self.name)) if channel_buffer: @@ -434,7 +399,7 @@ class Channel(SlackThing): '', NICK_GROUP_AWAY, "weechat.color.nicklist_group", 1) try: for user in self.members: - user = self.server.users.find(user) + user = self.members_table[user] if user.deleted: continue if user.presence == 'away': @@ -442,7 +407,7 @@ class Channel(SlackThing): else: w.nicklist_add_nick(self.channel_buffer, here, user.name, user.color_name, "", "", 1) except Exception as e: - print "DEBUG: {} {} {}".format(self.identifier, self.name, e) + dbg("DEBUG: {} {} {}".format(self.identifier, self.name, e)) def fullname(self): return "{}.{}".format(self.server.domain, self.name) @@ -452,11 +417,13 @@ class Channel(SlackThing): def user_join(self, name): self.members.add(name) + self.create_members_table() self.update_nicklist() def user_leave(self, name): if name in self.members: self.members.remove(name) + self.create_members_table() self.update_nicklist() def set_active(self): @@ -487,14 +454,14 @@ class Channel(SlackThing): for item in enumerate(message): if item[1].startswith('@') and len(item[1]) > 1: named = re.match('.*[@#](\w+)(\W*)', item[1]).groups() - if named[0] in ["group", "channel"]: + if named[0] in ["group", "channel", "here"]: message[item[0]] = "<!{}>".format(named[0]) if self.server.users.find(named[0]): - message[item[0]] = "<@{}>{}".format(self.server.users.find(named[0]).identifier, named[1]) + message[item[0]] = "<@{}|{}>{}".format(self.server.users.find(named[0]).identifier, named[0], named[1]) if item[1].startswith('#') and self.server.channels.find(item[1]): named = re.match('.*[@#](\w+)(\W*)', item[1]).groups() if self.server.channels.find(named[0]): - message[item[0]] = "<#{}>{}".format(self.server.channels.find(named[0]).identifier, named[1]) + message[item[0]] = "<#{}|{}>{}".format(self.server.channels.find(named[0]).identifier, named[0], named[1]) dbg(message) return " ".join(message) @@ -524,10 +491,6 @@ class Channel(SlackThing): async_slack_api_request(self.server.domain, self.server.token, SLACK_API_TRANSLATOR[self.type]["leave"], {"channel": self.identifier}) def closed(self): - try: - message_cache.pop(self.identifier) - except KeyError: - pass self.channel_buffer = None self.last_received = None self.close() @@ -573,19 +536,23 @@ class Channel(SlackThing): if w.buffer_get_string(self.channel_buffer, "short_name") != (color + new_name): w.buffer_set(self.channel_buffer, "short_name", color + new_name) - def buffer_prnt_changed(self, user, text, time, append=""): - if user: - if self.server.users.find(user): - name = self.server.users.find(user).formatted_name() - else: - name = user - name = name.decode('utf-8') - modify_buffer_line(self.channel_buffer, name, text, time, append) - else: - modify_buffer_line(self.channel_buffer, None, text, time, append) - return False - - def buffer_prnt(self, user='unknown user', message='no message', time=0): +# deprecated in favor of redrawing the entire buffer +# def buffer_prnt_changed(self, user, text, time, append=""): +# if self.channel_buffer: +# if user: +# if self.server.users.find(user): +# name = self.server.users.find(user).formatted_name() +# else: +# name = user +# name = name.decode('utf-8') +# modify_buffer_line(self.channel_buffer, name, text, time, append) +# else: +# modify_buffer_line(self.channel_buffer, None, text, time, append) + + def buffer_prnt(self, user='unknown_user', message='no message', time=0): + """ + writes output (message) to a buffer (channel) + """ set_read_marker = False time_float = float(time) if time_float != 0 and self.last_read >= time_float: @@ -599,6 +566,8 @@ class Channel(SlackThing): tags = "irc_smart_filter" else: tags = "notify_message" + #don't write these to local log files + #tags += ",no_log" time_int = int(time_float) if self.channel_buffer: if self.server.users.find(user): @@ -625,6 +594,44 @@ class Channel(SlackThing): self.last_received = time self.unset_typing(user) + def buffer_redraw(self): + if self.channel_buffer and not self.scrolling: + w.buffer_clear(self.channel_buffer) + self.messages.sort() + for message in self.messages: + process_message(message.message_json, False) + + def set_scrolling(self): + self.scrolling = True + + def unset_scrolling(self): + self.scrolling = False + self.buffer_redraw() + + def has_message(self, ts): + return self.messages.count(ts) > 0 + + def change_message(self, ts, text): + if self.has_message(ts): + message_index = self.messages.index(ts) + self.messages[message_index].change_text(text) + self.buffer_redraw() + return True + + def add_reaction(self, ts, reaction): + if self.has_message(ts): + message_index = self.messages.index(ts) + self.messages[message_index].add_reaction(reaction) + self.buffer_redraw() + return True + + def remove_reaction(self, ts, reaction): + if self.has_message(ts): + message_index = self.messages.index(ts) + self.messages[message_index].remove_reaction(reaction) + self.buffer_redraw() + return True + def change_previous_message(self, old, new): message = self.my_last_message() if new == "" and old == "": @@ -634,15 +641,21 @@ class Channel(SlackThing): async_slack_api_request(self.server.domain, self.server.token, 'chat.update', {"channel": self.identifier, "ts": message['ts'], "text": new_message}) def my_last_message(self): - for message in reversed(message_cache[self.identifier]): - if "user" in message and "text" in message and message["user"] == self.server.users.find(self.server.nick).identifier: - return message + for message in reversed(self.messages): + if "user" in message.message_json and "text" in message.message_json and message.message_json["user"] == self.server.users.find(self.server.nick).identifier: + return message.message_json + + def cache_message(self, message_json, from_me=False): + if from_me: + message_json["user"] = self.server.users.find(self.server.nick).identifier + self.messages.append(Message(message_json)) + if len(self.messages) > SCROLLBACK_SIZE: + self.messages = self.messages[-SCROLLBACK_SIZE:] def get_history(self): if self.active: - if self.identifier in message_cache.keys(): - for message in message_cache[self.identifier]: - process_message(message) + for message in cache_get(self.identifier): + process_message(json.loads(message), True) if self.last_received != None: async_slack_api_request(self.server.domain, self.server.token, SLACK_API_TRANSLATOR[self.type]["history"], {"channel": self.identifier, "oldest": self.last_received, "count": BACKLOG_SIZE}) else: @@ -677,14 +690,16 @@ class DmChannel(Channel): w.buffer_set(self.channel_buffer, "short_name", new_name) -class User(SlackThing): +class User(object): def __init__(self, server, name, identifier, presence="away", deleted=False): - super(User, self).__init__(name, identifier) - self.channel_buffer = w.info_get("irc_buffer", "{}.{}".format(domain, self.name)) - self.deleted = deleted - self.presence = presence self.server = server + self.name = name + self.identifier = identifier + self.presence = presence + self.deleted = deleted + + self.channel_buffer = w.info_get("irc_buffer", "{}.{}".format(domain, self.name)) self.update_color() self.name_regex = re.compile(r"([\W]|\A)(@{0,1})" + self.name + "('s|[^'\w]|\Z)") @@ -697,12 +712,21 @@ class User(SlackThing): self.nicklist_pointer = w.nicklist_add_nick(server.buffer, ngroup, self.name, self.color_name, "", "", 1) # w.nicklist_add_nick(server.buffer, "", self.formatted_name(), "", "", "", 1) + def __str__(self): + return self.name + + def __repr__(self): + return self.name + def __eq__(self, compare_str): if compare_str == self.name or compare_str == "@" + self.name or compare_str == self.identifier: return True else: return False + def get_aliases(self): + return [self.name, "@" + self.name, self.identifier] + def set_active(self): self.presence = "active" for channel in self.server.channels: @@ -744,6 +768,46 @@ class User(SlackThing): #reply = async_slack_api_request("im.open", {"channel":self.identifier,"ts":t}) async_slack_api_request(self.server.domain, self.server.token, "im.open", {"user": self.identifier, "ts": t}) +class Message(object): + + def __init__(self, message_json): + self.message_json = message_json + self.ts = message_json['ts'] + #split timestamp into time and counter + self.ts_time, self.ts_counter = message_json['ts'].split('.') + + def change_text(self, new_text): + self.message_json["text"] = new_text + + def add_reaction(self, reaction): + if "reactions" in self.message_json: + found = False + for r in self.message_json["reactions"]: + if r["name"] == reaction: + r["count"] += 1 + found = True + if not found: + self.message_json["reactions"].append({u"count": 1, u"name": reaction}) + else: + self.message_json["reactions"] = [{u"count": 1, u"name": reaction}] + + def remove_reaction(self, reaction): + if "reactions" in self.message_json: + for r in self.message_json["reactions"]: + if r["name"] == reaction: + r["count"] -= 1 + else: + pass + + def __eq__(self, other): + return self.ts_time == other or self.ts == other + + def __repr__(self): + return "{} {} {} {}\n".format(self.ts_time, self.ts_counter, self.ts, self.message_json) + + def __lt__(self, other): + return self.ts < other.ts + def slack_command_cb(data, current_buffer, args): a = args.split(' ', 1) @@ -756,7 +820,6 @@ def slack_command_cb(data, current_buffer, args): command = cmds[function_name](current_buffer, args) except KeyError: w.prnt("", "Command not found: " + function_name) - return w.WEECHAT_RC_OK @@ -847,14 +910,28 @@ def command_channels(current_buffer, args): def command_nodistractions(current_buffer, args): global hide_distractions hide_distractions = not hide_distractions - if distracting_channels[0] != "": + if distracting_channels != ['']: for channel in distracting_channels: try: - w.buffer_set(channels.find(channel).channel_buffer, "hidden", str(int(hide_distractions))) + channel_buffer = channels.find(channel).channel_buffer + if channel_buffer: + w.buffer_set(channels.find(channel).channel_buffer, "hidden", str(int(hide_distractions))) except: dbg("Can't hide channel {}".format(channel), main_buffer=True) +def command_distracting(current_buffer, args): + global distracting_channels + distracting_channels = [x.strip() for x in w.config_get_plugin("distracting_channels").split(',')] + fullname = channels.find(current_buffer).fullname() + if distracting_channels.count(fullname) == 0: + distracting_channels.append(fullname) + else: + distracting_channels.pop(distracting_channels.index(fullname)) + new = ','.join(distracting_channels) + w.config_set_plugin('distracting_channels', new) + + @slack_buffer_required def command_users(current_buffer, args): """ @@ -875,7 +952,6 @@ def command_setallreadmarkers(current_buffer, args): for channel in channels: channel.mark_read() - def command_changetoken(current_buffer, args): w.config_set_plugin('slack_api_token', args) @@ -916,20 +992,9 @@ def command_markread(current_buffer, args): if servers.find(domain).channels.find(channel): servers.find(domain).channels.find(channel).mark_read() -def command_cacheinfo(current_buffer, args): - for channel in message_cache.keys(): - c = channels.find(channel) - w.prnt("", "{} {}".format(channels.find(channel), len(message_cache[channel]))) -# server.buffer_prnt("{} {}".format(channels.find(channel), len(message_cache[channel]))) - def command_flushcache(current_buffer, args): global message_cache - message_cache = {} - cache_write_cb("","") - -def command_uncache(current_buffer, args): - identifier = channels.find(current_buffer).identifier - message_cache.pop(identifier) + message_cache = [] cache_write_cb("","") def command_cachenow(current_buffer, args): @@ -1053,9 +1118,10 @@ def process_reply(message_json): identifier = message_json["reply_to"] item = server.message_buffer.pop(identifier) if "type" in item: - if item["type"] == "message": + if item["type"] == "message" and "channel" in item.keys(): item["ts"] = message_json["ts"] - cache_message(item, from_me=True) + channels.find(item["channel"]).cache_message(item, from_me=True) + channels.find(item["channel"]).buffer_prnt(item["user"], item["text"], item["ts"]) dbg("REPLY {}".format(item)) def process_pong(message_json): @@ -1065,7 +1131,7 @@ def process_pong(message_json): def process_team_join(message_json): server = servers.find(message_json["myserver"]) item = message_json["user"] - server.users.append(User(server, item["name"], item["id"], item["presence"])) + server.add_user(User(server, item["name"], item["id"], item["presence"])) server.buffer_prnt(server.buffer, "New user joined: {}".format(item["name"])) def process_manual_presence_change(message_json): @@ -1073,13 +1139,11 @@ def process_manual_presence_change(message_json): def process_presence_change(message_json): server = servers.find(message_json["myserver"]) - nick = message_json.get("user", server.nick) - buffer_name = "{}.{}".format(domain, nick) - buf_ptr = w.buffer_search("", buffer_name) + identifier = message_json.get("user", server.nick) if message_json["presence"] == 'active': - users.find(nick).set_active() + server.users.find(identifier).set_active() else: - users.find(nick).set_inactive() + server.users.find(identifier).set_inactive() def process_channel_marked(message_json): @@ -1103,7 +1167,7 @@ def process_channel_created(message_json): server.channels.find(message_json["channel"]["name"]).open(False) else: item = message_json["channel"] - server.channels.append(Channel(server, item["name"], item["id"], False)) + server.add_channel(Channel(server, item["name"], item["id"], False)) server.buffer_prnt("New channel created: {}".format(item["name"])) @@ -1130,7 +1194,7 @@ def process_channel_joined(message_json): server.channels.find(message_json["channel"]["name"]).open(False) else: item = message_json["channel"] - server.channels.append(Channel(server, item["name"], item["id"], item["is_open"], item["last_read"], "#", item["members"], item["topic"]["value"])) + server.add_channel(Channel(server, item["name"], item["id"], item["is_open"], item["last_read"], "#", item["members"], item["topic"]["value"])) def process_channel_leave(message_json): @@ -1139,7 +1203,7 @@ def process_channel_leave(message_json): channel.user_leave(message_json["user"]) -def process_channel_archive(message_json): +def process_channel_archive(message_json): server = servers.find(message_json["myserver"]) channel = server.channels.find(message_json["channel"]) channel.detach_buffer() @@ -1156,7 +1220,7 @@ def process_group_joined(message_json): server.channels.find(message_json["channel"]["name"]).open(False) else: item = message_json["channel"] - server.channels.append(GroupChannel(server, item["name"], item["id"], item["is_open"], item["last_read"], "#", item["members"], item["topic"]["value"])) + server.add_channel(GroupChannel(server, item["name"], item["id"], item["is_open"], item["last_read"], "#", item["members"], item["topic"]["value"])) def process_group_archive(message_json): @@ -1189,7 +1253,7 @@ def process_im_created(message_json): server.channels.find(channel_name).open(False) else: item = message_json["channel"] - server.channels.append(DmChannel(server, channel_name, item["id"], item["is_open"], item["last_read"])) + server.add_channel(DmChannel(server, channel_name, item["id"], item["is_open"], item["last_read"])) server.buffer_prnt("New channel created: {}".format(item["name"])) @@ -1202,54 +1266,62 @@ def process_user_typing(message_json): # todo: does this work? - def process_error(message_json): pass #connected = False -# def process_message_changed(message_json): -# process_message(message_json) +def process_reaction_added(message_json): + channel = channels.find(message_json["item"]["channel"]) + channel.add_reaction(message_json["item"]["ts"], message_json["reaction"]) -def cache_message(message_json, from_me=False): - global message_cache - if from_me: - server = channels.find(message_json["channel"]).server - message_json["user"] = server.users.find(server.nick).identifier - channel = message_json["channel"] - if channel not in message_cache: - message_cache[channel] = [] - if message_json not in message_cache[channel]: - message_cache[channel].append(message_json) - if len(message_cache[channel]) > BACKLOG_SIZE: - message_cache[channel] = message_cache[channel][-BACKLOG_SIZE:] - - -def modify_buffer_line(buffer, user, new_message, time, append): - time = int(float(time)) - own_lines = w.hdata_pointer(w.hdata_get('buffer'), buffer, 'own_lines') - if own_lines: - line = w.hdata_pointer(w.hdata_get('lines'), own_lines, 'last_line') - hdata_line = w.hdata_get('line') - hdata_line_data = w.hdata_get('line_data') - - while line: - data = w.hdata_pointer(hdata_line, line, 'data') - if data: - date = w.hdata_time(hdata_line_data, data, 'date') - prefix = w.hdata_string(hdata_line_data, data, 'prefix') - if user and (int(date) == int(time) and user == prefix): +def process_reaction_removed(message_json): + channel = channels.find(message_json["item"]["channel"]) + channel.remove_reaction(message_json["item"]["ts"], message_json["reaction"]) + +def create_reaction_string(reactions): + if not isinstance(reactions, list): + reaction_string = " [{}]".format(reactions) + else: + reaction_string = ' [' + count = 0 + for r in reactions: + if r["count"] > 0: + count += 1 + reaction_string += ":{}:{} ".format(r["name"], r["count"]) + reaction_string = reaction_string[:-1] + ']' + if count == 0: + reaction_string = '' + return reaction_string + +# deprecated in favor of redrawing the entire buffer +#def modify_buffer_line(buffer, user, new_message, time, append): +# time = int(float(time)) +# own_lines = w.hdata_pointer(w.hdata_get('buffer'), buffer, 'own_lines') +# if own_lines: +# line = w.hdata_pointer(w.hdata_get('lines'), own_lines, 'last_line') +# hdata_line = w.hdata_get('line') +# hdata_line_data = w.hdata_get('line_data') +# +# while line: +# data = w.hdata_pointer(hdata_line, line, 'data') +# if data: +# date = w.hdata_time(hdata_line_data, data, 'date') +# prefix = w.hdata_string(hdata_line_data, data, 'prefix') +# if new_message == "": +# new_message = w.hdata_string(hdata_line_data, data, 'message') +# if user and (int(date) == int(time) and user == prefix): # w.prnt("", "found matching time date is {}, time is {} ".format(date, time)) - w.hdata_update(hdata_line_data, data, {"message": "{}{}".format(new_message, append)}) - break - elif not user and (int(date) == int(time)): - w.hdata_update(hdata_line_data, data, {"message": "{}{}".format(new_message, append)}) - else: - pass - line = w.hdata_move(hdata_line, line, -1) - return w.WEECHAT_RC_OK +# w.hdata_update(hdata_line_data, data, {"message": "{}{}".format(new_message, append)}) +# break +# elif not user and (int(date) == int(time)): +# w.hdata_update(hdata_line_data, data, {"message": "{}{}".format(new_message, append)}) +# else: +# pass +# line = w.hdata_move(hdata_line, line, -1) +# return w.WEECHAT_RC_OK -def process_message(message_json): +def process_message(message_json, cache=True): try: # send these messages elsewhere known_subtypes = ['channel_join', 'channel_leave', 'channel_topic'] @@ -1268,8 +1340,6 @@ def process_message(message_json): dbg("message came for closed channel {}".format(channel.name)) return - cache_message(message_json) - time = message_json['ts'] if "fallback" in message_json: text = message_json["fallback"] @@ -1278,13 +1348,22 @@ def process_message(message_json): else: text = "" - text = unfurl_refs(text) + text = text.decode('utf-8') + + ignore_alt_text = False + if w.config_get_plugin('unfurl_ignore_alt_text') != "0": + ignore_alt_text = True + text = unfurl_refs(text, ignore_alt_text=ignore_alt_text) + if "attachments" in message_json: text += u" --- {}".format(unwrap_attachments(message_json)) text = text.lstrip() text = text.replace("\t", " ") name = get_user(message_json, server) + if "reactions" in message_json: + text += create_reaction_string(message_json["reactions"]) + text = text.encode('utf-8') name = name.encode('utf-8') @@ -1293,11 +1372,13 @@ def process_message(message_json): append = " (edited)" else: append = '' - channel.buffer_prnt_changed(message_json["message"]["user"], text, message_json["message"]["ts"], append) + channel.change_message(message_json["message"]["ts"], text + append) + cache=False elif "subtype" in message_json and message_json["subtype"] == "message_deleted": append = "(deleted)" text = "" - channel.buffer_prnt_changed(None, text, message_json["deleted_ts"], append) + channel.change_message(message_json["deleted_ts"], text + append) + cache = False elif message_json.get("subtype", "") == "channel_leave": channel.buffer_prnt(w.prefix("quit").rstrip(), text, time) elif message_json.get("subtype", "") == "channel_join": @@ -1306,9 +1387,12 @@ def process_message(message_json): channel.buffer_prnt(w.prefix("network").rstrip(), text, time) else: channel.buffer_prnt(name, text, time) - except: - dbg("cannot process message {}".format(message_json)) + if cache: + channel.cache_message(message_json) + + except Exception: + dbg("cannot process message {}\n{}".format(message_json, traceback.format_exc())) def unwrap_message(message_json): if "message" in message_json: @@ -1337,18 +1421,28 @@ def unwrap_attachments(message_json): return attachment_text -def unfurl_refs(text): +def unfurl_refs(text, ignore_alt_text=False): + """ + Worst code ever written. this needs work + """ if text.find('<') > -1: newtext = [] text = text.split(" ") for item in text: # dbg(item) + prefix = "" + suffix = "" start = item.find('<') end = item.find('>') if start > -1 and end > -1: + prefix = item[:start] + suffix = item[end+1:] item = item[start + 1:end] if item.find('|') > -1: - item = item.split('|')[0] + if ignore_alt_text: + item = item.split('|')[1] + else: + item = item.split('|')[0] if item.startswith('@U'): if users.find(item[1:]): try: @@ -1358,7 +1452,7 @@ def unfurl_refs(text): if item.startswith('#C'): if channels.find(item[1:]): item = "{}".format(channels.find(item[1:]).name) - newtext.append(item) + newtext.append(prefix + item + suffix) text = " ".join(newtext) return text else: @@ -1382,7 +1476,7 @@ def get_user(message_json, server): def typing_bar_item_cb(data, buffer, args): - typers = [x for x in channels.get_all() if x.is_someone_typing()] + typers = [x for x in channels if x.is_someone_typing()] if len(typers) > 0: direct_typers = [] channel_typers = [] @@ -1416,7 +1510,6 @@ def buffer_list_update_cb(data, remaining_calls): gray_check = False if len(servers) > 1: gray_check = True - # for channel in channels.find_by_class(Channel) + channels.find_by_class(GroupChannel): for channel in channels: channel.rename() buffer_list_update = False @@ -1466,15 +1559,18 @@ def typing_notification_cb(signal, sig_type, data): typing_timer = now return w.WEECHAT_RC_OK -# NOTE: figured i'd do this because they do - - def slack_ping_cb(data, remaining): + """ + Periodic websocket ping to detect broken connection. + """ servers.find(data).ping() return w.WEECHAT_RC_OK def slack_connection_persistence_cb(data, remaining_calls): + """ + Reconnect if a connection is detected down + """ for server in servers: if not server.connected: server.buffer_prnt("Disconnected from slack, trying to reconnect..") @@ -1494,11 +1590,62 @@ def slack_never_away_cb(data, remaining): server.send_to_websocket(request, expect_reply=False) return w.WEECHAT_RC_OK -# Slack specific requests -# NOTE: switched to async/curl because sync slowed down the UI +def nick_completion_cb(data, completion_item, buffer, completion): + """ + Adds all @-prefixed nicks to completion list + """ + + channel = channels.find(buffer) + if channel is None or channel.members is None: + return w.WEECHAT_RC_OK + for m in channel.members: + user = channel.server.users.find(m) + w.hook_completion_list_add(completion, "@" + user.name, 1, w.WEECHAT_LIST_POS_SORT) + return w.WEECHAT_RC_OK + + +def complete_next_cb(data, buffer, command): + """Extract current word, if it is equal to a nick, prefix it with @ and + rely on nick_completion_cb adding the @-prefixed versions to the + completion lists, then let Weechat's internal completion do its + thing + + """ + + channel = channels.find(buffer) + if channel is None or channel.members is None: + return w.WEECHAT_RC_OK + input = w.buffer_get_string(buffer, "input") + current_pos = w.buffer_get_integer(buffer, "input_pos") - 1 + input_length = w.buffer_get_integer(buffer, "input_length") + word_start = 0 + word_end = input_length + # If we're on a non-word, look left for something to complete + while current_pos >= 0 and input[current_pos] != '@' and not input[current_pos].isalnum(): + current_pos = current_pos - 1 + for l in range(current_pos, 0, -1): + if input[l] != '@' and not input[l].isalnum(): + word_start = l + 1 + break + for l in range(current_pos, input_length): + if not input[l].isalnum(): + word_end = l + break + word = input[word_start:word_end] + for m in channel.members: + user = channel.server.users.find(m) + if user.name == word: + # Here, we cheat. Insert a @ in front and rely in the @ + # nicks being in the completion list + w.buffer_set(buffer, "input", input[:word_start] + "@" + input[word_start:]) + w.buffer_set(buffer, "input_pos", str(w.buffer_get_integer(buffer, "input_pos") + 1)) + return w.WEECHAT_RC_OK_EAT + return w.WEECHAT_RC_OK +# Slack specific requests +# NOTE: switched to async/curl because sync slowed down the UI def async_slack_api_request(domain, token, request, post_data, priority=False): if not STOP_TALKING_TO_SLACK: post_data["token"] = token @@ -1511,7 +1658,7 @@ def async_slack_api_request(domain, token, request, post_data, priority=False): big_data = {} def url_processor_cb(data, command, return_code, out, err): - global big_data, message_cache + global big_data data = pickle.loads(data) identifier = sha.sha("{}{}".format(data, command)).hexdigest() if identifier not in big_data: @@ -1530,6 +1677,7 @@ def url_processor_cb(data, command, return_code, out, err): if my_json: if data["request"] == 'rtm.start': servers.find(data["token"]).connected_to_slack(my_json) + servers.update_hashtable() else: if "channel" in data["post_data"]: @@ -1551,10 +1699,32 @@ def url_processor_cb(data, command, return_code, out, err): return w.WEECHAT_RC_OK def cache_write_cb(data, remaining): - open("{}/{}".format(WEECHAT_HOME, CACHE_NAME), 'w').write(json.dumps(message_cache)) + cache_file = open("{}/{}".format(WEECHAT_HOME, CACHE_NAME), 'w') + for channel in channels: + if channel.active: + for message in channel.messages: + cache_file.write("{}\n".format(json.dumps(message.message_json))) return w.WEECHAT_RC_OK - +def cache_load(): + global message_cache + try: + file_name = "{}/{}".format(WEECHAT_HOME, CACHE_NAME) + if sum(1 for line in open('myfile.txt')) > 2: + cache_file = open(file_name, 'r') + for line in cache_file: + message_cache.append(line) + except IOError: + #cache file didn't exist + pass + +def cache_get(channel): + lines = [] + for line in message_cache: + j = json.loads(line) + if j["channel"] == channel: + lines.append(line) + return lines # END Slack specific requests @@ -1632,17 +1802,33 @@ def config_changed_cb(data, option, value): return w.WEECHAT_RC_OK def quit_notification_cb(signal, sig_type, data): - global STOP_TALKING_TO_SLACK - STOP_TALKING_TO_SLACK = True - cache_write_cb("", "") - return w.WEECHAT_RC_OK + stop_talking_to_slack() def script_unloaded(): + stop_talking_to_slack() + return w.WEECHAT_RC_OK + +def stop_talking_to_slack(): + """ + Prevents a race condition where quitting closes buffers + which triggers leaving the channel because of how close + buffer is handled + """ global STOP_TALKING_TO_SLACK STOP_TALKING_TO_SLACK = True cache_write_cb("", "") return w.WEECHAT_RC_OK +def scrolled_cb(signal, sig_type, data): + try: + if w.window_get_integer(data, "scrolling") == 1: + channels.find(w.current_buffer()).set_scrolling() + else: + channels.find(w.current_buffer()).unset_scrolling() + except: + pass + return w.WEECHAT_RC_OK + # END Utility Methods # Main @@ -1666,6 +1852,8 @@ if __name__ == "__main__": w.config_set_plugin('colorize_nicks', "1") if not w.config_get_plugin('trigger_value'): w.config_set_plugin('trigger_value', "0") + if not w.config_get_plugin('unfurl_ignore_alt_text'): + w.config_set_plugin('unfurl_ignore_alt_text', "0") version = w.info_get("version_number", "") or 0 if int(version) >= 0x00040400: @@ -1688,25 +1876,20 @@ if __name__ == "__main__": buffer_list_update = False previous_buffer_list_update = 0 - #name = None never_away = False hide_distractions = False hotlist = w.infolist_get("hotlist", "", "") main_weechat_buffer = w.info_get("irc_buffer", "{}.{}".format(domain, "DOESNOTEXIST!@#$")) - try: - cache_file = open("{}/{}".format(WEECHAT_HOME, CACHE_NAME), 'r') - message_cache = json.loads(cache_file.read()) - except (IOError, ValueError): - message_cache = {} - # End global var section + message_cache = [] + cache_load() - #channels = SearchList() servers = SearchList() for token in slack_api_token.split(','): - servers.append(SlackServer(token)) - channels = Meta('channels', servers) - users = Meta('users', servers) + server = SlackServer(token) + servers.append(server) + channels = SearchList() + users = SearchList() w.hook_config("plugins.var.python." + SCRIPT_NAME + ".*", "config_changed_cb", "") w.hook_timer(3000, 0, 0, "slack_connection_persistence_cb", "") @@ -1722,6 +1905,7 @@ if __name__ == "__main__": w.hook_signal('window_switch', "buffer_switch_cb", "") w.hook_signal('input_text_changed', "typing_notification_cb", "") w.hook_signal('quit', "quit_notification_cb", "") + w.hook_signal('window_scrolled', "scrolled_cb", "") w.hook_command( # Command name and description 'slack', 'Plugin to allow typing notification and sync of read markers for slack.com', @@ -1741,5 +1925,8 @@ if __name__ == "__main__": w.hook_command_run('/join', 'join_command_cb', '') w.hook_command_run('/part', 'part_command_cb', '') w.hook_command_run('/leave', 'part_command_cb', '') + w.hook_command_run("/input complete_next", "complete_next_cb", "") + w.hook_completion("nicks", "complete @-nicks for slack", + "nick_completion_cb", "") w.bar_item_new('slack_typing_notice', 'typing_bar_item_cb', '') # END attach to the weechat hooks we need |