├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── fishroom ├── IRC.py ├── __init__.py ├── api_client.py ├── base.py ├── bus.py ├── chatlogger.py ├── command.py ├── config.py.example ├── counter.py ├── db.py ├── dumpload.py ├── filestore.py ├── fishroom.py ├── gitter.py ├── helpers.py ├── matrix.py ├── models.py ├── photostore.py ├── plugins │ ├── __init__.py │ ├── hualao.py │ ├── imglink.py │ ├── pia.py │ ├── ratelimit.py │ ├── stats.py │ └── vote.py ├── runner.py ├── telegram.py ├── telegram_tg.py ├── textformat.py ├── textstore.py ├── web │ ├── __init__.py │ ├── __main__.py │ ├── base.html │ ├── chat_log.html │ ├── handlers.py │ ├── navbar.html │ ├── nickcolors.css │ ├── oauth.py │ └── text_store.html ├── wechat.py └── xmpp.py ├── requirements.txt └── test ├── __init__.py ├── test_pastebin.py └── test_vinergy.py /.gitignore: -------------------------------------------------------------------------------- 1 | /fishroom/config.py 2 | /test/config.py 3 | config.py.production 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5-slim 2 | 3 | RUN useradd fishroom 4 | USER fishroom 5 | 6 | # COPY fishroom /data/fishroom 7 | COPY requirements.txt /data/requirements.txt 8 | 9 | USER root 10 | 11 | # RUN echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ jessie main contrib non-free" > /etc/apt/sources.list && \ 12 | # echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ jessie-backports main contrib non-free" >> /etc/apt/sources.list && \ 13 | # echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ jessie-updates main contrib non-free" >> /etc/apt/sources.list && \ 14 | # echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian-security/ jessie/updates main contrib non-free" >> /etc/apt/sources.list 15 | 16 | # RUN echo "[global]" > /etc/pip.conf && \ 17 | # echo "index-url=https://pypi.tuna.tsinghua.edu.cn/simple" >> /etc/pip.conf 18 | 19 | RUN apt-get update && \ 20 | apt-get install -y libmagic1 libjpeg62-turbo libjpeg-dev libpng-dev libwebp-dev zlib1g zlib1g-dev gcc mime-support 21 | 22 | RUN python3 -m ensurepip && \ 23 | pip3 install --upgrade pip setuptools 24 | 25 | 26 | RUN pip3 install pillow && \ 27 | pip3 install -r /data/requirements.txt 28 | 29 | RUN apt-get remove -y libjpeg-dev libpng-dev libwebp-dev zlib1g-dev gcc && \ 30 | apt-get autoremove -y && \ 31 | apt-get clean all 32 | 33 | WORKDIR /data 34 | USER fishroom 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fishroom 2 | ![GPL license](https://img.shields.io/badge/license-GPL-blue.svg) 3 | ![Proudly Powered by Python3](https://img.shields.io/badge/python-3.5-blue.svg) 4 | [![](https://img.shields.io/badge/%23chat-fishroom-brightgreen.svg)](https://fishroom.tuna.moe/) 5 | 6 | Message forwarding for multiple IM protocols 7 | 8 | ## Motivation 9 | TUNA needs a chatroom, while each IM protocol/software has its own implementation for chatroom. 10 | 11 | Unlike email and mailing list, instant messaging is fragmented: everyone prefers different softwares. 12 | As a result, people of TUNA are divided by the IM they use, be it IRC, wechat, telegram, or XMPP. 13 | 14 | To reunify TUNA, we created this project to relay messages between IM clients, so that people can enjoy a 15 | big party again. 16 | 17 | ## Supported IMs 18 | 19 | - IRC 20 | - XMPP 21 | - [Matrix](https://matrix.org) 22 | - Telegram 23 | - Gitter 24 | - Actor (not yet) 25 | - Tox (not yet) 26 | - Wechat (maybe) 27 | 28 | ## Basic Architecture 29 | 30 | Fishroom consists of a *fishroom core* process, which routes messages among IMs and process commands, 31 | and several IM handler processes to deal with different IMs. These components are connected via Redis pub/sub. 32 | 33 | ``` 34 | +----------+ 35 | | IRC |<-+ 36 | +----------+ | 37 | +----------+ | 38 | | XMPP |<-+ 39 | +----------+ | 40 | +----------+ | +-------+ +---------------+ 41 | | Telegram |<-+--> | Redis | <---> | Fishroom Core | 42 | +----------+ | +-------+ +---------------+ 43 | +----------+ | 44 | | Gitter |<-+ 45 | +----------+ | 46 | +----------+ | 47 | | Web |<-+ 48 | +----------+ 49 | ``` 50 | 51 | ## How to Use 52 | 53 | Clone me first 54 | ``` 55 | git clone https://github.com/tuna/fishroom 56 | cd fishroom 57 | ``` 58 | 59 | ### Docker Rocks! 60 | 61 | Get a redis docker and run it: 62 | 63 | ``` 64 | docker pull redis:alpine 65 | docker run --name redis -v /var/lib/redis:/data -d redis:alpine 66 | ``` 67 | 68 | Modify the config file, and remember the redis hostname you specified in `config.py`. 69 | I suggest that just use `redis` as the hostname. 70 | 71 | ```bash 72 | mv fishroom/config.py.example fishroom/config.py 73 | vim fishroom/config.py 74 | ``` 75 | 76 | Modify `Dockerfile`, you may want to change the `sources.list` content. 77 | Build the docker for fishroom: 78 | 79 | ``` 80 | docker build --tag fishroom:dev . 81 | ``` 82 | 83 | Since the code of fishroom often changes, we mount the code as a volume, and link redis to it. 84 | 85 | You can test it using 86 | ``` 87 | # this is fishroom core 88 | docker run -it --rm --link redis:redis -v /path/to/fishroom/fishroom:/data/fishroom fishroom:dev python3 -u -m fishroom.fishroom 89 | 90 | # these are fishroom IM interfaces, not all of them are needed 91 | docker run -it --rm --link redis:redis -v /path/to/fishroom/fishroom:/data/fishroom fishroom:dev python3 -u -m fishroom.telegram 92 | docker run -it --rm --link redis:redis -v /path/to/fishroom/fishroom:/data/fishroom fishroom:dev python3 -u -m fishroom.IRC 93 | docker run -it --rm --link redis:redis -v /path/to/fishroom/fishroom:/data/fishroom fishroom:dev python3 -u -m fishroom.gitter 94 | docker run -it --rm --link redis:redis -v /path/to/fishroom/fishroom:/data/fishroom fishroom:dev python3 -u -m fishroom.xmpp 95 | ``` 96 | You may need `tmux` or simply multiple terminals to run the aforementioned foreground commands. 97 | 98 | If everything works, we run it as daemon. 99 | ``` 100 | docker run -d --name fishroom --link redis:redis -v /path/to/fishroom/fishroom:/data/fishroom fishroom:dev python3 -u -m fishroom.fishroom 101 | docker run -d --name fishroom --link redis:redis -v /path/to/fishroom/fishroom:/data/fishroom fishroom:dev python3 -u -m fishroom.telegram 102 | ``` 103 | 104 | To view the logs, use 105 | ``` 106 | docker logs fishroom 107 | ``` 108 | 109 | Next we run the web interface, if you have configured the `chat_logger` part in `config.py`. 110 | ``` 111 | docker run -d --name fishroom-web --link redis:redis -p 127.0.0.1:8000:8000 -v /path/to/fishroom/fishroom:/data/fishroom fishroom:dev python3 -u -m fishroom.web 112 | ``` 113 | Open your browser, and visit , you should be able to view the web UI of fishoom. 114 | 115 | 116 | ### Docker Sucks! 117 | 118 | Install and run redis first, assuming you use ubuntu or debian. 119 | 120 | ``` 121 | apt-get install redis 122 | ``` 123 | 124 | Modify the config file, the redis server should be on addr `127.0.0.1` and port `6379`. 125 | 126 | ```bash 127 | mv fishroom/config.py.example fishroom/config.py 128 | vim fishroom/config.py 129 | ``` 130 | 131 | Ensure your python version is at least 3.5, next, we install the dependencies for fishroom. 132 | 133 | ``` 134 | apt-get install -y python3-dev python3-pip libmagic1 libjpeg-dev libpng-dev libwebp-dev zlib1g-dev gcc 135 | pip3 install --upgrade pip setuptools 136 | pip3 install -r requirements.txt 137 | ``` 138 | 139 | Run fishroom and fishroom web. 140 | ``` 141 | # run fishroom core 142 | python3 -m fishroom.fishroom 143 | 144 | # start IM interfaces, select not all of them are needed 145 | python3 -m fishroom.telegram 146 | python3 -m fishroom.IRC 147 | python3 -m fishroom.gitter 148 | python3 -m fishroom.xmpp 149 | 150 | python3 -m fishroom.web 151 | ``` 152 | Open your browser, and visit , you should be able to view the web UI of fishoom. 153 | 154 | Good Luck! 155 | 156 | ## Related Projects 157 | 158 | - [Telegram2IRC](https://github.com/tuna/telegram2irc) 159 | - [Telegram Bot API](https://core.telegram.org/bots/api) 160 | - [IRCBindXMPP](https://github.com/lilydjwg/ircbindxmpp) 161 | - [SleekXMPP](https://pypi.python.org/pypi/sleekxmpp) 162 | - Multi-User Chat Supported (http://sleekxmpp.com/getting_started/muc.html) 163 | - [Tox-Sync](https://github.com/aitjcize/tox-irc-sync) 164 | - [qwx](https://github.com/xiangzhai/qwx) 165 | 166 | ## LICENSE 167 | 168 | ``` 169 | This program is free software: you can redistribute it and/or modify 170 | it under the terms of the GNU General Public License as published by 171 | the Free Software Foundation, either version 3 of the License, or 172 | (at your option) any later version. 173 | ``` 174 | -------------------------------------------------------------------------------- /fishroom/IRC.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import ssl 3 | import time 4 | import irc 5 | import irc.client 6 | import random 7 | from .base import BaseBotInstance, EmptyBot 8 | from .bus import MessageBus, MsgDirection 9 | from .models import ( 10 | Message, ChannelType, MessageType, RichText, TextStyle, Color 11 | ) 12 | from .textformat import TextFormatter, IRCCtrl 13 | from .helpers import get_now_date_time, get_logger 14 | from .config import config 15 | 16 | 17 | logger = get_logger("IRC") 18 | 19 | 20 | class IRCHandle(BaseBotInstance): 21 | """\ 22 | Handle IRC connection 23 | """ 24 | 25 | ChanTag = ChannelType.IRC 26 | 27 | def __init__(self, server, port, usessl, nickname, channels, blacklist=[], password=None): 28 | irc.client.ServerConnection.buffer_class.errors = 'replace' 29 | 30 | self.nickname = nickname 31 | self.channels = channels 32 | self.blacklist = set(blacklist) 33 | 34 | self.reactor = irc.client.Reactor() 35 | self.irc_conn = self.reactor.server() 36 | 37 | logger.info("connecting to {}:{}".format(server, port)) 38 | if usessl: 39 | logger.debug("using ssl to connect...") 40 | ssl_factory = irc.connection.Factory(wrapper=ssl.wrap_socket) 41 | self.irc_conn.connect( 42 | server, port, nickname, password=password, connect_factory=ssl_factory) 43 | else: 44 | self.irc_conn.connect(server, port, nickname, password=password) 45 | self.irc_conn.last_pong = time.time() 46 | self.reactor.scheduler.execute_every(60, self.keep_alive_ping) 47 | 48 | for msg in ("welcome", "join", "privmsg", "pubmsg", 49 | "action", "pong", "nicknameinuse"): 50 | self.irc_conn.add_global_handler(msg, getattr(self, "on_"+msg)) 51 | 52 | def __del__(self): 53 | self.irc_conn.disconnect("I'll be back") 54 | 55 | def keep_alive_ping(self): 56 | try: 57 | if time.time() - self.irc_conn.last_pong > 360: 58 | raise irc.client.ServerNotConnectedError('ping timeout!') 59 | self.irc_conn.last_pong = time.time() 60 | self.irc_conn.ping(self.irc_conn.get_server_name()) 61 | except irc.client.ServerNotConnectedError: 62 | logger.info('Reconnecting...') 63 | self.irc_conn.reconnect() 64 | self.irc_conn.last_pong = time.time() 65 | 66 | def on_pong(self, conn, event): 67 | conn.last_pong = time.time() 68 | 69 | def on_welcome(self, conn, event): 70 | for c in self.channels: 71 | if irc.client.is_channel(c): 72 | conn.join(c) 73 | 74 | def on_join(self, conn, event): 75 | logger.info(event.source + ' ' + event.target) 76 | 77 | def on_privmsg(self, conn, event): 78 | irc_nick = event.source[:event.source.index('!')] 79 | if irc_nick in self.blacklist: 80 | return 81 | 82 | rich_text = TextFormatter.parseIRC(event.arguments[0]) 83 | content = rich_text.toPlain() 84 | # if only normal text is available 85 | if len(rich_text) == 1 and rich_text[0][0].is_normal(): 86 | rich_text = None 87 | 88 | date, time = get_now_date_time() 89 | mtype = MessageType.Command \ 90 | if self.is_cmd(content) \ 91 | else MessageType.Text 92 | 93 | msg = Message( 94 | ChannelType.IRC, irc_nick, event.target, content, 95 | mtype=mtype, date=date, time=time, rich_text=rich_text 96 | ) 97 | self.send_to_bus(self, msg) 98 | 99 | def on_pubmsg(self, conn, event): 100 | return self.on_privmsg(conn, event) 101 | 102 | def on_action(self, conn, event): 103 | irc_nick = event.source[:event.source.index('!')] 104 | if irc_nick in self.blacklist: 105 | return 106 | content = random.choice(('🐠', '🐟', '🐡', '🐬', '🐳', '🐋', '🦈', '🐙')) + \ 107 | " {} {}".format(irc_nick, event.arguments[0]) 108 | date, time = get_now_date_time() 109 | mtype = MessageType.Event 110 | msg = Message( 111 | ChannelType.IRC, irc_nick, event.target, content, 112 | mtype=mtype, date=date, time=time 113 | ) 114 | self.send_to_bus(self, msg) 115 | 116 | def on_nicknameinuse(self, conn, event): 117 | conn.nick(conn.get_nickname() + "_") 118 | 119 | def rich_message(self, content, sender=None, color=None, reply_quote=""): 120 | if color and sender: 121 | return RichText([ 122 | (TextStyle(color=color), "[{}] ".format(sender)), 123 | (TextStyle(color=Color(15)), "{}".format(reply_quote)), 124 | (TextStyle(), "{}".format(content)), 125 | ]) 126 | else: 127 | tmpl = "{content}" if sender is None else "[{sender}] {content}" 128 | return RichText([ 129 | (TextStyle(), tmpl.format(content=content, sender=sender)) 130 | ]) 131 | 132 | def send_msg(self, target, content, sender=None, first=False, **kwargs): 133 | # color that fits both dark and light background 134 | color_avail = (2, 3, 4, 5, 6, 7, 10, 12, 13) 135 | color = None 136 | 137 | if sender: 138 | # color defined at http://www.mirc.com/colors.html 139 | # background_num = sum([ord(i) for i in sender]) % 16 140 | cidx = sum([ord(i) for i in sender]) % len(color_avail) 141 | foreground_num = color_avail[cidx] 142 | color = Color(foreground_num) # + ',' + str(background_num) 143 | 144 | reply_quote = "" 145 | if first and 'reply_text' in kwargs: 146 | reply_to = kwargs['reply_to'] 147 | reply_text = kwargs['reply_text'] 148 | if len(reply_text) > 8: 149 | reply_text = reply_text[:8] + '...' 150 | reply_quote = "「Re {reply_to}: {reply_text}」".format( 151 | reply_text=reply_text, reply_to=reply_to) 152 | 153 | msg = self.rich_message(content, sender=sender, color=color, 154 | reply_quote=reply_quote) 155 | msg = self.formatRichText(msg) 156 | try: 157 | self.irc_conn.privmsg(target, msg) 158 | except irc.client.ServerNotConnectedError: 159 | logger.warning("Server not connected") 160 | self.irc_conn.reconnect() 161 | except irc.client.InvalidCharacters: 162 | logger.warning("Invalid character in msg: %s", repr(msg)) 163 | time.sleep(0.5) 164 | 165 | def formatRichText(self, rich_text: RichText): 166 | formated_text = "" 167 | for ts, text in rich_text: 168 | if not text: 169 | continue 170 | if ts.is_normal(): 171 | formated_text += text 172 | continue 173 | ctrl = [] 174 | if ts.is_bold(): 175 | ctrl.append(IRCCtrl.BOLD) 176 | if ts.is_italic(): 177 | ctrl.append(IRCCtrl.ITALIC) 178 | if ts.is_underline(): 179 | ctrl.append(IRCCtrl.UNDERLINE) 180 | if ts.has_color(): 181 | ctrl.append(IRCCtrl.COLOR) 182 | if ts.color.bg: 183 | ctrl.append("{},{}".format(ts.color.fg, ts.color.bg)) 184 | else: 185 | ctrl.append("{}".format(ts.color.fg)) 186 | formated_text += "".join(ctrl) + text + IRCCtrl.RESET 187 | return formated_text 188 | 189 | def send_to_bus(self, msg): 190 | raise Exception("Not implemented") 191 | 192 | 193 | def IRC2FishroomThread(irc_handle: IRCHandle, bus: MessageBus): 194 | if irc_handle is None or isinstance(irc_handle, EmptyBot): 195 | return 196 | 197 | def send_to_bus(self, msg): 198 | bus.publish(msg) 199 | 200 | irc_handle.send_to_bus = send_to_bus 201 | irc_handle.reactor.process_forever(60) 202 | 203 | 204 | def Fishroom2IRCThread(irc_handle: IRCHandle, bus: MessageBus): 205 | if irc_handle is None or isinstance(irc_handle, EmptyBot): 206 | return 207 | for msg in bus.message_stream(): 208 | irc_handle.forward_msg_from_fishroom(msg) 209 | 210 | 211 | def init(): 212 | from .db import get_redis 213 | redis_client = get_redis() 214 | im2fish_bus = MessageBus(redis_client, MsgDirection.im2fish) 215 | fish2im_bus = MessageBus(redis_client, MsgDirection.fish2im) 216 | 217 | irc_channels = [b["irc"] for _, b in config['bindings'].items() if "irc" in b] 218 | server = config['irc']['server'] 219 | port = config['irc']['port'] 220 | nickname = config['irc']['nick'] 221 | usessl = config['irc']['ssl'] 222 | blacklist = config['irc']['blacklist'] 223 | password = config['irc']['password'] 224 | 225 | return ( 226 | IRCHandle(server, port, usessl, nickname, irc_channels, blacklist, password), 227 | im2fish_bus, fish2im_bus, 228 | ) 229 | 230 | 231 | def main(): 232 | if "irc" not in config: 233 | logger.error("IRC config not found in config.py! exiting...") 234 | return 235 | 236 | from .runner import run_threads 237 | bot, im2fish_bus, fish2im_bus = init() 238 | run_threads([ 239 | (IRC2FishroomThread, (bot, im2fish_bus, ), ), 240 | (Fishroom2IRCThread, (bot, fish2im_bus, ), ), 241 | ]) 242 | 243 | 244 | def test(): 245 | irc_channels = [b["irc"] for _, b in config['bindings'].items()] 246 | server = config['irc']['server'] 247 | port = config['irc']['port'] 248 | nickname = config['irc']['nick'] 249 | usessl = config['irc']['ssl'] 250 | 251 | irc_handle = IRCHandle(server, port, usessl, nickname, irc_channels) 252 | 253 | def send_to_bus(self, msg): 254 | print(msg.dumps()) 255 | irc_handle.send_to_bus = send_to_bus 256 | irc_handle.reactor.process_forever(60) 257 | 258 | 259 | if __name__ == '__main__': 260 | import argparse 261 | parser = argparse.ArgumentParser() 262 | parser.add_argument("--test", default=False, action="store_true") 263 | args = parser.parse_args() 264 | 265 | if args.test: 266 | test() 267 | else: 268 | main() 269 | 270 | # vim: ts=4 sw=4 sts=4 expandtab 271 | -------------------------------------------------------------------------------- /fishroom/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuna/fishroom/a76daf5b88bb116a136123b270d8064ddfca4401/fishroom/__init__.py -------------------------------------------------------------------------------- /fishroom/api_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | import hashlib 4 | from .config import config 5 | 6 | 7 | class TokenException(Exception): 8 | pass 9 | 10 | 11 | class APIClientManager(object): 12 | 13 | clients_key = config["redis"]["prefix"] + ":api_clients" 14 | clients_name_key = config["redis"]["prefix"] + ":api_clients_name" 15 | queue_key = config["redis"]["prefix"] + ":api:{token_id}" 16 | max_buffer = 15 17 | 18 | def __init__(self, r): 19 | self.r = r 20 | 21 | def publish(self, msg): 22 | clients = self.r.hgetall(self.clients_key) 23 | p = self.r.pipeline(transaction=False) 24 | for token_id in clients: 25 | k = self.queue_key.format(token_id=token_id.decode('utf-8')) 26 | p.rpush(k, msg.dumps()) 27 | p.ltrim(k, -self.max_buffer, -1) 28 | p.expire(k, 60) 29 | p.execute() 30 | 31 | def auth(self, token_id, token_key): 32 | saved = self.r.hget(self.clients_key, token_id) 33 | if not saved: 34 | return False 35 | 36 | m = hashlib.sha1() 37 | m.update(token_key.encode('utf-8')) 38 | return m.digest() == saved 39 | 40 | def list_clients(self): 41 | tokens = self.r.hgetall(self.clients_key) 42 | names_map = self.r.hgetall(self.clients_name_key) 43 | ids = [_id.decode('utf-8') for _id in tokens] 44 | names = [names_map.get(_id, b"nobot").decode('utf-8') for _id in tokens] 45 | return zip(ids, names) 46 | 47 | def add(self, token_id, token_key, name): 48 | if self.r.hexists(self.clients_key, token_id): 49 | raise TokenException("Token Id Existed!") 50 | 51 | m = hashlib.sha1() 52 | m.update(token_key.encode('utf-8')) 53 | self.r.hset(self.clients_key, token_id, m.digest()) 54 | self.r.hset(self.clients_name_key, token_id, name) 55 | 56 | def get_name(self, token_id): 57 | n = self.r.hget(self.clients_name_key, token_id) 58 | return n.decode('utf-8') if isinstance(n, bytes) else None 59 | 60 | def revoke(self, token_id): 61 | self.r.hdel(self.clients_key, args.token_id) 62 | queue = self.queue_key.format(token_id=token_id) 63 | self.r.delete(queue) 64 | 65 | def exists(self, token_id): 66 | return self.r.hexists(self.clients_key, args.token_id) 67 | 68 | 69 | if __name__ == "__main__": 70 | import sys 71 | import argparse 72 | import string 73 | import random 74 | from .db import get_redis 75 | 76 | def token_id_gen(N): 77 | return ''.join( 78 | random.choice(string.digits) 79 | for _ in range(N) 80 | ) 81 | 82 | def token_key_gen(N): 83 | return ''.join( 84 | random.choice(string.ascii_letters + string.digits) 85 | for _ in range(N) 86 | ) 87 | 88 | parser = argparse.ArgumentParser("API tokens management") 89 | subparsers = parser.add_subparsers(dest="command", help="valid subcommands") 90 | subparsers.add_parser('list', aliases=['l'], help="list tokens") 91 | sp = subparsers.add_parser('add', aliases=['a'], help="add a token") 92 | sp.add_argument('-n', '--name', required=True, help='bot name') 93 | sp.add_argument('token_id', nargs='?', default='', 94 | help='token id (auto generate if unspecified)') 95 | sp.add_argument('token_key', nargs='?', default='', 96 | help='token key (auto generate if unspecified)') 97 | sp = subparsers.add_parser('revoke', aliases=['r'], help="revoke a token") 98 | sp.add_argument('token_id', help='token_id') 99 | sp = subparsers.add_parser('test', help="test authenticating a token") 100 | sp.add_argument('token_id', help='token id') 101 | sp.add_argument('token_key', help='token key') 102 | subparsers.add_parser('help', help="print help") 103 | 104 | args = parser.parse_args() 105 | 106 | if args.command == "help": 107 | parser.print_help() 108 | sys.exit(0) 109 | 110 | r = get_redis() 111 | mgr = APIClientManager(r) 112 | 113 | if args.command in ("list", "l"): 114 | print("\n".join(["{}: {}".format(_id, n) 115 | for _id, n in mgr.list_clients()])) 116 | 117 | elif args.command in ("add", "a"): 118 | if args.token_id and args.token_key: 119 | token_id, token_key = args.token_id, args.token_key 120 | elif not (args.token_id or args.token_key): 121 | token_id, token_key = token_id_gen(8), token_key_gen(16) 122 | while mgr.exists(token_id): 123 | token_id = token_id_gen(8) 124 | else: 125 | print('Please specify both or neither of token_id and token_key') 126 | sys.exit(-1) 127 | try: 128 | mgr.add(token_id, token_key, args.name) 129 | except TokenException as e: 130 | print(e) 131 | else: 132 | print(token_id, token_key, args.name) 133 | 134 | elif args.command in ("revoke", "r"): 135 | yn = input("Revoke token_id: {}? Y/[N]:".format(args.token_id)) 136 | if yn.lower() == "y": 137 | mgr.revoke(args.token_id) 138 | else: 139 | print("Cancelled") 140 | 141 | elif args.command == "test": 142 | print(mgr.auth(args.token_id, args.token_key)) 143 | 144 | 145 | # vim: ts=4 sw=4 sts=4 expandtab 146 | -------------------------------------------------------------------------------- /fishroom/base.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | from typing import Tuple 4 | from .models import Message, MessageType, Color 5 | from .helpers import download_file 6 | from .command import LEADING_CHARS, parse_command 7 | 8 | 9 | class BaseBotInstance(object): 10 | 11 | ChanTag = None 12 | SupportMultiline = False 13 | SupportPhoto = False 14 | 15 | def send_msg(self, target: str, content: str, sender=None, **kwargs): 16 | pass 17 | 18 | def send_photo(self, target: str, photo_data: bytes): 19 | pass 20 | 21 | @classmethod 22 | def is_cmd(self, content) -> bool: 23 | if not ( 24 | (len(content) > 2) and 25 | (content[0] in LEADING_CHARS) and 26 | (content[1] not in LEADING_CHARS) 27 | ): 28 | return False 29 | 30 | try: 31 | cmd, args = parse_command(content) 32 | except: 33 | return False 34 | return (cmd is not None) 35 | 36 | def msg_tmpl(self, sender=None) -> str: 37 | return "{content}" if sender is None else "[{sender}] {content}" 38 | 39 | def match_nickname_content(self, content: str) -> Tuple[str, str]: 40 | m = re.match( 41 | r'^\[(?P.+?)\] (?P.*)', 42 | content, flags=re.UNICODE 43 | ) 44 | return (m.group('nick'), m.group('content')) if m else (None, None) 45 | 46 | def forward_msg_from_fishroom(self, msg: Message): 47 | if self.ChanTag == msg.channel and (not msg.botmsg): 48 | return 49 | 50 | route = msg.route 51 | if route is None: 52 | return 53 | 54 | target = route.get(self.ChanTag.lower()) 55 | if target is None: 56 | return 57 | 58 | if (msg.mtype == MessageType.Photo and self.SupportPhoto): 59 | if msg.media_url: 60 | photo_data, ptype = download_file(msg.media_url) 61 | if ptype is not None and ptype.startswith("image"): 62 | self.send_photo(target, photo_data, sender=msg.sender) 63 | return 64 | 65 | if msg.mtype == MessageType.Event: 66 | self.send_msg(target, msg.content, sender=None) 67 | return 68 | 69 | if self.SupportMultiline: 70 | sender = None if msg.botmsg else msg.sender 71 | self.send_msg( 72 | target, msg.content, sender=sender, rich_text=msg.rich_text, 73 | raw=msg, **msg.opt, 74 | ) 75 | return 76 | 77 | # when the agent does not support multi-line text, prefer long text URL 78 | # to line-by-line content 79 | text_url = msg.opt.get('text_url', None) 80 | if text_url is not None: 81 | lines = [text_url + " (long text)", ] 82 | else: 83 | lines = msg.lines 84 | 85 | for i, line in enumerate(lines): 86 | sender = None if msg.botmsg else msg.sender 87 | self.send_msg( 88 | target, content=line, sender=sender, rich_text=msg.rich_text, 89 | first=(i == 0), raw=msg, **msg.opt, 90 | ) 91 | 92 | 93 | class EmptyBot(BaseBotInstance): 94 | ChanTag = "__NULL__" 95 | 96 | 97 | # vim: ts=4 sw=4 sts=4 expandtab 98 | -------------------------------------------------------------------------------- /fishroom/bus.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import typing 3 | from enum import Enum 4 | 5 | from .models import Message 6 | from .config import config 7 | 8 | 9 | class MsgDirection(Enum): 10 | im2fish = 1 11 | fish2im = 2 12 | 13 | 14 | class MessageBus(object): 15 | 16 | CHANNELS = { 17 | MsgDirection.im2fish: config["redis"]["prefix"] + ":" + "im_msg_channel", 18 | MsgDirection.fish2im: config["redis"]["prefix"] + ":" + "fish_msg_channel", 19 | } 20 | 21 | def __init__(self, redis_client, direction: MsgDirection): 22 | self.r = redis_client 23 | self.d = direction 24 | 25 | @property 26 | def channel(self) -> str: 27 | return self.CHANNELS[self.d] 28 | 29 | def publish(self, msg: Message): 30 | self.r.publish(self.channel, msg.dumps()) 31 | 32 | def message_stream(self) -> typing.Iterator[Message]: 33 | p = self.r.pubsub() 34 | p.subscribe(self.channel) 35 | for rmsg in p.listen(): 36 | if rmsg is not None and rmsg['type'] == "message": 37 | yield Message.loads(rmsg['data'].decode('utf-8')) 38 | 39 | 40 | # vim: ts=4 sw=4 sts=4 expandtab 41 | -------------------------------------------------------------------------------- /fishroom/chatlogger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from .models import Message 3 | from .helpers import get_now 4 | from .config import config 5 | 6 | 7 | class ChatLogger(object): 8 | 9 | LOG_QUEUE_TMPL = ":".join( 10 | [config["redis"]["prefix"], "log", "{channel}", "{date}"]) 11 | CHANNEL = ":".join( 12 | [config["redis"]["prefix"], "msg_channel", "{channel}"]) 13 | 14 | def __init__(self, redis_client): 15 | self.r = redis_client 16 | 17 | def log(self, channel, msg: Message): 18 | chan = self.CHANNEL.format(channel=channel) 19 | self.r.publish(chan, msg.dumps()) 20 | return self.r.rpush(self.key(channel), msg.dumps()) - 1 21 | 22 | def key(self, channel: str): 23 | return self.LOG_QUEUE_TMPL.format( 24 | channel=channel, 25 | date=get_now().strftime("%Y-%m-%d") 26 | ) 27 | 28 | 29 | # vim: ts=4 sw=4 sts=4 expandtab 30 | -------------------------------------------------------------------------------- /fishroom/command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding:utf-8 -*- 3 | import shlex 4 | from collections import namedtuple 5 | from .config import config 6 | from .helpers import get_logger 7 | 8 | logger = get_logger(__name__) 9 | 10 | LEADING_CHARS = ('/', '.') 11 | 12 | CmdHandler = namedtuple( 13 | 'CmdHandler', 14 | ('func', 'desc', 'usage') 15 | ) 16 | CmdMe = config.get("cmd_me", "") 17 | 18 | command_handlers = {} 19 | 20 | 21 | def register_command(cmd, func, **options): 22 | if cmd in command_handlers: 23 | raise Exception("Command '%s' already registered" % cmd) 24 | logger.info("command `%s` registered" % cmd) 25 | command_handlers[cmd] = CmdHandler( 26 | func, options.get("desc", ""), options.get("usage", "")) 27 | 28 | 29 | def command(cmd, **options): 30 | def wrapper(func): 31 | register_command(cmd, func, **options) 32 | return wrapper 33 | 34 | 35 | def parse_command(content): 36 | tokens = shlex.split(content) 37 | if len(tokens) < 1: 38 | return None, None 39 | cmd = tokens.pop(0) 40 | assert cmd[0] in LEADING_CHARS and len(cmd) > 2 41 | cmd, *botname = cmd.split('@') 42 | if len(botname) == 1 and CmdMe not in botname: 43 | return None, None 44 | args = tokens 45 | return cmd[1:], args 46 | 47 | 48 | def get_command_handler(cmd): 49 | return command_handlers.get(cmd, None) 50 | 51 | 52 | @command("help", desc="list commands or usage", usage="help [cmd]") 53 | def list_commands(cmd, *args, **kwargs): 54 | if len(args) == 0: 55 | return "\n".join([ 56 | "{}: {}".format(c, h.desc) 57 | for c, h in command_handlers.items() 58 | ]) 59 | 60 | if len(args) == 1: 61 | h = get_command_handler(args[0]) 62 | if h is None: 63 | return 64 | return "{}: {}\nUsage: {}".format(args[0], h.desc, h.usage) 65 | 66 | 67 | if __name__ == "__main__": 68 | 69 | @command("test") 70 | def test(cmd, *args, **kwargs): 71 | print("Command: ", cmd) 72 | print("Arguments: ", args) 73 | 74 | print(command_handlers) 75 | cmd = "/test a b c 'd'" 76 | cmd, args = parse_command(cmd) 77 | func = command_handlers[cmd] 78 | func(cmd, *args) 79 | 80 | 81 | # vim: ts=4 sw=4 sts=4 expandtab 82 | -------------------------------------------------------------------------------- /fishroom/config.py.example: -------------------------------------------------------------------------------- 1 | config = { 2 | "debug": True, 3 | "timezone": "Asia/Shanghai", 4 | "baseurl": "http://fishroom.example.com", # do not end with "/" 5 | "name": "teleboto", 6 | "cookie_secret": "123456", # you should use a strong random secret 7 | "cmd_me": "tg_bot", # username of the telegram bot 8 | 9 | "redis": { 10 | # "unix_socket_path": "/var/run/redis/redis.sock" 11 | "host": "redis-host", # hostname for redis server 12 | "port": 6379, 13 | "prefix": "fishroom", 14 | }, 15 | 16 | "irc": { 17 | "server": "irc.freenode.net", 18 | "port": 6697, 19 | "nick": "XiaoT", # IRC nick name 20 | "password": None, # IRC account password, if nickname registered 21 | "ssl": True, 22 | "blacklist": [ 23 | "[Olaf]", 24 | ], 25 | }, 26 | 27 | # Uncomment these if you want GitHub OAuth 28 | # Note that you need to configure your redirect uri the same as the baseurl in GitHub 29 | # "github": { 30 | # "client_id": "", # get these two from github.com 31 | # "client_secret": "", 32 | # }, 33 | 34 | # Uncomment these if you want telegram access 35 | # "telegram": { 36 | # "token": "", # get this from @BotFather 37 | # "me": None, 38 | # "admin": [], # admin id (integer) 39 | # }, 40 | 41 | # Uncomment these if you want XMPP-MUC access 42 | # "xmpp": { 43 | # "server": "xmpp.jp", 44 | # "port": 5222, 45 | # "jid": "user@xmpp.jp/resource", 46 | # "password": "", 47 | # "nick": "XiaoT", 48 | # }, 49 | 50 | # Uncomment these if you want gitter access 51 | # "gitter": { 52 | # "token": "", 53 | # "me": "", # bot username 54 | # }, 55 | 56 | # Uncomment these if you want Matrix access 57 | # "matrix": { 58 | # "server": "https://matrixim.cc:8448", 59 | # "user": "fishroom", 60 | # "password": "", 61 | # "nick": "bot_fishroom", 62 | # "bot_msg_pattern": "^mubot|^!wikipedia", 63 | # }, 64 | 65 | # Uncomment these if you want WeChat access 66 | # "wechat": {}, 67 | 68 | # Optional, only if you use qiniu for file_store 69 | # Comment this out if you don"t use qiniu 70 | "qiniu": { 71 | "access_key": "", 72 | "secret_key": "", 73 | "bucket": "", 74 | "base_url": "", 75 | }, 76 | 77 | "photo_store": { 78 | # set one in ("imgur", "vim-cn", "qiniu") 79 | "provider": "vim-cn", 80 | "options": { 81 | "client_id": "", 82 | } 83 | }, 84 | 85 | # this is the web interface 86 | "chatlog": { 87 | "host": "tornado-host", # hostname for web server 88 | "port": 8000, 89 | "default_channel": "teleboto-dev", 90 | }, 91 | 92 | # Comment this out if you don"t use qiniu 93 | "file_store": { 94 | "provider": "qiniu", 95 | }, 96 | 97 | "text_store": { 98 | "provider": "vinergy", 99 | "options": { 100 | }, 101 | }, 102 | 103 | "plugins": [ 104 | "pia", "imglink", "vote", "hualao" 105 | ], 106 | 107 | "bindings": { 108 | "archlinux-cn": { 109 | "irc": "#archlinux-cn", 110 | "telegram": "-1001031857103", # group id can be obtained using bot api 111 | "xmpp": "chat@conference.xmpp.jp", 112 | "matrix": "#archlinux:matrixim.cc" 113 | }, 114 | "test": { 115 | # Use room nick name to identify a room 116 | # TODO: use Uins (https://itchat.readthedocs.io/zh/latest/intro/contact/#uins) 117 | # to identify a room, but currently I can"t get room Uins on login. 118 | "wechat": "xxx chat room" 119 | } 120 | } 121 | } 122 | 123 | # vim: ft=python 124 | -------------------------------------------------------------------------------- /fishroom/counter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from .config import config 3 | 4 | 5 | class Counter(object): 6 | 7 | COUNTER_KEY = ":".join( 8 | [config["redis"]["prefix"], "counter", "{name}"]) 9 | 10 | def __init__(self, redis_client, name): 11 | self.r = redis_client 12 | self.key = self.COUNTER_KEY.format(name=name) 13 | 14 | def incr(self, amount=1): 15 | return int(self.r.incr(self.key, amount)) 16 | 17 | # vim: ts=4 sw=4 sts=4 expandtab 18 | -------------------------------------------------------------------------------- /fishroom/db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import redis 3 | from .config import config 4 | 5 | __dbctx = {} 6 | 7 | 8 | def get_redis(): 9 | if 'redis' not in __dbctx: 10 | if config['redis'].get('unix_socket_path') is not None: 11 | redis_client = redis.StrictRedis( 12 | unix_socket_path=config['redis']['unix_socket_path']) 13 | else: 14 | redis_client = redis.StrictRedis( 15 | host=config['redis']['host'], port=config['redis']['port']) 16 | 17 | __dbctx['redis'] = redis_client 18 | return __dbctx['redis'] 19 | 20 | 21 | # vim: ts=4 sw=4 sts=4 expandtab 22 | -------------------------------------------------------------------------------- /fishroom/dumpload.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | import base64 4 | from .api_client import APIClientManager 5 | from .telegram import RedisNickStore, RedisStickerURLStore 6 | from .counter import Counter 7 | 8 | 9 | def dump_meta(r, tofilename): 10 | backup = {} 11 | 12 | rkeys = [ 13 | APIClientManager.clients_name_key, 14 | RedisNickStore.NICKNAME_KEY, RedisNickStore.USERNAME_KEY, 15 | RedisStickerURLStore.STICKER_KEY, 16 | ] 17 | 18 | for rk in rkeys: 19 | b = {} 20 | for k, v in r.hgetall(rk).items(): 21 | try: 22 | k, v = k.decode('utf-8'), v.decode('utf-8') 23 | except: 24 | continue 25 | b[k] = v 26 | backup[rk] = b 27 | 28 | backup[APIClientManager.clients_key] = { 29 | k.decode('utf-8'): base64.b64encode(v).decode('utf-8') 30 | for k, v in r.hgetall(APIClientManager.clients_key).items() 31 | } 32 | 33 | counters = [Counter(r, name) for name in ('qiniu', )] 34 | for c in counters: 35 | backup[c.key] = c.incr() 36 | 37 | with open(tofilename, 'w') as f: 38 | json.dump(backup, f, indent=4) 39 | 40 | 41 | def load_meta(r, fromfile): 42 | with open(fromfile, 'r') as f: 43 | backup = json.load(f) 44 | 45 | for rk, b in backup.items(): 46 | if rk == APIClientManager.clients_key: 47 | for token_id, b64token in b.items(): 48 | r.hset(rk, token_id, base64.b64decode(b64token)) 49 | elif isinstance(b, int): 50 | r.set(rk, b) 51 | elif isinstance(b, dict): 52 | for k, v in b.items(): 53 | r.hset(rk, k, v) 54 | 55 | 56 | if __name__ == "__main__": 57 | import os 58 | import argparse 59 | import sys 60 | from .db import get_redis 61 | 62 | parser = argparse.ArgumentParser("Import/Export data from/to json") 63 | subparsers = parser.add_subparsers(dest="command", help="valid subcommands") 64 | dp = subparsers.add_parser('dump', aliases=['d'], help="dump data") 65 | dp.add_argument('-d', '--dump-dir', help="where to store the backup json") 66 | lp = subparsers.add_parser('load', aliases=['l'], help="load data") 67 | lp.add_argument('--meta-file', help='json is metadata (nicks, cache, etc.)') 68 | subparsers.add_parser('help', help="print help") 69 | 70 | args = parser.parse_args() 71 | 72 | if args.command == "help": 73 | parser.print_help() 74 | sys.exit(0) 75 | 76 | r = get_redis() 77 | mgr = APIClientManager(r) 78 | 79 | if args.command in ('dump', 'd'): 80 | dump_meta(r, os.path.join(args.dump_dir, "meta.json")) 81 | elif args.command in ('load', 'l'): 82 | load_meta(r, args.meta_file) 83 | 84 | 85 | # vim: ts=4 sw=4 sts=4 expandtab 86 | -------------------------------------------------------------------------------- /fishroom/filestore.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from .photostore import BasePhotoStore 3 | from io import BytesIO 4 | import imghdr 5 | import functools 6 | 7 | 8 | class BaseFileStore(object): 9 | 10 | def upload_file(self, filename): 11 | raise Exception("Not Implemented") 12 | 13 | 14 | class QiniuStore(BaseFileStore, BasePhotoStore): 15 | 16 | def __init__(self, access_key, secret_key, bucket_name, counter, base_url): 17 | try: 18 | import qiniu 19 | except ImportError: 20 | raise Exception("qiniu sdk is not installed") 21 | self.qiniu = qiniu 22 | 23 | self.auth = qiniu.Auth(access_key, secret_key) 24 | self.bucket = bucket_name 25 | self.counter = counter 26 | self.base_url = base_url 27 | 28 | def upload_image(self, filename=None, filedata=None, tag=None): 29 | token = self.auth.upload_token(self.bucket) 30 | if filedata is None: 31 | with open(filename, 'rb') as f: 32 | filedata = f.read() 33 | 34 | with BytesIO(filedata) as f: 35 | ext = imghdr.what(f) 36 | 37 | prefix = tag or "img" 38 | name = "%s/%02x.%s" % (prefix, self.counter.incr(), ext) 39 | 40 | ret, info = self.qiniu.put_data(token, name, filedata) 41 | if ret is None: 42 | return 43 | 44 | return self.base_url + name 45 | 46 | def upload_file(self, filedata, filename, filetype="file"): 47 | token = self.auth.upload_token(self.bucket) 48 | 49 | name = "%s/%02x-%s" % (filetype, self.counter.incr(), filename) 50 | ret, info = self.qiniu.put_data(token, name, filedata) 51 | if ret is None: 52 | return 53 | return self.base_url + name 54 | 55 | 56 | def get_qiniu(redis_client, config): 57 | from .counter import Counter 58 | if 'qiniu' not in config: 59 | return None 60 | 61 | c = config['qiniu'] 62 | counter = Counter(redis_client, 'qiniu') 63 | return QiniuStore( 64 | c['access_key'], c['secret_key'], c['bucket'], 65 | counter, c['base_url'], 66 | ) 67 | 68 | 69 | # vim: ts=4 sw=4 sts=4 expandtab 70 | -------------------------------------------------------------------------------- /fishroom/fishroom.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import os, sys 4 | import signal 5 | import threading 6 | import time 7 | 8 | from .base import EmptyBot 9 | from .bus import MessageBus, MsgDirection 10 | from .models import MessageType, Message 11 | from .chatlogger import ChatLogger 12 | from .textstore import Pastebin, Vinergy, RedisStore, ChatLoggerStore 13 | # from .telegram_tg import TgTelegram, TgTelegramThread 14 | from .api_client import APIClientManager 15 | from .command import get_command_handler, parse_command 16 | from .helpers import get_logger 17 | 18 | from .config import config 19 | from .db import get_redis 20 | 21 | 22 | 23 | redis_client = get_redis() 24 | msgs_from_im = MessageBus(redis_client, MsgDirection.im2fish) 25 | msgs_to_im = MessageBus(redis_client, MsgDirection.fish2im) 26 | 27 | chat_logger = ChatLogger(redis_client) 28 | api_mgr = APIClientManager(redis_client) 29 | 30 | logger = get_logger("Fishroom") 31 | 32 | 33 | def load_plugins(): 34 | from importlib import import_module 35 | for plugin in config['plugins']: 36 | module = ".plugins." + plugin 37 | import_module(module, package="fishroom") 38 | 39 | 40 | def init_text_store(): 41 | provider = config['text_store']['provider'] 42 | if provider == "pastebin": 43 | options = config['text_store']['options'] 44 | return Pastebin(**options) 45 | elif provider == "vinergy": 46 | return Vinergy() 47 | elif provider == "redis": 48 | return RedisStore(redis_client) 49 | elif provider == "chat_logger": 50 | return ChatLoggerStore() 51 | 52 | 53 | def main(): 54 | load_plugins() 55 | text_store = init_text_store() 56 | bindings = config['bindings'] 57 | 58 | def get_binding(msg): 59 | for room, b in bindings.items(): 60 | if msg.receiver == b.get(msg.channel.lower(), None): 61 | return room, b 62 | return (None, None) 63 | 64 | def try_command(msg): 65 | cmd, args = parse_command(msg.content) 66 | if cmd is None: 67 | msg.mtype = MessageType.Text 68 | return 69 | 70 | handler = get_command_handler(cmd) 71 | if handler is None: 72 | msg.mtype = MessageType.Text 73 | return 74 | 75 | try: 76 | return handler.func(cmd, *args, msg=msg, room=room) 77 | except: 78 | logger.exception("failed to execute command: {}".format(cmd)) 79 | 80 | for msg in msgs_from_im.message_stream(): 81 | logger.info(msg) 82 | if msg.room is None: 83 | room, b = get_binding(msg) 84 | msg.room = room 85 | else: 86 | room = msg.room 87 | b = bindings.get(room, None) 88 | 89 | if b is None: 90 | continue 91 | 92 | # Deliver to api clients 93 | api_mgr.publish(msg) 94 | msg_id = chat_logger.log(room, msg) 95 | 96 | # Handle commands 97 | bot_reply = "" 98 | if msg.mtype == MessageType.Command: 99 | bot_reply = try_command(msg) 100 | 101 | if bot_reply: 102 | opt = None 103 | if isinstance(bot_reply, tuple) and len(bot_reply) == 2: 104 | bot_reply, opt = bot_reply 105 | bot_msg = Message( 106 | msg.channel, config.get("name", "bot"), msg.receiver, 107 | content=bot_reply, date=msg.date, time=msg.time, 108 | botmsg=True, room=room, opt=opt 109 | ) 110 | # bot replies will be furthor processed by this function 111 | msgs_from_im.publish(bot_msg) 112 | 113 | # attach routing infomation 114 | msg.route = {c: t for c, t in b.items()} 115 | 116 | # get url or for long text 117 | if (msg.content.count('\n') > 5 or 118 | len(msg.content.encode('utf-8')) >= 400): 119 | text_url = text_store.new_paste( 120 | msg.content, msg.sender, 121 | channel=room, date=msg.date, time=msg.time, msg_id=msg_id 122 | ) 123 | 124 | if text_url is None: 125 | # Fail 126 | logger.error("Failed to publish text") 127 | continue 128 | 129 | msg.opt['text_url'] = text_url 130 | 131 | # push to IM 132 | msgs_to_im.publish(msg) 133 | 134 | 135 | if __name__ == "__main__": 136 | main() 137 | 138 | 139 | # vim: ts=4 sw=4 sts=4 expandtab 140 | -------------------------------------------------------------------------------- /fishroom/gitter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import json 4 | import asyncio 5 | import aiohttp 6 | import requests 7 | import requests.exceptions 8 | 9 | from .bus import MessageBus, MsgDirection 10 | from .base import BaseBotInstance, EmptyBot 11 | from .models import MessageType, Message, ChannelType 12 | from .helpers import string_date_time, get_logger 13 | from .config import config 14 | 15 | logger = get_logger("Gitter") 16 | 17 | 18 | class Gitter(BaseBotInstance): 19 | 20 | ChanTag = ChannelType.Gitter 21 | SupportMultiline = True 22 | 23 | _stream_api = "https://stream.gitter.im/v1/rooms/{room}/chatMessages" 24 | _post_api = "https://api.gitter.im/v1/rooms/{room}/chatMessages" 25 | 26 | def __init__(self, token, rooms, me): 27 | self.token = token 28 | self.rooms = rooms 29 | self.me = me 30 | 31 | @property 32 | def headers(self): 33 | return { 34 | 'Accept': 'application/json', 35 | 'Authorization': 'Bearer %s' % self.token, 36 | } 37 | 38 | def _must_post(self, api, data=None, json=None, timeout=10, **kwargs): 39 | if data is not None: 40 | kwargs['data'] = data 41 | elif json is not None: 42 | kwargs['json'] = json 43 | else: 44 | kwargs['data'] = {} 45 | kwargs['timeout'] = timeout 46 | 47 | try: 48 | r = requests.post(api, **kwargs) 49 | return r 50 | except requests.exceptions.Timeout: 51 | logger.error("Timeout requesting Gitter") 52 | except KeyboardInterrupt: 53 | raise 54 | except: 55 | logger.exception("Unknown error requesting Gitter") 56 | return None 57 | 58 | async def fetch(self, session, room, id_blacklist): 59 | url = self._stream_api.format(room=room) 60 | while True: 61 | # print("polling on url %s" % url) 62 | try: 63 | with aiohttp.Timeout(300): 64 | async with session.get(url, headers=self.headers) as resp: 65 | while True: 66 | line = await resp.content.readline() 67 | line = bytes.decode(line, 'utf-8').strip() 68 | if not line: 69 | continue 70 | msg = self.parse_jmsg(room, json.loads(line)) 71 | if msg.sender in id_blacklist: 72 | continue 73 | self.send_to_bus(msg) 74 | except asyncio.TimeoutError: 75 | pass 76 | except: 77 | raise 78 | 79 | def parse_jmsg(self, room, jmsg) -> Message: 80 | from_user = jmsg['fromUser']['username'] 81 | content = jmsg['text'] 82 | date, time = string_date_time(jmsg['sent']) 83 | 84 | mtype = MessageType.Command \ 85 | if self.is_cmd(content) \ 86 | else MessageType.Text 87 | 88 | return Message( 89 | ChannelType.Gitter, 90 | from_user, room, content, mtype, 91 | date=date, time=time, media_url=None, opt={} 92 | ) 93 | 94 | def send_msg(self, target, content, sender=None, raw=None, **kwargs): 95 | url = self._post_api.format(room=target) 96 | if sender: 97 | sender = re.sub(r'([\[\*_#])', r'\\\1', sender) 98 | 99 | reply = "" 100 | if 'reply_text' in kwargs: 101 | reply_to = kwargs['reply_to'] 102 | reply_text_lines = kwargs['reply_text'].splitlines() 103 | if len(reply_text_lines) > 0: 104 | for line in reply_text_lines: 105 | if not line.startswith(">"): 106 | reply_text = line 107 | break 108 | else: 109 | reply_text = reply_text_lines[0] 110 | 111 | reply = "> [{reply_to}] {reply_text}\n\n".format( 112 | reply_to=reply_to, reply_text=reply_text, 113 | ) 114 | 115 | text = "**[{sender}]** {content}" if sender else "{content}" 116 | 117 | if raw is not None: 118 | if raw.mtype in (MessageType.Photo, MessageType.Sticker): 119 | content = "%s\n![](%s)" % (raw.mtype, raw.media_url) 120 | 121 | j = { 122 | 'text': reply + text.format(sender=sender, content=content) 123 | } 124 | 125 | self._must_post(url, json=j, headers=self.headers) 126 | 127 | def send_to_bus(self, msg): 128 | raise NotImplementedError() 129 | 130 | def listen_message_stream(self, id_blacklist=None): 131 | id_blacklist = set(id_blacklist or [self.me, ]) 132 | 133 | loop = asyncio.new_event_loop() 134 | asyncio.set_event_loop(loop) 135 | with aiohttp.ClientSession(loop=loop) as session: 136 | self.aioclient_session = session 137 | 138 | tasks = [ 139 | asyncio.ensure_future(self.fetch(session, room, id_blacklist)) 140 | for room in self.rooms 141 | ] 142 | done, _ = loop.run_until_complete( 143 | asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) 144 | ) 145 | for d in done: 146 | if d.exception(): 147 | raise d.exception() 148 | 149 | 150 | def Gitter2FishroomThread(gt: Gitter, bus: MessageBus): 151 | if gt is None or isinstance(gt, EmptyBot): 152 | return 153 | 154 | def send_to_bus(msg): 155 | bus.publish(msg) 156 | 157 | gt.send_to_bus = send_to_bus 158 | gt.listen_message_stream() 159 | 160 | 161 | def Fishroom2GitterThread(gt: Gitter, bus: MessageBus): 162 | if gt is None or isinstance(gt, EmptyBot): 163 | return 164 | for msg in bus.message_stream(): 165 | gt.forward_msg_from_fishroom(msg) 166 | 167 | 168 | def init(): 169 | from .db import get_redis 170 | redis_client = get_redis() 171 | im2fish_bus = MessageBus(redis_client, MsgDirection.im2fish) 172 | fish2im_bus = MessageBus(redis_client, MsgDirection.fish2im) 173 | 174 | rooms = [b["gitter"] for _, b in config['bindings'].items() if 'gitter' in b] 175 | token = config['gitter']['token'] 176 | me = config['gitter']['me'] 177 | 178 | return (Gitter(token, rooms, me), im2fish_bus, fish2im_bus) 179 | 180 | 181 | def main(): 182 | if "gitter" not in config: 183 | return 184 | 185 | from .runner import run_threads 186 | bot, im2fish_bus, fish2im_bus = init() 187 | run_threads([ 188 | (Gitter2FishroomThread, (bot, im2fish_bus, ), ), 189 | (Fishroom2GitterThread, (bot, fish2im_bus, ), ), 190 | ]) 191 | 192 | 193 | def test(): 194 | gitter = Gitter( 195 | token="", 196 | rooms=( 197 | "57397795c43b8c60197322b9", 198 | "5739b957c43b8c6019732c0b", 199 | ), 200 | me='' 201 | ) 202 | 203 | def response(msg): 204 | print(msg) 205 | gitter.send_msg( 206 | target=msg.receiver, content=msg.content, sender="fishroom") 207 | 208 | gitter.send_to_bus = response 209 | 210 | try: 211 | gitter.listen_message_stream(id_blacklist=("master_tuna_twitter", )) 212 | except Exception: 213 | import traceback 214 | traceback.print_exc() 215 | 216 | 217 | if __name__ == "__main__": 218 | import argparse 219 | parser = argparse.ArgumentParser() 220 | parser.add_argument("--test", default=False, action="store_true") 221 | args = parser.parse_args() 222 | 223 | if args.test: 224 | test() 225 | else: 226 | main() 227 | 228 | # vim: ts=4 sw=4 sts=4 expandtab 229 | -------------------------------------------------------------------------------- /fishroom/helpers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import pytz 3 | import requests 4 | import hashlib 5 | import logging 6 | 7 | from typing import Tuple 8 | from datetime import datetime 9 | from dateutil import parser 10 | from io import BytesIO 11 | from PIL import Image 12 | from .config import config 13 | 14 | tz = pytz.timezone(config.get("timezone", "utc")) 15 | 16 | 17 | def get_logger(name, level=None) -> logging.Logger: 18 | logging.basicConfig(format='[%(name)s] [%(levelname)s] %(message)s') 19 | logger = logging.getLogger(name) 20 | if level is None: 21 | level = logging.DEBUG if config.get("debug", False) else logging.INFO 22 | logger.setLevel(level) 23 | return logger 24 | 25 | 26 | def get_now(): 27 | return datetime.now(tz=tz) 28 | 29 | 30 | def get_now_date_time(): 31 | now = get_now() 32 | return now.strftime("%Y-%m-%d"), now.strftime("%H:%M:%S") 33 | 34 | 35 | def timestamp_date_time(ts): 36 | d = datetime.fromtimestamp(ts, tz=tz) 37 | return d.strftime("%Y-%m-%d"), d.strftime("%H:%M:%S") 38 | 39 | 40 | def string_date_time(dstr): 41 | d = parser.parse(dstr).astimezone(tz) 42 | return d.strftime("%Y-%m-%d"), d.strftime("%H:%M:%S") 43 | 44 | 45 | def webp2png(webp_data): 46 | with BytesIO(webp_data) as fd: 47 | im = Image.open(fd) 48 | 49 | with BytesIO() as out: 50 | im.save(out, "PNG") 51 | out.seek(0) 52 | return out.read() 53 | 54 | 55 | def md5(data): 56 | m = hashlib.md5() 57 | m.update(data) 58 | return m.hexdigest() 59 | 60 | 61 | def download_file(url) -> Tuple[bytes, str]: 62 | logger = get_logger(__name__) 63 | try: 64 | r = requests.get(url, timeout=10) 65 | except requests.exceptions.Timeout: 66 | logger.error("Timeout downloading {}".format(url)) 67 | return None, None 68 | except: 69 | logger.exception("Failed to download {}".format(url)) 70 | return None, None 71 | 72 | return (r.content, r.headers.get('content-type')) 73 | 74 | 75 | def plural(number: int, origin: str, plurals: str=None) -> str: 76 | # need lots of check, or not? 77 | if plurals is None: 78 | plurals = origin + "s" 79 | 80 | if number != 1: 81 | return "{} {}".format(number, plurals) 82 | else: 83 | return "{} {}".format(number, origin) 84 | 85 | # vim: ts=4 sw=4 sts=4 expandtab 86 | -------------------------------------------------------------------------------- /fishroom/matrix.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from matrix_client.client import MatrixClient 4 | from matrix_client.api import MatrixRequestError 5 | from requests.exceptions import MissingSchema 6 | from .bus import MessageBus, MsgDirection 7 | from .base import BaseBotInstance, EmptyBot 8 | from .models import Message, ChannelType, MessageType 9 | from .helpers import get_now_date_time, get_logger 10 | from .config import config 11 | import sys 12 | import re 13 | 14 | logger = get_logger("Matrix") 15 | 16 | class MatrixHandle(BaseBotInstance): 17 | 18 | ChanTag = ChannelType.Matrix 19 | SupportMultiline = True 20 | 21 | def __init__(self, server, username, password, rooms, nick=None): 22 | client = MatrixClient(server) 23 | self.viewer_url = server.strip('/') + "/_matrix/media/v1/download/" 24 | 25 | try: 26 | client.login_with_password(username, password) 27 | except MatrixRequestError as e: 28 | if e.code == 403: 29 | logger.error("403 Bad username or password.") 30 | sys.exit(4) 31 | else: 32 | logger.error("{} Check your server details are correct.".format(e)) 33 | sys.exit(2) 34 | except MissingSchema as e: 35 | logger.error("{} Bad URL format.".format(e)) 36 | sys.exit(3) 37 | 38 | self.username = client.user_id 39 | logger.info("logged in as: {}".format(self.username)) 40 | 41 | if nick is not None: 42 | u = client.get_user(client.user_id) 43 | logger.info("Setting display name to {}".format(nick)) 44 | try: 45 | u.set_display_name(nick) 46 | except MatrixRequestError as e: 47 | logger.error("Fail to set display name: error = {}".format(e)) 48 | 49 | self.joined_rooms = {} 50 | self.room_id_to_alias = {} 51 | self.displaynames = {} 52 | 53 | for room_id_alias in rooms: 54 | try: 55 | room = client.join_room(room_id_alias) 56 | except MatrixRequestError as e: 57 | if e.code == 400: 58 | logger.error("400 Room ID/Alias in the wrong format") 59 | sys.exit(11) 60 | else: 61 | logger.error("{} Couldn't find room {}".format(e, room_id_alias)) 62 | sys.exit(12) 63 | logger.info("Joined room {}".format(room_id_alias)) 64 | self.joined_rooms[room_id_alias] = room 65 | self.room_id_to_alias[room.room_id] = room_id_alias 66 | room.add_listener(self.on_message) 67 | 68 | self.client = client 69 | self.bot_msg_pattern = config['matrix'].get('bot_msg_pattern', None) 70 | 71 | def on_message(self, room, event): 72 | if event['sender'] == self.username: 73 | return 74 | logger.info("event received, type: {}".format(event['type'])) 75 | if event['type'] == "m.room.member": 76 | if event['content']['membership'] == "join": 77 | logger.info("{0} joined".format(event['content']['displayname'])) 78 | elif event['type'] == "m.room.message": 79 | sender = event['sender'] 80 | opt = {'matrix': sender} 81 | if sender not in self.displaynames.keys(): 82 | u_send = self.client.get_user(sender) 83 | self.displaynames[sender] = u_send.get_display_name() 84 | sender = self.displaynames[sender] 85 | 86 | msgtype = event['content']['msgtype'] 87 | room_alias = self.room_id_to_alias[room.room_id] 88 | date, time = get_now_date_time() 89 | mtype = None 90 | media_url = None 91 | typedict = { 92 | "m.image": MessageType.Photo, 93 | "m.audio": MessageType.Audio, 94 | "m.video": MessageType.Video, 95 | "m.file": MessageType.File 96 | } 97 | if msgtype == "m.text" or msgtype == "m.notice": 98 | mtype = MessageType.Text 99 | msg_content = event['content']['body'] 100 | elif msgtype == "m.emote": 101 | mtype = MessageType.Text 102 | msg_content = "*{}* {}".format(sender, event['content']['body']) 103 | elif msgtype in ["m.image", "m.audio", "m.video", "m.file"]: 104 | new_url = event['content']['url'].replace("mxc://", self.viewer_url) 105 | mtype = typedict[msgtype] 106 | msg_content = "{} ({})\n{}".format(new_url, mtype, event['content']['body']) 107 | media_url = new_url 108 | else: 109 | pass 110 | 111 | logger.info("[{}] {}: {}".format(room_alias, sender, event['content']['body'])) 112 | if mtype is not None: 113 | msg = Message( 114 | ChannelType.Matrix, 115 | sender, room_alias, msg_content, 116 | mtype=mtype, date=date, time=time, 117 | media_url=media_url, opt=opt) 118 | self.send_to_bus(self, msg) 119 | 120 | def send_to_bus(self, msg): 121 | raise NotImplementedError() 122 | 123 | def listen_message_stream(self): 124 | self.client.start_listener_thread() 125 | 126 | def send_msg(self, target, content, sender=None, first=False, **kwargs): 127 | target_room = self.joined_rooms[target] 128 | if self.bot_msg_pattern is not None and re.match(self.bot_msg_pattern, content) is not None: 129 | target_room.send_text("{} sent the following message:".format(sender)) 130 | target_room.send_text(content) 131 | else: 132 | target_room.send_text("[{}] {}".format(sender, content)) 133 | 134 | def Matrix2FishroomThread(mx: MatrixHandle, bus: MessageBus): 135 | if mx is None or isinstance(mx, EmptyBot): 136 | return 137 | 138 | def send_to_bus(self, msg): 139 | bus.publish(msg) 140 | 141 | mx.send_to_bus = send_to_bus 142 | mx.listen_message_stream() 143 | 144 | def Fishroom2MatrixThread(mx: MatrixHandle, bus: MessageBus): 145 | if mx is None or isinstance(mx, EmptyBot): 146 | return 147 | for msg in bus.message_stream(): 148 | mx.forward_msg_from_fishroom(msg) 149 | 150 | 151 | def init(): 152 | from .db import get_redis 153 | redis_client = get_redis() 154 | im2fish_bus = MessageBus(redis_client, MsgDirection.im2fish) 155 | fish2im_bus = MessageBus(redis_client, MsgDirection.fish2im) 156 | 157 | rooms = [b["matrix"] for _, b in config['bindings'].items() if "matrix" in b] 158 | server = config['matrix']['server'] 159 | user = config['matrix']['user'] 160 | password = config['matrix']['password'] 161 | nick = config['matrix'].get('nick', None) 162 | 163 | return ( 164 | MatrixHandle(server, user, password, rooms, nick), 165 | im2fish_bus, fish2im_bus, 166 | ) 167 | 168 | 169 | def main(): 170 | if "matrix" not in config: 171 | return 172 | 173 | from .runner import run_threads 174 | bot, im2fish_bus, fish2im_bus = init() 175 | run_threads([ 176 | (Matrix2FishroomThread, (bot, im2fish_bus, ), ), 177 | (Fishroom2MatrixThread, (bot, fish2im_bus, ), ), 178 | ]) 179 | 180 | 181 | def test(): 182 | rooms = [b["matrix"] for _, b in config['bindings'].items()] 183 | server = config['matrix']['server'] 184 | user = config['matrix']['user'] 185 | password = config['matrix']['password'] 186 | 187 | matrix_handle = MatrixHandle(server, user, password, rooms) 188 | 189 | def send_to_bus(self, msg): 190 | logger.info(msg.dumps()) 191 | matrix_handle.send_to_bus = send_to_bus 192 | matrix_handle.process(block=True) 193 | 194 | 195 | if __name__ == "__main__": 196 | import argparse 197 | parser = argparse.ArgumentParser() 198 | parser.add_argument("--test", default=False, action="store_true") 199 | args = parser.parse_args() 200 | 201 | if args.test: 202 | test() 203 | else: 204 | main() 205 | 206 | # vim: ts=4 sw=4 sts=4 expandtab 207 | -------------------------------------------------------------------------------- /fishroom/models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import unittest 4 | from marshmallow import Schema, fields, validate, ValidationError 5 | 6 | 7 | class ChannelType(object): 8 | """\ 9 | Channel Types 10 | """ 11 | XMPP = "xmpp" 12 | IRC = "irc" 13 | Telegram = "telegram" 14 | Gitter = "gitter" 15 | Matrix = "matrix" 16 | Wechat = "wechat" 17 | Web = "web" 18 | API = "api" 19 | 20 | 21 | class MessageType(object): 22 | """\ 23 | Message Types 24 | """ 25 | Text = "text" 26 | Photo = "photo" 27 | Sticker = "sticker" 28 | Location = "location" 29 | Audio = "audio" 30 | Video = "video" 31 | Animation = "animation" 32 | File = "file" 33 | Event = "event" 34 | Command = "command" 35 | 36 | 37 | class Color(object): 38 | """\ 39 | Text color option 40 | """ 41 | 42 | def __init__(self, fg: int, bg: int=None): 43 | self.fg = fg 44 | self.bg = bg 45 | 46 | def __repr__(self): 47 | return "".format(self.fg, self.bg) 48 | 49 | def __nonzero__(self): 50 | return (self.fg is not None) or (self.bg is not None) 51 | 52 | def __eq__(self, other): 53 | return ( 54 | isinstance(other, self.__class__) and 55 | self.fg == other.fg and 56 | self.bg == other.bg 57 | ) 58 | 59 | def __ne__(self, other): 60 | return not self.__eq__(other) 61 | 62 | def swap(self): 63 | self.fg, self.bg = self.bg, self.fg 64 | 65 | 66 | class ColorField(fields.Field): 67 | 68 | def _serialize(self, value, attr, obj): 69 | if value is None: 70 | return '' 71 | return (value.fg, value.bg) 72 | 73 | def _deserialize(self, value, attr, obj): 74 | if not value: 75 | return None 76 | elif isinstance(value, int): 77 | return Color(value) 78 | else: 79 | try: 80 | fg, bg = map(int, value) 81 | except: 82 | raise ValidationError( 83 | "Color field should only contain fg and bg") 84 | return Color(fg, bg) 85 | # def __str__(self): 86 | # return json.dumps({'fg': self.fg, 'bg': self.bg}) 87 | 88 | 89 | class TextStyle(object): 90 | """\ 91 | TextStyle option, including normal, color, italic, bold and underline 92 | """ 93 | 94 | # TODO: Add newline support 95 | 96 | NORMAL = 0 97 | COLOR = 1 98 | ITALIC = 2 99 | BOLD = 4 100 | UNDERLINE = 8 101 | 102 | _schema = None # should be set later 103 | 104 | def __init__(self, color: Color=None, italic: int=0, 105 | bold: int=0, underline: int=0, style: int=0): 106 | self.style = style 107 | self.color = color 108 | if color: 109 | self.style |= self.COLOR 110 | self.style |= self.ITALIC if italic else 0 111 | self.style |= self.BOLD if bold else 0 112 | self.style |= self.UNDERLINE if underline else 0 113 | 114 | @classmethod 115 | def style_list(cls, style): 116 | styles = [] 117 | if style & cls.ITALIC: 118 | styles.append('italic') 119 | if style & cls.BOLD: 120 | styles.append('bold') 121 | if style & cls.UNDERLINE: 122 | styles.append('underline') 123 | return styles 124 | 125 | def toggle(self, mask: int=0): 126 | """\ 127 | mask should be one of COLOR, ITALIC, BOLD, UNDERLINE 128 | """ 129 | if mask not in (self.COLOR, self.ITALIC, self.BOLD, self.UNDERLINE): 130 | return 131 | self.style ^= mask 132 | 133 | def set(self, mask: int=0): 134 | """ 135 | set style of a mask 136 | """ 137 | if mask not in (self.COLOR, self.ITALIC, self.BOLD, self.UNDERLINE): 138 | return 139 | self.style |= mask 140 | 141 | def clear(self, mask: int=0): 142 | """ 143 | clear style of a mask 144 | """ 145 | self.style &= ~mask 146 | if mask == self.COLOR: 147 | self.color = None 148 | 149 | def set_color(self, fg: int, bg: int=None): 150 | self.set(self.COLOR) 151 | self.color = Color(fg, bg) 152 | 153 | def has_color(self): 154 | return self.style & self.COLOR 155 | 156 | def is_normal(self): 157 | return self.style == 0 158 | 159 | def is_italic(self): 160 | return self.style & self.ITALIC 161 | 162 | def is_bold(self): 163 | return self.style & self.BOLD 164 | 165 | def is_underline(self): 166 | return self.style & self.UNDERLINE 167 | 168 | def copy(self): 169 | return TextStyle( 170 | color=Color(self.color.fg, self.color.bg), 171 | style=self.style, 172 | ) if self.has_color() else TextStyle(style=self.style) 173 | 174 | def dump(self): 175 | return self._schema.dump(self).data 176 | 177 | def dumps(self): 178 | return self._schema.dumps(self).data 179 | 180 | @classmethod 181 | def loads(cls, jstr): 182 | if isinstance(jstr, bytes): 183 | jstr = jstr.decode('utf-8') 184 | 185 | ts = TextStyle(**cls._schema.loads(jstr).data) 186 | return ts 187 | 188 | @classmethod 189 | def load(cls, data): 190 | return TextStyle(**cls._schema.load(data).data) 191 | 192 | def __eq__(self, other): 193 | return ( 194 | isinstance(other, self.__class__) and 195 | self.style == other.style and 196 | self.color == other.color 197 | ) 198 | 199 | def __ne__(self, other): 200 | return not self.__eq__(other) 201 | 202 | def __repr__(self): 203 | styles = self.style_list(self.style) 204 | color = None 205 | if self.style & self.COLOR: 206 | color = self.color 207 | 208 | if color is None: 209 | if not styles: 210 | return "" 211 | return "<{}>".format(",".join(styles)) 212 | 213 | if not styles: 214 | return "{}".format(self.color) 215 | return "<{}, [{}]>".format(self.color, ",".join(styles)) 216 | 217 | 218 | class TextStyleField(fields.Field): 219 | """\ 220 | Serialization of TextStyle, color is not included 221 | """ 222 | 223 | def _serialize(self, value, attr, obj): 224 | if value is None: 225 | return [] 226 | return TextStyle.style_list(value) 227 | 228 | def _deserialize(self, value, attr, obj): 229 | style = TextStyle.NORMAL 230 | try: 231 | styles = set(value) 232 | except: 233 | raise ValidationError("Invalid style list") 234 | if "italic" in styles: 235 | style |= TextStyle.ITALIC 236 | if "bold" in styles: 237 | style |= TextStyle.BOLD 238 | if "underline" in styles: 239 | style |= TextStyle.UNDERLINE 240 | return style 241 | 242 | 243 | class TextStyleSchema(Schema): 244 | """\ 245 | Schema of Styled Text 246 | """ 247 | 248 | color = ColorField(missing=None) 249 | style = TextStyleField(missing=[]) 250 | 251 | 252 | TextStyle._schema = TextStyleSchema() 253 | 254 | 255 | class RichText(object): 256 | 257 | def __init__(self, text: list): 258 | """\ 259 | text should be list of (style, text) tuple 260 | """ 261 | self.text = list(text) 262 | 263 | def __repr__(self): 264 | return "%s" % self.text 265 | 266 | def __eq__(self, other): 267 | return (isinstance(other, self.__class__) and 268 | self.text == other.text) 269 | 270 | def __ne__(self, other): 271 | return not self.__eq__(other) 272 | 273 | def __getitem__(self, i): 274 | return self.text[i] 275 | 276 | def __len__(self): 277 | return len(self.text) 278 | 279 | def __iter__(self): 280 | yield from self.text 281 | 282 | def toPlain(self): 283 | return ''.join(i[1] for i in self.text) 284 | 285 | 286 | class RichTextField(fields.Field): 287 | """\ 288 | RichText field serialization. 289 | rich_text is a list of (style, text) tuple 290 | """ 291 | 292 | def _serialize(self, value, attr, obj): 293 | if value is None: 294 | return None 295 | 296 | try: 297 | for style, text in value.text: 298 | if not isinstance(style, TextStyle) or \ 299 | not isinstance(text, str): 300 | raise 301 | except: 302 | raise ValidationError( 303 | "RichText should be a list of style and content") 304 | 305 | return [(s.dump(), t) for s, t in value.text] 306 | 307 | def _deserialize(self, value, attr, obj): 308 | if value is None: 309 | return None 310 | try: 311 | return RichText([(TextStyle.load(s), t) for s, t in value]) 312 | except: 313 | raise ValidationError( 314 | "RichText should be a list of style and content") 315 | 316 | 317 | class MessageSchema(Schema): 318 | """\ 319 | Json Schema for Message 320 | """ 321 | 322 | # Where is this message from 323 | channel = fields.String() 324 | # message sender 325 | sender = fields.String() 326 | # message receiver (usually group id) 327 | receiver = fields.String() 328 | # message type 329 | mtype = fields.String(validate=validate.OneOf( 330 | (MessageType.Photo, MessageType.Text, MessageType.Sticker, 331 | MessageType.Location, MessageType.Audio, MessageType.Command, 332 | MessageType.Event, MessageType.File, MessageType.Animation, 333 | MessageType.Video), 334 | )) 335 | # if message is photo or sticker, this contains url 336 | media_url = fields.String() 337 | # message text 338 | content = fields.String() 339 | # formated rich text 340 | rich_text = RichTextField() 341 | # date and time 342 | date = fields.String() 343 | time = fields.String() 344 | # is this message from fishroom bot? 345 | botmsg = fields.Boolean() 346 | # room 347 | room = fields.String() 348 | # channel specific options (passed to send_msg method) 349 | opt = fields.Dict() 350 | # available on fishroom to IM direction, specify message route 351 | route = fields.Dict() 352 | 353 | 354 | class Message(object): 355 | """\ 356 | Message instance 357 | 358 | Attributes: 359 | channel: one in ChannelType.{XMPP, Telegram, IRC} 360 | sender: sender name 361 | receiver: receiver name 362 | content: message content 363 | mtype: text or photo or sticker 364 | media_url: URL to media if mtype is sticker or photo 365 | date, time: message date and time 366 | room: which room to deliver 367 | botmsg: msg is from fishroom bot 368 | route: message route info 369 | opt: channel specific options 370 | """ 371 | 372 | _schema = MessageSchema() 373 | 374 | def __init__(self, channel, sender, receiver, content, 375 | mtype=MessageType.Text, date=None, time=None, 376 | media_url=None, botmsg=False, room=None, opt=None, route=None, 377 | rich_text=None): 378 | self.channel = channel 379 | self.sender = sender 380 | self.receiver = receiver 381 | self.content = content 382 | self.rich_text = rich_text 383 | self.mtype = mtype 384 | self.date = date 385 | self.time = time 386 | self.media_url = media_url 387 | self.botmsg = botmsg 388 | self.route = route 389 | self.room = room 390 | self.opt = opt or {} 391 | 392 | def __repr__(self): 393 | return ( 394 | "[{channel}] {mtype} from: {sender}, to: {receiver}, {content}" 395 | .format( 396 | channel=self.channel, mtype=self.mtype, sender=self.sender, 397 | receiver=self.receiver, content=self.content, 398 | )) 399 | 400 | def dumps(self): 401 | return self._schema.dumps(self).data 402 | 403 | @classmethod 404 | def loads(cls, jstr): 405 | if isinstance(jstr, bytes): 406 | jstr = jstr.decode('utf-8') 407 | 408 | try: 409 | m = Message(**cls._schema.loads(jstr).data) 410 | return m 411 | except: 412 | return Message("fishroom", "fishroom", "None", "Error") 413 | 414 | @property 415 | def lines(self): 416 | return [ 417 | line for line in self.content.splitlines() 418 | if not re.match(r'^\s*$', line) 419 | ] 420 | 421 | 422 | class TestRichText(unittest.TestCase): 423 | 424 | def test_eq(self): 425 | self.assertEqual(RichText([("normal", "Normal")]), 426 | RichText([("normal", "Normal")]), 427 | "RichText equal function") 428 | self.assertEqual( 429 | RichText([ 430 | (TextStyle(color=Color(3, 5)), "Test11"), 431 | (TextStyle(color=Color(4, 5)), "Test11"), 432 | (TextStyle(), "Test11"), 433 | ]), 434 | RichText([ 435 | (TextStyle(color=Color(3, 5)), "Test11"), 436 | (TextStyle(color=Color(4, 5)), "Test11"), 437 | (TextStyle(), "Test11"), 438 | ]), 439 | "RichText equal function", 440 | ) 441 | 442 | self.assertEqual( 443 | TextStyle(italic=1, color=Color(58, 12)), 444 | TextStyle(italic=1, color=Color(58, 12)), 445 | "TextStyle equal" 446 | ) 447 | 448 | def test_to_plain(self): 449 | self.assertEqual(RichText([ 450 | (TextStyle(italic=1), "Test1"), 451 | (TextStyle(), "Test2"), 452 | (TextStyle(), "Test3") 453 | ]).toPlain(), "Test1Test2Test3") 454 | 455 | def test_serialization_deserialization(self): 456 | c = Color(fg=5, bg=6) 457 | ts = TextStyle(color=c, italic=1) 458 | self.assertEqual( 459 | TextStyle.loads(ts.dumps()), 460 | ts, 461 | "TextStyle loads the same value from its dump" 462 | ) 463 | m = Message( 464 | channel=ChannelType.Telegram, content="test", sender="tester", 465 | receiver="tester2", rich_text=RichText([(ts, "test")]), 466 | ) 467 | self.assertEqual( 468 | Message.loads(m.dumps()).rich_text, 469 | RichText([(ts, "test")]), 470 | "Rich Text should be dumpable and loadable", 471 | ) 472 | print(m, m.rich_text) 473 | 474 | 475 | if __name__ == '__main__': 476 | 477 | unittest.main() 478 | 479 | # vim: ts=4 sw=4 sts=4 expandtab 480 | -------------------------------------------------------------------------------- /fishroom/photostore.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import json 4 | import requests 5 | import requests.exceptions 6 | from base64 import b64encode 7 | from .helpers import get_logger 8 | 9 | 10 | logger = get_logger(__name__) 11 | 12 | 13 | class BasePhotoStore(object): 14 | 15 | def upload_image(self, filename, **kwargs): 16 | raise Exception("Not Implemented") 17 | 18 | 19 | class Imgur(BasePhotoStore): 20 | 21 | url = "https://api.imgur.com/3/image?_format=json" 22 | 23 | def __init__(self, client_id, **kwargs): 24 | self.client_id = client_id 25 | 26 | def upload_image(self, filename=None, filedata=None, **kwargs): 27 | if filedata is None: 28 | with open(filename, 'rb') as f: 29 | b64img = b64encode(f.read()) 30 | else: 31 | b64img = b64encode(filedata) 32 | 33 | headers = {"Authorization": "Client-ID %s" % self.client_id} 34 | try: 35 | r = requests.post( 36 | self.url, 37 | headers=headers, 38 | data={ 39 | 'image': b64img, 40 | 'type': 'base64', 41 | }, 42 | timeout=5, 43 | ) 44 | except requests.exceptions.Timeout: 45 | logger.error("Timeout uploading to Imgur") 46 | return None 47 | except: 48 | logger.exception("Unknown errror uploading to Imgur") 49 | return None 50 | 51 | try: 52 | ret = json.loads(r.text) 53 | except: 54 | return None 55 | if ret.get('status', None) != 200 or ret.get('success', False) != True: 56 | logger.error( 57 | "Error: Imgur returned error, {}".format(ret.get('data', '')) 58 | ) 59 | return None 60 | 61 | link = ret.get('data', {}).get('link', None) 62 | return link if link is None else re.sub(r'^http:', 'https:', link) 63 | 64 | 65 | class VimCN(BasePhotoStore): 66 | 67 | url = "https://img.vim-cn.com/" 68 | 69 | def __init__(self, **kwargs): 70 | pass 71 | 72 | def upload_image(self, filename=None, filedata=None, **kwargs) -> str: 73 | if filedata is None: 74 | files = {"image": open(filename, 'rb')} 75 | else: 76 | files = {"image": filedata} 77 | 78 | try: 79 | r = requests.post(self.url, files=files, timeout=5) 80 | except requests.exceptions.Timeout: 81 | logger.error("Timeout uploading to VimCN") 82 | return None 83 | except: 84 | logger.exception("Unknown errror uploading to VimCN") 85 | return None 86 | if not r.ok: 87 | return None 88 | return r.text.strip() 89 | 90 | 91 | if __name__ == "__main__": 92 | import sys 93 | imgur = Imgur(sys.argv[1]) 94 | print(imgur.upload_image(sys.argv[2])) 95 | 96 | 97 | # vim: ts=4 sw=4 sts=4 expandtab 98 | -------------------------------------------------------------------------------- /fishroom/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuna/fishroom/a76daf5b88bb116a136123b270d8064ddfca4401/fishroom/plugins/__init__.py -------------------------------------------------------------------------------- /fishroom/plugins/hualao.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | from datetime import timedelta 4 | from collections import Counter 5 | 6 | from ..db import get_redis 7 | from ..command import command 8 | from ..models import Message 9 | from ..helpers import get_now 10 | from ..chatlogger import ChatLogger 11 | from .ratelimit import RateLimiter 12 | 13 | rlimiter = RateLimiter() 14 | 15 | r = get_redis() 16 | 17 | 18 | @command("hualao", desc="show top-n talkative individuals", usage="hualao [topn] [days]") 19 | def hualao(cmd, *args, **kwargs): 20 | if 'room' not in kwargs: 21 | return None 22 | room = kwargs['room'] 23 | log_key_tmpl = ChatLogger.LOG_QUEUE_TMPL 24 | 25 | if rlimiter.check(room, cmd, period=30, count=2) is False: 26 | return 27 | 28 | days = 7 29 | topn = 10 30 | 31 | if len(args) == 1: 32 | topn = int(args[0]) 33 | elif len(args) == 2: 34 | topn, days = map(int, args) 35 | elif len(args) > 2: 36 | return "hualao: invalid arguments" 37 | 38 | if topn > 10: 39 | return "hualao: toooooo many hualaos" 40 | 41 | days = min(days, 21) 42 | 43 | c = Counter() 44 | today = get_now() 45 | for _ in range(days): 46 | key = log_key_tmpl.format(date=today.strftime("%Y-%m-%d"), channel=room) 47 | senders = [Message.loads(bmsg).sender for bmsg in r.lrange(key, 0, -1)] 48 | c.update(senders) 49 | today -= timedelta(days=1) 50 | 51 | hualaos = c.most_common(topn) 52 | most = hualaos[0][1] 53 | 54 | def to_star(n): 55 | return '⭐️' * round(5 * n / most) or '⭐️' 56 | 57 | head = "Most talkative {} individuals within {} days:\n".format(topn, days) 58 | return head + "\n".join( 59 | ["{}: {} {}".format(u, to_star(c), c) for u, c in hualaos]) 60 | 61 | 62 | # vim: ts=4 sw=4 sts=4 expandtab 63 | -------------------------------------------------------------------------------- /fishroom/plugins/imglink.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | import re 4 | from ..models import MessageType 5 | from ..command import command 6 | 7 | url_regex = re.compile(r'https?://[^\s<>"]+') 8 | 9 | 10 | @command("imglink", desc="set message type as image", usage="imglink ") 11 | def imglink(cmd, *args, **kwargs): 12 | msg = kwargs.get("msg", None) 13 | if msg is None: 14 | return 15 | candidates = url_regex.findall(msg.content) 16 | if len(candidates) == 0: 17 | return 18 | 19 | msg.mtype = MessageType.Photo 20 | msg.media_url = candidates[0] 21 | 22 | # vim: ts=4 sw=4 sts=4 expandtab 23 | -------------------------------------------------------------------------------- /fishroom/plugins/pia.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | from ..command import command 4 | from .ratelimit import RateLimiter 5 | 6 | rlimiter = RateLimiter() 7 | 8 | 9 | @command("pia", desc="Pia somebody", usage="pia [name]") 10 | def pia(cmd, *args, **kwargs): 11 | _pia = "Pia!<(=o ‵-′)ノ☆ " 12 | room = kwargs.get('room', "ALL") 13 | if rlimiter.check(room, cmd, period=15, count=2) is False: 14 | return 15 | 16 | if len(args) == 0: 17 | # pia the bot 18 | msg = kwargs.get("msg", None) 19 | to = msg.sender if msg is not None else "" 20 | return "%s %s" % (_pia, to) 21 | elif len(args) == 1: 22 | return "%s %s" % (_pia, args[0]) 23 | elif len(args) > 1: 24 | return "Too many persons to %s" % _pia 25 | 26 | 27 | @command("mua", desc="mua somebody", usage="mua [name]") 28 | def mua(cmd, *args, **kwargs): 29 | _mua = "💋 Mua! " 30 | room = kwargs.get('room', "ALL") 31 | if rlimiter.check(room, cmd, period=15, count=2) is False: 32 | return 33 | 34 | if len(args) == 0: 35 | # pia the bot 36 | msg = kwargs.get("msg", None) 37 | sender = msg.sender if msg is not None else "" 38 | return "%s %s himself/herself" % (sender, _mua) 39 | elif len(args) == 1: 40 | return "%s %s" % (_mua, args[0]) 41 | elif len(args) > 1: 42 | return "Too many persons to %s" % _mua 43 | 44 | # vim: ts=4 sw=4 sts=4 expandtab 45 | -------------------------------------------------------------------------------- /fishroom/plugins/ratelimit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import pytz 3 | from datetime import datetime 4 | 5 | from ..db import get_redis 6 | from ..config import config 7 | 8 | tz = pytz.timezone(config.get("timezone", "utc")) 9 | 10 | 11 | class RateLimiter(object): 12 | 13 | key = config["redis"]["prefix"] + ":rate_limit:{room}:{cmd}" 14 | 15 | def __init__(self): 16 | self.r = get_redis() 17 | 18 | def trigger(self, room, cmd): 19 | key = self.key.format(room=room, cmd=cmd) 20 | now_ts = datetime.now(tz=tz).strftime("%s") 21 | self.r.rpush(key, now_ts) 22 | 23 | def check(self, room, cmd, period=30, count=5): 24 | key = self.key.format(room=room, cmd=cmd) 25 | l = self.r.llen(key) 26 | if l < count: 27 | self.trigger(room, cmd) 28 | return True 29 | 30 | self.r.ltrim(key, -count, -1) 31 | first = int(self.r.lindex(key, 0)) 32 | now_ts = int(datetime.now(tz=tz).strftime("%s")) 33 | if now_ts - first <= period: 34 | return False 35 | 36 | self.trigger(room, cmd) 37 | return True 38 | 39 | # vim: ts=4 sw=4 sts=4 expandtab 40 | -------------------------------------------------------------------------------- /fishroom/plugins/stats.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | from datetime import datetime, timedelta 4 | from collections import Counter 5 | from statistics import mean, stdev 6 | 7 | from ..db import get_redis 8 | from ..command import command 9 | from ..models import Message 10 | from ..helpers import get_now, tz, plural 11 | from ..chatlogger import ChatLogger 12 | from .ratelimit import RateLimiter 13 | 14 | rlimiter = RateLimiter() 15 | 16 | r = get_redis() 17 | 18 | 19 | @command("stats", desc="channel message statistics", usage="stats [days]") 20 | def hualao(cmd, *args, **kwargs): 21 | if 'room' not in kwargs: 22 | return None 23 | room = kwargs['room'] 24 | log_key_tmpl = ChatLogger.LOG_QUEUE_TMPL 25 | 26 | if rlimiter.check(room, cmd, period=30, count=2) is False: 27 | return 28 | 29 | days = 1 30 | 31 | if len(args) == 1: 32 | days = int(args[0]) 33 | 34 | if days <= 0: 35 | return "stats: invalid days" 36 | 37 | days = min(days, 21) 38 | 39 | total = 0 40 | today = get_now() 41 | day = today 42 | c = Counter() 43 | for _ in range(days): 44 | key = log_key_tmpl.format(date=day.strftime("%Y-%m-%d"), channel=room) 45 | senders = [Message.loads(bmsg).sender for bmsg in r.lrange(key, 0, -1)] 46 | c.update(senders) 47 | day -= timedelta(days=1) 48 | 49 | today_seconds = ( 50 | today - datetime(today.year, today.month, today.day, 0, 0, 0, 0, tz) 51 | ).total_seconds() 52 | 53 | seconds = 86400 * (days - 1) + today_seconds 54 | minutes = 1440 * (days - 1) + (today_seconds / 60) 55 | hours = 24 * (days - 1) + (today_seconds / 3600) 56 | 57 | mean_person = mean(c.values()) 58 | std_person = stdev(c.values()) 59 | 60 | total = sum(c.values()) 61 | 62 | if total > seconds: 63 | time_average = total / seconds 64 | time_unit = "second" 65 | elif total > minutes: 66 | time_average = total / minutes 67 | time_unit = "minute" 68 | elif total > hours: 69 | time_average = total / hours 70 | time_unit = "hour" 71 | else: 72 | time_average = total / days 73 | time_unit = "day" 74 | 75 | msg = "Total {} in the past {}\n".format( 76 | plural(total, "message"), plural(days, "day")) 77 | msg += "Mean {:.2f} +/− {:.2f} per person , {:.2f} per {}".format( 78 | mean_person, 79 | std_person, 80 | time_average, 81 | time_unit 82 | ) 83 | 84 | return msg 85 | 86 | # vim: ts=4 sw=4 sts=4 expandtab 87 | -------------------------------------------------------------------------------- /fishroom/plugins/vote.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | from ..command import command 4 | from ..config import config 5 | from ..db import get_redis 6 | 7 | 8 | class NoVote(Exception): 9 | pass 10 | 11 | 12 | class NoOptions(Exception): 13 | pass 14 | 15 | 16 | class VoteExisted(Exception): 17 | pass 18 | 19 | 20 | class VoteStarted(Exception): 21 | pass 22 | 23 | 24 | class VoteNotStarted(Exception): 25 | pass 26 | 27 | 28 | class VoteManager(object): 29 | 30 | topic_key = config["redis"]["prefix"] + ":" + "current_vote:" + "{room}" + ":topic" 31 | status_key = config["redis"]["prefix"] + ":" + "current_vote:" + "{room}" + ":status" 32 | option_key = config["redis"]["prefix"] + ":" + "current_vote:" + "{room}" + ":options" 33 | voters_key = config["redis"]["prefix"] + ":" + "current_vote:" + "{room}" + ":voters" 34 | 35 | STAT_NEW = b"new" 36 | STAT_VOTING = b"voting" 37 | 38 | def __init__(self): 39 | self.r = get_redis() 40 | 41 | def new_vote(self, room, topic): 42 | key = self.topic_key.format(room=room) 43 | if self.r.get(key) is not None: 44 | raise VoteExisted() 45 | self.r.set(key, topic) 46 | key = self.status_key.format(room=room) 47 | self.r.set(key, self.STAT_NEW) 48 | 49 | def get_vote_topic(self, room): 50 | key = self.topic_key.format(room=room) 51 | topic = self.r.get(key) 52 | if topic is None: 53 | raise NoVote() 54 | return topic 55 | 56 | def get_vote(self, room): 57 | key = self.topic_key.format(room=room) 58 | topic = self.r.get(key) 59 | if topic is None: 60 | raise NoVote() 61 | skey = self.status_key.format(room=room) 62 | okey = self.option_key.format(room=room) 63 | vkey = self.voters_key.format(room=room) 64 | status = self.r.get(skey) 65 | options = self.r.lrange(okey, 0, -1) 66 | votes = self.r.hgetall(vkey) 67 | topic = topic.decode('utf-8') 68 | options = [o.decode('utf-8') for o in options] 69 | votes = {k.decode('utf-8'): idx.decode('utf-8') 70 | for k, idx in votes.items()} 71 | return (topic, status, options, votes) 72 | 73 | def start_vote(self, room): 74 | key = self.topic_key.format(room=room) 75 | topic = self.r.get(key) 76 | if topic is None: 77 | raise NoVote() 78 | okey = self.option_key.format(room=room) 79 | if self.r.llen(okey) == 0: 80 | raise NoOptions() 81 | skey = self.status_key.format(room=room) 82 | if self.r.get(skey) == self.STAT_VOTING: 83 | raise VoteStarted() 84 | self.r.set(skey, self.STAT_VOTING) 85 | 86 | def end_vote(self, room): 87 | tkey = self.topic_key.format(room=room) 88 | okey = self.option_key.format(room=room) 89 | vkey = self.voters_key.format(room=room) 90 | self.r.delete(tkey, okey, vkey) 91 | 92 | def add_option(self, room, option): 93 | tkey = self.topic_key.format(room=room) 94 | if self.r.get(tkey) is None: 95 | raise NoVote() 96 | skey = self.status_key.format(room=room) 97 | if self.r.get(skey) != self.STAT_NEW: 98 | raise VoteStarted() 99 | okey = self.option_key.format(room=room) 100 | self.r.rpush(okey, option) 101 | 102 | def vote_for(self, room, voter, option_idx): 103 | skey = self.status_key.format(room=room) 104 | if self.r.get(skey) != self.STAT_VOTING: 105 | raise VoteNotStarted() 106 | okey = self.option_key.format(room=room) 107 | vkey = self.voters_key.format(room=room) 108 | idx = int(option_idx) 109 | opt = self.r.lindex(okey, idx) 110 | if opt is not None: 111 | self.r.hset(vkey, voter, idx) 112 | return opt.decode('utf-8') 113 | raise NoOptions() 114 | 115 | def vote_for_opt(self, room, voter, option_str): 116 | skey = self.status_key.format(room=room) 117 | if self.r.get(skey) != self.STAT_VOTING: 118 | raise VoteNotStarted() 119 | okey = self.option_key.format(room=room) 120 | vkey = self.voters_key.format(room=room) 121 | for idx, opt in enumerate(self.r.lrange(okey, 0, -1)): 122 | if opt.decode('utf-8') == option_str: 123 | self.r.hset(vkey, voter, idx) 124 | return idx 125 | raise NoOptions() 126 | 127 | 128 | _vote_mgr = VoteManager() 129 | votemarks = ['⭐', '👍', '❤ ', '☀', ] 130 | 131 | 132 | @command("vote", desc="Vote plugin", 133 | usage="\n" 134 | "vote: show current vote\n" 135 | "vote new '' [ -- 'option' ... ]: create new vote\n" 136 | "vote add '