diff --git a/.gitignore b/.gitignore index 9631938..23b1b1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /fishroom/config.py /test/config.py config.py.production +**/__pycache__ +**/*.py[cod] diff --git a/fishroom/DiscourseBabble.py b/fishroom/DiscourseBabble.py new file mode 100644 index 0000000..26865d6 --- /dev/null +++ b/fishroom/DiscourseBabble.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +Support for the Discourse Babble plugin: +https://github.com/gdpelican/babble +""" +import requests +import requests.exceptions +import tornado +import tornado.web + +import time +import re + +from . import xss + +from .base import BaseBotInstance, EmptyBot +from .bus import MessageBus, MsgDirection +from .models import ( + Message, ChannelType, MessageType, RichText, TextStyle, Color +) +from .textformat import TextFormatter +from .helpers import get_now_date_time, get_logger +from .config import config + +logger = get_logger("DiscourseBabble") + +IRC_COLOR_RGB = [ + '#ffffff', # 0 + '#000000', # 1 + '#00007f', + '#009300', + '#ff0000', + '#7f0000', + '#9c009c', + '#fc7f00', + '#ffff00', + '#00fc00', + '#009300', + '#00ffff', + '#0000fc', + '#ff00ff', + '#7f7f7f', + '#d2d2d2', #15 + '#888', # 16 +] + + +def getWebhookHandler(dbh): + class WebhookHandler(tornado.web.RequestHandler): + def post(self): + """ + Process the JSON post object. + """ + json = tornado.escape.json_decode(self.request.body) + post = json.get('post', None) + if post: + # new api + topic_id = post.get('id', 0) + current_user = post.get('username', '未知用户') + # get bbcode/markdown hybrid: + # more consice than html, still allows for image links + message = post.get('raw', '未知消息') + dbh.on_sendmessage(topic_id, current_user, message) + else: + topic_id = json.get('topic_id', 0) + current_user = json.get('current_user', '未知用户') + # what kind of data is this? + message = json.get('message', '未知消息') + dbh.on_sendmessage(topic_id, current_user, message) + self.write('Got a message.') + return WebhookHandler + +class DiscourseBabbleHandle(BaseBotInstance): + ChanTag = ChannelType.DiscourseBabble + SupportMultiline = True + send_to_bus = None + def __init__(self, base_url, username, api_key, topic_ids): + debug=config['debug'] + self.username = username + self.base_url = base_url + self.api_key = api_key + self.topic_ids = topic_ids + application = tornado.web.Application([ + (r"/sendmessage", getWebhookHandler(self)), + ],debug=debug, autoreload=debug) + application.listen(config['babble']['webhook_port'], address=config['babble'].get('webhook_host', '0.0.0.0')) + + def listen(self): + tornado.ioloop.IOLoop.instance().start() + + def on_sendmessage(self, topic_id, current_user, message): + if current_user == self.username: + return + date, time = get_now_date_time() + logger.debug(message) + mtype = MessageType.Text + # Replace discourse-relative media with absolute links + message = re.sub(r'!\[.*\]\(/uploads/.+\)', + r'![\1](' + self.base_url + r'/uploads/\2)', + message) + msg = Message( + ChannelType.DiscourseBabble, + current_user, topic_id, + xss.cooked_unescape(message), + mtype=mtype, media_url='', + date=date, time=time + ) + self.send_to_bus(self,msg) + + def do_send_request(self, topic_id, text): + if topic_id not in self.topic_ids: + return + requests.post(self.base_url + + '/babble/topics/' + + '%d' % int(topic_id) + + '/posts', + data = { + 'api_user': self.username, + 'api_key': self.api_key, + 'raw': text, + 'topic_id': '%d'%int(topic_id) + }) + + def send_msg(self, target, content, sender=None, first=False, raw=None, **kwargs): + # --- Pick a color from username + # color that fits both dark and light background + color_avail = (2, 3, 4, 5, 6, 7, 10, 12, 13) + color = None + + if sender: + # color defined at http://www.mirc.com/colors.html + # background_num = sum([ord(i) for i in sender]) % 16 + cidx = sum([ord(i) for i in sender]) % len(color_avail) + foreground_num = color_avail[cidx] + color = Color(foreground_num) + + reply_quote = "" + if 'reply_text' in kwargs: + reply_to = kwargs['reply_to'] + reply_text = kwargs['reply_text'] + if len(reply_text) > 8: + reply_text = reply_text[:8] + '...' + reply_text = reply_text.strip() + reply_quote = "[b]{reply_to}[/b]
{reply_text}".format(locals()) + + try: + channel = raw.channel.capitalize() + channel = channel.replace('Babble', config['babble'].get('site_name', 'Discourse')) + except AttributeError: + channel = None + msg = self.rich_message(content, sender=sender, color=color, + reply_quote=reply_quote, channel=channel) + msg = self.formatRichText(msg) + if raw is not None: + if raw.mtype in (MessageType.Photo, MessageType.Sticker): + msg += "\n![](%s)" % (raw.media_url,) + self.do_send_request(target, msg) + time.sleep(0.5) + + def rich_message(self, content, sender=None, color=None, reply_quote="", channel=""): + if color and sender: + return RichText([ + (TextStyle(color=color, bold=1), "{}".format(sender)), + (TextStyle(color=Color(16)), " {} 用户\n".format(channel)), + (TextStyle(color=Color(16)), "{}\n".format(reply_quote)), + (TextStyle(), "{}".format(xss.md_escape(content))), + ]) + else: + tmpl = "{content}" if sender is None else "[{sender}] {content}" + return RichText([ + (TextStyle(), tmpl.format(content=content, sender=sender)) + ]) + + def formatRichText(self, rich_text: RichText): + formated_text = "" + for ts, text in rich_text: + if not text: + continue + if ts.is_normal(): + formated_text += text + continue + def bold(text): + if not ts.is_bold(): + return text + return "[b]{}[/b]".format(text) + def italic(text): + if not ts.is_italic(): + return text + return "[i]{}[/i]".format(text) + def underline(text): + if not ts.is_underline(): + return text + return "[u]{}[/u]".format(text) + def fgcolor(text, color): + return '[color={}]{}[/color]'.format(IRC_COLOR_RGB[color], text) + def bgcolor(text, color): + return '[bgcolor={}]{}[/bgcolor]'.format(IRC_COLOR_RGB[color], text) + def color(text): + if not ts.has_color(): + return text + if ts.color.bg: + return bgcolor(fgcolor(text,ts.color.fg), ts.color.bg) + else: + return fgcolor(text, ts.color.fg) + formated_text += (underline(italic(bold(color(text))))) + return formated_text + +def Babble2FishroomThread(disbbl_handle: DiscourseBabbleHandle, bus: MessageBus): + if disbbl_handle is None or isinstance(disbbl_handle, EmptyBot): + return + def send_to_bus(self, msg): + logger.debug(msg) + bus.publish(msg) + disbbl_handle.send_to_bus=send_to_bus + disbbl_handle.listen() + + +def Fishroom2BabbleThread(disbbl_handle, bus): + if disbbl_handle is None or isinstance(disbbl_handle, EmptyBot): + return + for msg in bus.message_stream(): + disbbl_handle.forward_msg_from_fishroom(msg) + +def init(): + from .db import get_redis + redis_client = get_redis() + im2fish_bus = MessageBus(redis_client, MsgDirection.im2fish) + fish2im_bus = MessageBus(redis_client, MsgDirection.fish2im) + + babble_idnumbers = [b["babble"] for _, b in config['bindings'].items() if "babble" in b] + base_url = config['babble']['base_url'] + username = config['babble']['username'] + api_key = config['babble']['api_key'] + + return ( + DiscourseBabbleHandle(base_url, username, api_key, babble_idnumbers), + im2fish_bus, fish2im_bus, + ) + + +def main(): + if "babble" not in config: + logger.error("Babble config not found in config.py! exiting...") + return + + from .runner import run_threads + bot, im2fish_bus, fish2im_bus = init() + run_threads([ + (Babble2FishroomThread, (bot, im2fish_bus, ), ), + (Fishroom2BabbleThread, (bot, fish2im_bus, ), ), + ]) + +main() +# vim: ts=4 sw=4 sts=4 expandtab diff --git a/fishroom/config.py.example b/fishroom/config.py.example index 1c19131..aab06e3 100644 --- a/fishroom/config.py.example +++ b/fishroom/config.py.example @@ -54,6 +54,14 @@ config = { # "bot_msg_pattern": "^mubot|^!wikipedia", # }, + # Uncomment these if you want discourse babble access + # "babble": { + # "username": "fishroom", + # "api_key": "", + # "base_url": "https://forum.foo.moe/", + # "webhook_port": 2334 + # }, + # Uncomment these if you want WeChat access # "wechat": {}, diff --git a/fishroom/models.py b/fishroom/models.py index d838baa..c59dab9 100755 --- a/fishroom/models.py +++ b/fishroom/models.py @@ -16,6 +16,7 @@ class ChannelType(object): Wechat = "wechat" Web = "web" API = "api" + DiscourseBabble = "babble" class MessageType(object): diff --git a/fishroom/photostore.py b/fishroom/photostore.py index df1f582..6464db6 100644 --- a/fishroom/photostore.py +++ b/fishroom/photostore.py @@ -5,6 +5,7 @@ import requests.exceptions from base64 import b64encode from .helpers import get_logger +import shutil,os logger = get_logger(__name__) @@ -15,6 +16,13 @@ class BasePhotoStore(object): def upload_image(self, filename, **kwargs): raise Exception("Not Implemented") +class LocalPhotoStore(object): + def __init__(self, path, **kwargs): + self.path = path + + def upload_image(self, filename=None, filedata=None, **kwargs): + if filedata is None: + shutil.copy2(filename, os.path.join(path, os.path.dirname(filename))) class Imgur(BasePhotoStore): @@ -64,10 +72,8 @@ def upload_image(self, filename=None, filedata=None, **kwargs): class VimCN(BasePhotoStore): - url = "https://img.vim-cn.com/" - - def __init__(self, **kwargs): - pass + def __init__(self, url="https://img.vim-cn.com/", **kwargs): + self.url = url def upload_image(self, filename=None, filedata=None, **kwargs) -> str: if filedata is None: diff --git a/fishroom/telegram.py b/fishroom/telegram.py index 6727f79..b2462e0 100644 --- a/fishroom/telegram.py +++ b/fishroom/telegram.py @@ -667,7 +667,7 @@ def send_msg(self, peer, content, sender=None, escape=True, rich_text=None, self._must_post(api, json=data) def msg_tmpl(self, sender=None): - return "{content}" if sender is None else "[{sender}] {content}" + return "{content}" if sender is None else "{sender}\n{content}" @classmethod def formatRichText(cls, rich_text: RichText, escape=True): @@ -712,7 +712,7 @@ def photo_store_init(): options = config['photo_store']['options'] return Imgur(**options) elif provider == "vim-cn": - return VimCN() + return VimCN(**config['photo_store']['options']) elif provider == "qiniu": return get_qiniu(redis_client, config) diff --git a/fishroom/wechat.py b/fishroom/wechat.py index d160973..8c562f3 100644 --- a/fishroom/wechat.py +++ b/fishroom/wechat.py @@ -247,7 +247,7 @@ def init(): options = config['photo_store']['options'] photo_store = Imgur(**options) elif provider == "vim-cn": - photo_store = VimCN() + photo_store = VimCN(**config['photo_store']['options']) elif provider == "qiniu": photo_store = get_qiniu(redis_client, config) diff --git a/fishroom/xss.py b/fishroom/xss.py new file mode 100644 index 0000000..155bd3f --- /dev/null +++ b/fishroom/xss.py @@ -0,0 +1,27 @@ +#coding=utf-8 +import re +import html + +def md_escape(text): + replacements = [ + [r'\*', r'\\*'], + [r'#', r'\\#'], + [r'\/', r'\\/'], + [r'\(', r'\\('], + [r'\)', r'\\)'], + [r'\[', r'\\['], + [r'\]', r'\\]'], + [r'\<', r'<'], + [r'\>', r'>'], + [r'_', r'\\_'], + [r'`', r'\\`'], + ] + return replace(text, replacements) + +def replace(text,rs): + for r in rs: + text = re.sub(r[0], r[1], text) + return text + +def cooked_unescape(message): + return html.unescape(re.sub(r'<[^>]+>','', message))