to vote"
245 | idx = _vote_mgr.vote_for_opt(room, sender, opt)
246 | else:
247 | idx = int(subcmd) - 1
248 | opt = _vote_mgr.vote_for(room, sender, idx)
249 |
250 | except NoOptions:
251 | return "invalid option"
252 | except VoteNotStarted:
253 | return "vote not started"
254 | except:
255 | return None
256 |
257 | return votemarks[idx % len(votemarks)]
258 |
259 | return "Invalid command, try .help vote for usage"
260 |
261 |
262 | # vim: ts=4 sw=4 sts=4 expandtab
263 |
--------------------------------------------------------------------------------
/fishroom/runner.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import os
3 | import threading
4 | import traceback
5 |
6 | from typing import Callable, Tuple, Iterable, Any
7 |
8 | from .helpers import get_logger
9 |
10 | logger = get_logger(__name__)
11 |
12 | AnyFunc = Callable[[Any], Any]
13 | AnyArgs = Tuple[Any]
14 |
15 |
16 | def run_threads(thread_target_args: Iterable[Tuple[AnyFunc, AnyArgs]]):
17 | from .telegram import Telegram
18 | from .config import config
19 |
20 | tasks = []
21 | DEAD = threading.Event()
22 |
23 | # wrapper to send report traceback info to telegram
24 | def die(f: AnyFunc):
25 | tg = Telegram(config["telegram"]["token"])
26 | logger = get_logger(__name__)
27 |
28 | def send_all(text):
29 | for adm in config["telegram"]["admin"]:
30 | try:
31 | tg.send_msg(adm, text, escape=False)
32 | except:
33 | pass
34 |
35 | def wrapper(*args, **kwargs):
36 | try:
37 | f(*args, **kwargs)
38 | except:
39 | logger.exception("thread failed")
40 | exc = traceback.format_exc()
41 | send_all("{}
".format(exc))
42 | DEAD.set()
43 |
44 | return wrapper
45 |
46 | for target, args in thread_target_args:
47 | t = threading.Thread(target=die(target), args=args)
48 | t.setDaemon(True)
49 | t.start()
50 | tasks.append(t)
51 |
52 | DEAD.wait()
53 | logger.error("Everybody died, I don't wanna live any more! T_T")
54 | os._exit(1)
55 |
56 |
57 | __all__ = [run_threads, ]
58 |
59 | # vim: ts=4 sw=4 sts=4 expandtab
60 |
--------------------------------------------------------------------------------
/fishroom/telegram.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding:utf-8 -*-
3 | import re
4 | import json
5 | import imghdr
6 | import requests
7 | import requests.exceptions
8 | import mimetypes
9 | import magic
10 | import html
11 | import time as pytime
12 | import unittest
13 | import traceback
14 |
15 | from collections import namedtuple
16 | from .base import BaseBotInstance, EmptyBot
17 | from .photostore import BasePhotoStore
18 | from .filestore import BaseFileStore
19 | from .models import (
20 | Message, ChannelType, MessageType, RichText, TextStyle, Color
21 | )
22 | from .bus import MessageBus, MsgDirection
23 | from .helpers import (
24 | timestamp_date_time, get_now_date_time, webp2png, md5, get_logger,
25 | )
26 | from .config import config
27 |
28 | logger = get_logger("Telegram")
29 |
30 | TeleUser = namedtuple(
31 | 'TeleUser', ('id', 'username', 'name'),
32 | )
33 |
34 | TeleMessage = namedtuple(
35 | 'TeleMessage',
36 | ('msg_id', 'user', 'fwd_from',
37 | 'chat_id', 'content', 'mtype', 'ts', 'media_url',
38 | 'reply_to', 'reply_text')
39 | )
40 |
41 |
42 | class BaseNickStore(object):
43 | """\
44 | Save nicknames for telegram
45 | """
46 | def get_nickname(self, user_id, username=None):
47 | return None
48 |
49 | def set_nickname(self, user_id, nickname):
50 | return None
51 |
52 | def set_username(self, nickname, username):
53 | return None
54 |
55 | def get_username(self, nickname):
56 | return None
57 |
58 |
59 | class RedisNickStore(BaseNickStore):
60 | """\
61 | Save nicknames for telegram in redis
62 |
63 | Attributes:
64 | NICKNAME_KEY: redis key
65 | r: redis client
66 | """
67 |
68 | NICKNAME_KEY = config["redis"]["prefix"] + ":" + "telegram_nicks"
69 | USERNAME_KEY = config["redis"]["prefix"] + ":" + "telegram_usernames"
70 |
71 | def __init__(self, redis_client):
72 | self.r = redis_client
73 |
74 | def get_nickname(self, user_id, username=None, display_name=None):
75 | nick = self.r.hget(self.NICKNAME_KEY, user_id)
76 | if (not nick) and username:
77 | self.set_nickname(user_id, username)
78 | nick = username
79 | if nick and username:
80 | self.set_username(nick, username)
81 | nick = nick.decode('utf-8') if isinstance(nick, bytes) else nick
82 | return nick or display_name or "tg-{}".format(user_id)
83 |
84 | def set_nickname(self, user_id, nickname):
85 | self.r.hset(self.NICKNAME_KEY, user_id, nickname)
86 |
87 | def set_username(self, nickname, username):
88 | self.r.hset(self.USERNAME_KEY, nickname, username)
89 |
90 | def get_username(self, nickname):
91 | n = self.r.hget(self.USERNAME_KEY, nickname)
92 | return n.decode('utf-8') if isinstance(n, bytes) else n
93 |
94 |
95 | class MemNickStore(BaseNickStore):
96 | """\
97 | Save nicknames for telegram in memory (volatile)
98 | """
99 |
100 | def __init__(self):
101 | self.usernicks = {}
102 | self.nickusers = {}
103 |
104 | def get_nickname(self, user_id, username=None, display_name=None):
105 | nick = self.usernicks.get(user_id)
106 | if (not nick) and username:
107 | self.set_nickname(user_id, username)
108 | nick = username
109 | if nick and username:
110 | self.set_username(nick, username)
111 | return nick or display_name or "tg-{}".format(user_id)
112 |
113 | def set_nickname(self, user_id, nickname):
114 | self.usernicks[user_id] = nickname
115 |
116 | def set_username(self, nickname, username):
117 | self.nickusers[nickname] = username
118 |
119 | def get_username(self, nickname):
120 | return self.nickusers.get(nickname, None)
121 |
122 |
123 | class BaseStickerURLStore(object):
124 |
125 | def get_sticker(self, sticker_id):
126 | return None
127 |
128 | def set_sticker(self, sticker_id, url):
129 | return None
130 |
131 |
132 | class RedisStickerURLStore(BaseStickerURLStore):
133 | """\
134 | Save sticker url for telegram in redis
135 |
136 | Attributes:
137 | STICKER_KEY: redis key
138 | r: redis client
139 | """
140 |
141 | STICKER_KEY = config["redis"]["prefix"] + ":" + "telegram_stickers"
142 |
143 | def __init__(self, redis_client):
144 | self.r = redis_client
145 |
146 | def get_sticker(self, sticker_id):
147 | u = self.r.hget(self.STICKER_KEY, sticker_id)
148 | if u:
149 | return u.decode('utf-8')
150 |
151 | def set_sticker(self, sticker_id, url):
152 | self.r.hset(self.STICKER_KEY, sticker_id, url)
153 |
154 |
155 | class Telegram(BaseBotInstance):
156 |
157 | ChanTag = ChannelType.Telegram
158 | SupportMultiline = True
159 | SupportPhoto = True
160 |
161 | _api_base_tmpl = "https://api.telegram.org/bot{token}"
162 | _file_base_tmpl = "https://api.telegram.org/file/bot{token}/"
163 |
164 | nickuser_regexes = [
165 | re.compile(r'(?P.*\s|^)@(?P\w+)(?P.*)'),
166 | re.compile(r'(?P^)(?P\w+)(?P:.*)'),
167 | ]
168 |
169 | def __init__(self, token="", nick_store=None,
170 | sticker_url_store=None, photo_store=None, file_store=None):
171 | self._token = token
172 | self.uid = int(token.split(':')[0])
173 | self.api_base = self._api_base_tmpl.format(token=token)
174 | self.file_base = self._file_base_tmpl.format(token=token)
175 |
176 | self.nick_store = nick_store \
177 | if isinstance(nick_store, BaseNickStore) \
178 | else MemNickStore()
179 |
180 | self.photo_store = photo_store \
181 | if isinstance(photo_store, BasePhotoStore) \
182 | else None
183 | self.file_store = file_store \
184 | if isinstance(file_store, BaseFileStore) \
185 | else None
186 | self.sticker_url_store = sticker_url_store \
187 | if isinstance(sticker_url_store, BaseStickerURLStore) \
188 | else BaseStickerURLStore()
189 |
190 | def _must_post(self, api, data=None, json=None, timeout=10, **kwargs):
191 | if data is not None:
192 | kwargs['data'] = data
193 | elif json is not None:
194 | kwargs['json'] = json
195 | else:
196 | kwargs['data'] = {}
197 | kwargs['timeout'] = timeout
198 |
199 | try:
200 | r = requests.post(api, **kwargs)
201 | return r
202 | except requests.exceptions.Timeout:
203 | logger.error("Timeout requesting Telegram")
204 | except KeyboardInterrupt:
205 | raise
206 | except:
207 | logger.exception("Unknown error")
208 | return None
209 |
210 | def _flush(self):
211 | """
212 | Flush unprocessed messages
213 | """
214 | logger.info("Flushing messages")
215 |
216 | api = self.api_base + "/getUpdates"
217 |
218 | for retry in range(3):
219 | r = self._must_post(api)
220 | if r is not None:
221 | break
222 | if retry == 3:
223 | raise Exception("Telegram API Server Error")
224 |
225 | ret = json.loads(r.text)
226 | if ret["ok"] is True:
227 | updates = ret['result']
228 | if len(updates) == 0:
229 | return 0
230 | latest = updates[-1]
231 | return latest["update_id"] + 1
232 |
233 | def download_file(self, file_id):
234 | logger.info("downloading file {}".format(file_id))
235 | api = self.api_base + "/getFile"
236 | r = self._must_post(api, data={'file_id': file_id})
237 | if r is None:
238 | return
239 | ret = json.loads(r.text)
240 | if ret["ok"] is False:
241 | logger.info(ret["description"])
242 | return
243 | file_path = ret["result"]["file_path"]
244 | file_url = self.file_base + file_path
245 | r = requests.get(file_url)
246 | if r.status_code == 200:
247 | return r.content
248 |
249 | def upload_photo(self, file_id):
250 | if not self.photo_store:
251 | return None, "No photo store available"
252 | photo = self.download_file(file_id)
253 | if photo is None:
254 | return None, "teleboto Faild to download file"
255 |
256 | logger.info("uploading photo {}".format(file_id))
257 | url = self.photo_store.upload_image(filedata=photo)
258 | if url is None:
259 | return None, "Failed to upload Image"
260 |
261 | return url, None
262 |
263 | def upload_sticker(self, file_id):
264 | if self.sticker_url_store:
265 | url = self.sticker_url_store.get_sticker(file_id)
266 | if url is not None:
267 | return url, None
268 |
269 | if not self.photo_store:
270 | return None, "Unable to upload photo"
271 |
272 | sticker = self.download_file(file_id)
273 | logger.info("uploading sticker {}".format(file_id))
274 |
275 | if sticker is None:
276 | return None, "teleboto failed to download file"
277 |
278 | if self.sticker_url_store:
279 | m = md5(sticker)
280 | url = self.sticker_url_store.get_sticker(m)
281 | if url is not None:
282 | return url, None
283 |
284 | photo = webp2png(sticker)
285 | url = self.photo_store.upload_image(filedata=photo, tag="sticker")
286 | if url is None:
287 | return None, "Failed to upload Image"
288 |
289 | if self.sticker_url_store:
290 | self.sticker_url_store.set_sticker(file_id, url)
291 | self.sticker_url_store.set_sticker(m, url)
292 | return url, None
293 |
294 | def upload_document(self, doc, filetype="file"):
295 | if not self.file_store:
296 | return None, "No file store available"
297 |
298 | filedata = self.download_file(doc["file_id"])
299 | if filedata is None:
300 | return None, "teleboto Faild to download file"
301 |
302 | logger.info("uploading document {}".format(doc["file_id"]))
303 |
304 | url = self.file_store.upload_file(
305 | filedata, doc.get("file_name", "file"), filetype=filetype)
306 | if url is None:
307 | return None, "Failed to upload Document"
308 |
309 | return url, None
310 |
311 | def upload_audio(self, file_id, mime):
312 | if not self.file_store:
313 | return None, "No file store available"
314 |
315 | filedata = self.download_file(file_id)
316 | if filedata is None:
317 | return None, "teleboto Faild to download file"
318 |
319 | if mime is None:
320 | mime = magic.from_buffer(filedata, mime=True).decode('utf-8')
321 | ext = mimetypes.guess_extension(mime)
322 | if ext is None:
323 | raise Exception("Failed to guess ext from mime: %s" % mime)
324 | filename = "voice" + ext
325 | url = self.file_store.upload_file(filedata, filename, filetype="audio")
326 | if url is None:
327 | return None, "Failed to upload Document"
328 |
329 | return url, None
330 |
331 | def parse_jmsg(self, jmsg):
332 | def get_display_name(user):
333 | names = filter(
334 | lambda x: x is not None,
335 | [user.get("first_name"), user.get("last_name")]
336 | )
337 | return " ".join(names)
338 |
339 | msg_id = jmsg["message_id"]
340 |
341 | from_info = jmsg["from"]
342 | user_id, username = from_info["id"], from_info.get("username", "")
343 | display_name = get_display_name(from_info)
344 |
345 | chat_id = jmsg["chat"]["id"]
346 | chat_title = jmsg["chat"].get("title", "unknown")
347 | ts = jmsg["date"]
348 | media_url = ""
349 |
350 | mtype = MessageType.Text
351 |
352 | if "text" in jmsg:
353 | content = jmsg["text"]
354 | mtype = MessageType.Command \
355 | if self.is_cmd(jmsg["text"]) \
356 | else MessageType.Text
357 |
358 | elif "photo" in jmsg:
359 | file_id = jmsg["photo"][-1]["file_id"]
360 | url, err = self.upload_photo(file_id)
361 | if err is not None:
362 | content = err
363 | else:
364 | content = url + " (photo)"
365 | if 'caption' in jmsg:
366 | content = content + "\n" + jmsg['caption']
367 | media_url = url
368 | mtype = MessageType.Photo
369 |
370 | elif "sticker" in jmsg:
371 | file_id = jmsg["sticker"]["file_id"]
372 | url, err = self.upload_sticker(file_id)
373 | if err is not None:
374 | content = err
375 | else:
376 | content = url + " (sticker)"
377 | if 'emoji' in jmsg:
378 | content += " " + jmsg['emoji']
379 | media_url = url
380 | mtype = MessageType.Sticker
381 |
382 | elif "document" in jmsg:
383 | doc = jmsg["document"]
384 | mime = doc.get("mime_type", "")
385 | if mime.startswith("image/"):
386 | url, err = self.upload_photo(doc["file_id"])
387 | mtype = MessageType.Photo
388 | elif mime.startswith("video/"):
389 | if doc.get("file_size", 2**31) > 2*1024*1024:
390 | # print("[Telegram] video tooo large")
391 | err = "(Video larger than 2MB is toooo large to upload)"
392 | mtype = MessageType.Event
393 | else:
394 | url, err = self.upload_document(doc, filetype="video")
395 | filename = doc.get("file_name", None)
396 | if filename == "giphy.mp4" or filename.endswith(".gif.mp4"):
397 | mtype = MessageType.Animation
398 | else:
399 | mtype = MessageType.Video
400 | else:
401 | url, err = self.upload_document(doc)
402 | mtype = MessageType.File
403 |
404 | if err is not None:
405 | content = err
406 | else:
407 | content = "{url} ({mtype})".format(url=url, mtype=mtype)
408 | media_url = url
409 |
410 | elif "voice" in jmsg:
411 | file_id = jmsg["voice"]["file_id"]
412 | mime_type = jmsg["voice"].get("mime_type")
413 |
414 | url, err = self.upload_audio(file_id, mime_type)
415 |
416 | if err is not None:
417 | content = err
418 | else:
419 | content = url + " (Voice Message)"
420 | media_url = url
421 | mtype = MessageType.Audio
422 |
423 | elif "new_chat_title" in jmsg:
424 | content = "{} {} changed group name to {}".format(
425 | from_info.get("first_name", ""),
426 | from_info.get("last_name", ""),
427 | jmsg["new_chat_title"],
428 | )
429 | mtype = MessageType.Event
430 |
431 | elif "location" in jmsg:
432 | loc = jmsg["location"]
433 | lon, lat = loc["longitude"], loc["latitude"]
434 | mtype = MessageType.Location
435 | content = (
436 | ("location {lat},{lon}\n"
437 | "https://www.openstreetmap.org/?mlat={lat}&mlon={lon}")
438 | .format(lat=lat, lon=lon)
439 | )
440 |
441 | elif "new_chat_participant" in jmsg:
442 | newp = jmsg["new_chat_participant"]
443 | content = "{} {} joined chat".format(
444 | newp.get("first_name", ""), newp.get("last_name", ""))
445 | mtype = MessageType.Event
446 |
447 | else:
448 | content = "(unsupported message type)"
449 |
450 | fwd_from = None
451 | if "forward_from" in jmsg:
452 | ffrom = jmsg["forward_from"]
453 | fwd_from = TeleUser(
454 | ffrom['id'], ffrom.get("username"), get_display_name(ffrom))
455 |
456 | reply_to, reply_text = None, None
457 | if "reply_to_message" in jmsg:
458 | reply = jmsg["reply_to_message"]
459 | reply_user = reply.get("from", None)
460 | if reply_user:
461 | if reply_user["id"] == self.uid:
462 | # msg replied to fishroom bot, reply info should be
463 | # obtained from the text
464 | if 'text' in reply:
465 | reply_to, reply_text = \
466 | self.match_nickname_content(reply['text'])
467 | logger.debug("reply", reply['text'], reply_to)
468 | else:
469 | # normal telegram reply
470 | reply_to = TeleUser(
471 | reply_user["id"], reply_user.get("username"),
472 | get_display_name(reply_user)
473 | )
474 | reply_text = reply.get('text', '')
475 |
476 | user = TeleUser(user_id, username, display_name)
477 |
478 | logger.debug("new msg to {}: {}".format(chat_title, content))
479 |
480 |
481 | return TeleMessage(
482 | msg_id=msg_id, user=user, fwd_from=fwd_from, chat_id=chat_id,
483 | content=content, mtype=mtype, ts=ts, media_url=media_url,
484 | reply_to=reply_to, reply_text=reply_text
485 | )
486 |
487 | def message_stream(self, id_blacklist=None):
488 | """\
489 | Iterator of messages.
490 |
491 | Yields:
492 | Fishroom Message instances
493 | """
494 |
495 | if isinstance(id_blacklist, (list, set, tuple)):
496 | id_blacklist = set(id_blacklist)
497 | else:
498 | id_blacklist = []
499 |
500 | api = self.api_base + "/getUpdates"
501 | offset = self._flush()
502 | logger.info("Ready!")
503 |
504 | while True:
505 | r = self._must_post(
506 | api,
507 | data={
508 | 'offset': offset, 'timeout': 10
509 | },
510 | timeout=15
511 | )
512 | if r is None:
513 | continue
514 |
515 | try:
516 | ret = json.loads(r.text)
517 | except:
518 | logger.error("Failed to parse json: %s" % r.text)
519 | continue
520 |
521 | if ret["ok"] is False:
522 | logger.error(ret["description"])
523 | continue
524 |
525 | for update in ret["result"]:
526 | offset = update["update_id"] + 1
527 | edited = False
528 | if "message" in update:
529 | jmsg = update["message"]
530 | elif "edited_message" in update:
531 | jmsg = update["edited_message"]
532 | edited = True
533 | else:
534 | continue
535 |
536 | # bypass outdated messages
537 | if pytime.time() - jmsg['date'] > 100:
538 | continue
539 |
540 | telemsg = self.parse_jmsg(jmsg)
541 | user = telemsg.user
542 |
543 | if telemsg is None or user.id in id_blacklist:
544 | continue
545 | if telemsg.mtype == MessageType.Command:
546 | if self.try_set_nick(telemsg) is not None:
547 | continue
548 |
549 | nickname = self.nick_store.get_nickname(
550 | user.id, user.username, user.name
551 | )
552 |
553 | reply_to = ""
554 | if telemsg.reply_to:
555 | if isinstance(telemsg.reply_to, str):
556 | reply_to = telemsg.reply_to
557 | elif isinstance(telemsg.reply_to, TeleUser):
558 | u = telemsg.reply_to
559 | reply_to = self.nick_store.get_nickname(
560 | u.id, u.username, u.name)
561 |
562 |
563 | content = telemsg.content
564 |
565 | if telemsg.fwd_from:
566 | u = telemsg.fwd_from
567 | content = content + " ".format(
568 | self.nick_store.get_nickname(u.id, u.username, u.name)
569 | )
570 |
571 | receiver = "%d" % telemsg.chat_id
572 |
573 | date, time = timestamp_date_time(telemsg.ts) \
574 | if telemsg.ts else get_now_date_time()
575 |
576 | opt = {
577 | 'msg_id': telemsg.msg_id,
578 | 'username': user.username,
579 | }
580 |
581 | if edited:
582 | opt['edited'] = True
583 |
584 | if reply_to:
585 | opt['reply_to'] = reply_to
586 | opt['reply_text'] = telemsg.reply_text
587 |
588 | yield Message(
589 | ChannelType.Telegram,
590 | nickname, receiver, content, telemsg.mtype,
591 | date=date, time=time, media_url=telemsg.media_url,
592 | opt=opt
593 | )
594 |
595 | def try_set_nick(self, msg):
596 | # handle command
597 | user_id = msg.user.id
598 | target = "%d" % msg.chat_id
599 | try:
600 | tmp = msg.content.split()
601 | cmd = tmp[0][1:].lower()
602 | args = tmp[1:]
603 | except:
604 | return
605 |
606 | if cmd == "nick":
607 | if len(args) == 1:
608 | nick = args[0]
609 | if not re.match(r'^\w', nick, flags=re.UNICODE):
610 | self.send_msg(target, "Use a human's nick name, please.")
611 | return True
612 | self.nick_store.set_nickname(user_id, nick)
613 | content = "Changed nickname to '%s'" % nick
614 | logger.debug(target, content)
615 | self.send_msg(target, content)
616 | else:
617 | self.send_msg(
618 | target,
619 | "Invalid Command, use '/nick nickname'"
620 | "to change nickname."
621 | )
622 | return True
623 |
624 | def send_photo(self, target, photo_data, sender=None):
625 |
626 | api = self.api_base + "/sendPhoto"
627 | caption = "{} sent a photo".format(sender) if sender else ""
628 |
629 | ft = imghdr.what('', photo_data)
630 | if ft is None:
631 | return
632 | filename = "image." + ft
633 | data = {'chat_id': target, 'caption': caption}
634 | files = {'photo': (filename, photo_data)}
635 | self._must_post(api, data=data, files=files)
636 |
637 | def send_msg(self, peer, content, sender=None, escape=True, rich_text=None,
638 | **kwargs):
639 | for r in self.nickuser_regexes:
640 | m = r.match(content)
641 | if m is None:
642 | continue
643 | nick = m.group("nick")
644 | username = self.nick_store.get_username(nick)
645 | if username is None:
646 | continue
647 | content = r.sub(r'\g@{}\g'.format(username), content)
648 |
649 | if rich_text:
650 | content = self.formatRichText(rich_text, escape=escape)
651 | elif escape:
652 | content = html.escape(content)
653 |
654 | # print(repr(content))
655 |
656 | tmpl = self.msg_tmpl(sender)
657 | api = self.api_base + "/sendMessage"
658 |
659 | data = {
660 | 'chat_id': int(peer),
661 | 'text': tmpl.format(sender=sender, content=content),
662 | 'parse_mode': 'HTML',
663 | }
664 | if 'telegram' in kwargs:
665 | for k, v in kwargs['telegram'].items():
666 | data[k] = v
667 | self._must_post(api, json=data)
668 |
669 | def msg_tmpl(self, sender=None):
670 | return "{content}" if sender is None else "[{sender}] {content}"
671 |
672 | @classmethod
673 | def formatRichText(cls, rich_text: RichText, escape=True):
674 | md = ""
675 | # telegram does not allow nested format
676 | for ts, text in rich_text:
677 | if escape:
678 | text = html.escape(text)
679 | if ts.is_bold():
680 | md += "{}".format(text)
681 | elif ts.is_italic():
682 | md += "{}".format(text)
683 | else:
684 | md += text
685 | return md
686 |
687 |
688 | def Telegram2FishroomThread(tg: Telegram, bus: MessageBus):
689 | if tg is None or isinstance(tg, EmptyBot):
690 | return
691 | tele_me = [int(x) for x in config["telegram"]["me"]]
692 | for msg in tg.message_stream(id_blacklist=tele_me):
693 | bus.publish(msg)
694 |
695 |
696 | def Fishroom2TelegramThread(tg: Telegram, bus: MessageBus):
697 | if tg is None or isinstance(tg, EmptyBot):
698 | return
699 | for msg in bus.message_stream():
700 | tg.forward_msg_from_fishroom(msg)
701 |
702 |
703 | def init():
704 | from .db import get_redis
705 | from .filestore import get_qiniu
706 | from .photostore import Imgur, VimCN
707 | redis_client = get_redis()
708 |
709 | def photo_store_init():
710 | provider = config['photo_store']['provider']
711 | if provider == "imgur":
712 | options = config['photo_store']['options']
713 | return Imgur(**options)
714 | elif provider == "vim-cn":
715 | return VimCN()
716 | elif provider == "qiniu":
717 | return get_qiniu(redis_client, config)
718 |
719 | nick_store = RedisNickStore(redis_client)
720 | sticker_url_store = RedisStickerURLStore(redis_client)
721 | photo_store = photo_store_init()
722 | file_store = None
723 |
724 | if "file_store" in config:
725 | provider = config["file_store"]["provider"]
726 | if provider == "qiniu":
727 | file_store = get_qiniu(redis_client, config)
728 |
729 | tg = Telegram(
730 | config["telegram"]["token"],
731 | sticker_url_store=sticker_url_store,
732 | nick_store=nick_store,
733 | photo_store=photo_store,
734 | file_store=file_store,
735 | )
736 |
737 | im2fish_bus = MessageBus(redis_client, MsgDirection.im2fish)
738 | fish2im_bus = MessageBus(redis_client, MsgDirection.fish2im)
739 | return tg, im2fish_bus, fish2im_bus
740 |
741 |
742 | def main():
743 | if "telegram" not in config:
744 | return
745 |
746 | from .runner import run_threads
747 | tg, im2fish_bus, fish2im_bus = init()
748 | run_threads([
749 | (Telegram2FishroomThread, (tg, im2fish_bus, ), ),
750 | (Fishroom2TelegramThread, (tg, fish2im_bus, ), ),
751 | ])
752 |
753 |
754 | class TestRichText(unittest.TestCase):
755 |
756 | def test_rich_text_format(self):
757 | test_cases = [
758 | ([
759 | (TextStyle(), "bigeagle: "),
760 | (TextStyle(color=Color(4)), "errors:"),
761 | (TextStyle(), (
762 | " source_file.java:1: error: class,"
763 | "interface, or enum expected"
764 | )),
765 | (TextStyle(color=Color(4)), "\\n"),
766 | (TextStyle(), " print(1)"),
767 | (TextStyle(color=Color(4)), "\\n"),
768 | (TextStyle(), " ^"),
769 | (TextStyle(color=Color(4)), "\\n"),
770 | (TextStyle(), " 1 error"),
771 | ], (
772 | "bigeagle: errors: source_file.java:1: error: class,"
773 | "interface, or enum expected\\n print(1)\\n ^\\n 1 error")
774 | )
775 | ]
776 |
777 | for (_input, output) in test_cases:
778 | with self.subTest(_input=_input, output=output):
779 | # print(TextFormatter.parseIRC(_input))
780 | self.assertEqual(
781 | Telegram.formatRichText(RichText(_input)), output
782 | )
783 |
784 |
785 | def test():
786 | unittest.main()
787 |
788 | from .photostore import VimCN
789 |
790 | tele = Telegram(config['telegram']['token'],
791 | nick_store=MemNickStore(), photo_store=VimCN())
792 | # tele.send_msg('user#67655173', 'hello')
793 | tele.send_photo('-34678255', open('test.png', 'rb').read())
794 | tele.send_msg('-34678255', "Back!")
795 | for msg in tele.message_stream():
796 | print(msg.dumps())
797 | tele.send_msg(msg.receiver, msg.content)
798 | return
799 |
800 |
801 | if __name__ == '__main__':
802 | import argparse
803 | parser = argparse.ArgumentParser()
804 | parser.add_argument("--test", default=False, action="store_true")
805 | args = parser.parse_args()
806 |
807 | if args.test:
808 | test()
809 | else:
810 | main()
811 |
812 |
813 | # vim: ts=4 sw=4 sts=4 expandtab
814 |
--------------------------------------------------------------------------------
/fishroom/telegram_tg.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import json
3 | from socket import socket, AF_INET, SOCK_STREAM
4 | from collections import namedtuple
5 | from .base import BaseBotInstance
6 | from .models import Message, ChannelType, MessageType
7 | from .helpers import timestamp_date_time, get_now_date_time
8 | from .telegram import BaseNickStore, MemNickStore
9 | from .config import config
10 |
11 |
12 | TeleMessage = namedtuple(
13 | 'TeleMessage',
14 | ('msg_id', 'user_id', 'username', 'chat_id', 'content', 'mtype', 'ts',)
15 | )
16 |
17 |
18 | class TgTelegram(BaseBotInstance):
19 |
20 | ChanTag = ChannelType.Telegram
21 |
22 | def __init__(self, ip_addr='127.0.0.1', port='4444', nick_store=None):
23 | self._socket_init(ip_addr, port)
24 | self.main_session()
25 | if not isinstance(nick_store, BaseNickStore):
26 | raise Exception("Invalid Nickname storage")
27 | self.nick_store = nick_store
28 |
29 | def __del__(self):
30 | self.sock.close()
31 |
32 | def _socket_init(self, ip_addr, port):
33 | s = socket(AF_INET, SOCK_STREAM)
34 | s.connect((ip_addr, port))
35 | self.sock = s
36 |
37 | def _send_cmd(self, cmd):
38 | if '\n' != cmd[-1]:
39 | cmd += '\n'
40 | self.sock.send(cmd.encode())
41 |
42 | def main_session(self):
43 | self._send_cmd('main_session')
44 |
45 | def parse_msg(self, jmsg):
46 | """Parse message.
47 |
48 | Returns:
49 | TeleMessage(user_id, username, chat_id, content, mtype) if jmsg is normal
50 | None if else.
51 | """
52 | mtype = jmsg.get('event', None)
53 | ts = jmsg.get('date', None)
54 |
55 | if mtype == "message":
56 | msg_id = jmsg["id"]
57 | from_info = jmsg["from"]
58 | user_id, username = from_info["id"], from_info.get("username", "")
59 |
60 | to_info = jmsg["to"]
61 | chat_id = to_info["id"] if to_info["type"] == "chat" else None
62 |
63 | if "text" in jmsg:
64 | content = jmsg["text"]
65 | mtype = MessageType.Command \
66 | if self.is_cmd(jmsg["text"]) \
67 | else MessageType.Text
68 |
69 | return TeleMessage(
70 | msg_id=msg_id, user_id=user_id, username=username,
71 | chat_id=chat_id, content=content, mtype=mtype, ts=ts,
72 | )
73 |
74 | def recv_header(self):
75 | """Receive and parse message head like `ANSWER XXX\n`
76 |
77 | Returns:
78 | next message size
79 | """
80 |
81 | # states = ("ANS", "NUM")
82 | state = "ANS"
83 | ans = b""
84 | size = b""
85 | while 1:
86 | r = self.sock.recv(1)
87 | if state == "ANS":
88 | if r == b" " and ans == b"ANSWER":
89 | state = "NUM"
90 | else:
91 | ans = ans + r
92 | elif state == "NUM":
93 | if r == b"\n":
94 | break
95 | else:
96 | size = size + r
97 |
98 | return int(size) + 1
99 |
100 | def message_stream(self, id_blacklist=None):
101 | """Iterator of messages.
102 |
103 | Yields:
104 | Fishroom Message instances
105 | """
106 | if isinstance(id_blacklist, (list, set, tuple)):
107 | id_blacklist = set(id_blacklist)
108 | else:
109 | id_blacklist = []
110 |
111 | while True:
112 | buf_size = self.recv_header()
113 |
114 | ret = self.sock.recv(buf_size)
115 |
116 | if '' == ret:
117 | break
118 |
119 | if ret[-2:] != b"\n\n":
120 | print("Error: buffer receive failed")
121 | break
122 |
123 | try:
124 | jmsg = json.loads(ret[:-2].decode("utf-8"))
125 | except ValueError:
126 | print("Error parsing: ", ret[:-2])
127 | # pprint.pprint(msg)
128 | # return self.parse_msg(jmsg)
129 |
130 | telemsg = self.parse_msg(jmsg)
131 | if (telemsg is None or
132 | telemsg.chat_id is None or
133 | telemsg.user_id in id_blacklist):
134 | continue
135 |
136 | nickname = self.nick_store.get_nickname(
137 | telemsg.user_id, telemsg.username)
138 |
139 | receiver = str(-telemsg.chat_id)
140 |
141 | date, time = timestamp_date_time(telemsg.ts) \
142 | if telemsg.ts else get_now_date_time()
143 |
144 | yield Message(
145 | ChannelType.Telegram, nickname, receiver,
146 | telemsg.content, telemsg.mtype, date=date, time=time
147 | )
148 |
149 |
150 | def TgTelegramThread(tg, bus):
151 | tele_me = [int(x) for x in config["telegram"]["me"]]
152 | for msg in tg.message_stream(id_blacklist=tele_me):
153 | if msg.mtype == MessageType.Command:
154 | continue
155 | bus.publish(msg)
156 |
157 |
158 | if __name__ == '__main__':
159 | tele = TgTelegram('127.0.0.1', 27219, nick_store=MemNickStore())
160 | # tele.send_msg('user#67655173', 'hello')
161 | for msg in tele.message_stream():
162 | print(msg.dumps())
163 |
--------------------------------------------------------------------------------
/fishroom/textformat.py:
--------------------------------------------------------------------------------
1 | """
2 | This is for text formatting
3 |
4 | IRC: https://github.com/myano/jenni/wiki/IRC-String-Formatting
5 | """
6 |
7 | import unittest
8 | from .models import TextStyle, RichText, Color
9 |
10 |
11 | class IRCCtrl(object):
12 | BOLD = '\x02'
13 | COLOR = '\x03'
14 | ITALIC = '\x1d'
15 | UNDERLINE = '\x1f'
16 | SWAPCOLOR = '\x16'
17 | RESET = '\x0f'
18 |
19 | _controls = set([BOLD, COLOR, ITALIC, UNDERLINE, SWAPCOLOR, RESET])
20 | styles = {
21 | BOLD: TextStyle.BOLD,
22 | COLOR: TextStyle.COLOR,
23 | ITALIC: TextStyle.ITALIC,
24 | UNDERLINE: TextStyle.UNDERLINE,
25 | }
26 |
27 | @classmethod
28 | def is_control(cls, t):
29 | return t in cls._controls
30 |
31 |
32 | class TextFormatter(object):
33 |
34 | @classmethod
35 | def parseIRC(cls, text):
36 | """
37 | returns: Text object, with text field set to a list of (style, text)
38 | """
39 |
40 | if len(text) == 0:
41 | return [(TextStyle.NORMAL, "")]
42 |
43 | formatted = []
44 | cur_style = TextStyle()
45 | cur_str = ""
46 | color_fg, color_bg = "", None # ANSI color number
47 |
48 | for (c, cn) in zip(text, list(text[1:])+[None]):
49 | if IRCCtrl.is_control(c):
50 | if cur_str:
51 | formatted.append((cur_style, cur_str))
52 | cur_str = ""
53 | cur_style = cur_style.copy()
54 |
55 | if not cn:
56 | break
57 |
58 | if c not in (IRCCtrl.COLOR, IRCCtrl.SWAPCOLOR, IRCCtrl.RESET):
59 | # use bit xor to toggle style
60 | cur_style.toggle(IRCCtrl.styles[c])
61 |
62 | elif c == IRCCtrl.COLOR:
63 | # color is set only if valid color option presents
64 | if cn.isnumeric():
65 | color_fg = cn # should be expanded later
66 | cur_style.set(TextStyle.COLOR)
67 | else:
68 | color_fg, color_bg = "", None
69 | cur_style.clear(TextStyle.COLOR)
70 |
71 | elif c == IRCCtrl.SWAPCOLOR:
72 | if cur_style.has_color():
73 | cur_style.color.swap()
74 |
75 | elif c == IRCCtrl.RESET:
76 | cur_style = TextStyle()
77 |
78 | else:
79 | if color_fg:
80 | # read color number
81 | if color_bg is None:
82 | # reading color_fg
83 | if len(color_fg) == 1:
84 | if cn.isnumeric():
85 | color_fg += cn
86 | elif cn == ',':
87 | color_bg = ""
88 | else:
89 | cur_style.set_color(int(color_fg))
90 | color_fg, color_bg = "", None
91 | elif len(color_fg) == 2:
92 | if cn == ',':
93 | color_bg = ""
94 | else:
95 | cur_style.set_color(int(color_fg))
96 | color_fg, color_bg = "", None
97 | elif isinstance(color_bg, str):
98 | # reading color_bg
99 | if len(color_bg) == 0:
100 | if cn.isnumeric():
101 | color_bg = cn
102 | else:
103 | # "if the charter after ',' is not number"
104 | cur_style.set_color(int(color_fg))
105 | color_fg, color_bg = "", None
106 | cur_str = ","
107 | elif len(color_bg) == 1:
108 | if cn.isnumeric():
109 | color_bg += cn
110 | else:
111 | cur_style.set_color(
112 | int(color_fg), int(color_bg))
113 | color_fg, color_bg = "", None
114 | elif len(color_bg) == 2:
115 | cur_style.set_color(int(color_fg), int(color_bg))
116 | color_fg, color_bg = "", None
117 | else:
118 | # read normal text
119 | cur_str += c
120 | if not cn:
121 | formatted.append((cur_style, cur_str))
122 |
123 | return RichText(formatted)
124 |
125 | @classmethod
126 | def parseTelgram(cls, text):
127 | pass
128 |
129 | @classmethod
130 | def parseHTML(cls, text):
131 | pass
132 |
133 |
134 | class TextTest(unittest.TestCase):
135 |
136 | def test_parse_irc(self):
137 | test_cases = [
138 | ("Test1", [(TextStyle(), "Test1")]),
139 | ("\x03Test2", [(TextStyle(), "Test2")]),
140 | ("\x03Test2\x03", [(TextStyle(), "Test2")]),
141 | ("\x03", []),
142 | ("\x033Test5", [(TextStyle(color=Color(3)), "Test5")]),
143 | ("\x033Test6\x03", [(TextStyle(color=Color(3)), "Test6")]),
144 | ("\x033,5Test7", [(TextStyle(color=Color(3, 5)), "Test7")]),
145 | ("Test9\x03Test9", [(TextStyle(), "Test9"), (TextStyle(), "Test9")]),
146 | ("\x033,5Test10\x03Test10\x03Test10", [
147 | (TextStyle(color=Color(3, 5)), "Test10"),
148 | (TextStyle(), "Test10"),
149 | (TextStyle(), "Test10"),
150 | ]),
151 | ("\x033,5Test11\x0f\x02Test11\x03Test11", [
152 | (TextStyle(color=Color(3, 5)), "Test11"),
153 | (TextStyle(bold=1), "Test11"),
154 | (TextStyle(bold=1), "Test11"),
155 | ]),
156 | ("\x033,045Test12", [(TextStyle(color=Color(3, 4)), "5Test12")]),
157 | ("\x03123,045Test13", [(TextStyle(color=Color(12)), "3,045Test13")]),
158 | ("Test14\x02\x034Test14\x02\x03Test14", [
159 | (TextStyle(), "Test14"),
160 | (TextStyle(bold=1, color=Color(4)), "Test14"),
161 | (TextStyle(), "Test14")
162 | ]),
163 | ("\x1d\x02Test15\x02\x1d", [(TextStyle(bold=1, italic=1), "Test15")]),
164 | ("\x035,2Test16\x16Test16", [
165 | (TextStyle(color=Color(5, 2)), "Test16"),
166 | (TextStyle(color=Color(2, 5)), "Test16"),
167 | ]),
168 | ("Test17\x035,2Test17\x16\x02Test17\x0fTest17", [
169 | (TextStyle(), "Test17"),
170 | (TextStyle(color=Color(5, 2)), "Test17"),
171 | (TextStyle(color=Color(2, 5), bold=1), "Test17"),
172 | (TextStyle(), "Test17"),
173 | ]),
174 | (
175 | ("bigeagle: \x0304errors:\x0f source_file.java:1: error: class,"
176 | "interface, or enum expected\x0304\\n\x0f print(1)"
177 | "\x0304\\n\x0f ^\x0304\\n\x0f 1 error"),
178 | [
179 | (TextStyle(), "bigeagle: "),
180 | (TextStyle(color=Color(4)), "errors:"),
181 | (TextStyle(), (
182 | " source_file.java:1: error: class,"
183 | "interface, or enum expected"
184 | )),
185 | (TextStyle(color=Color(4)), "\\n"),
186 | (TextStyle(), " print(1)"),
187 | (TextStyle(color=Color(4)), "\\n"),
188 | (TextStyle(), " ^"),
189 | (TextStyle(color=Color(4)), "\\n"),
190 | (TextStyle(), " 1 error"),
191 | ]
192 | ),
193 |
194 | ]
195 | for (_input, output) in test_cases:
196 | with self.subTest(_input=_input, output=output):
197 | self.assertEqual(
198 | TextFormatter.parseIRC(_input), RichText(output)
199 | )
200 |
201 |
202 | if __name__ == '__main__':
203 | unittest.main()
204 |
--------------------------------------------------------------------------------
/fishroom/textstore.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding:utf-8 -*-
3 | import requests
4 | import requests.exceptions
5 | import hashlib
6 | import json
7 | from .helpers import get_now
8 | from .config import config
9 | from .helpers import get_logger
10 |
11 |
12 | logger = get_logger(__name__)
13 |
14 |
15 | class BaseTextStore(object):
16 | def new_paste(self, text, sender):
17 | """\
18 | Upload text to text store
19 |
20 | Args:
21 | text: text content
22 | sender: sender's nickname
23 |
24 | Returns:
25 | url: URL to pasted text page
26 | """
27 | raise Exception("Not Implemented")
28 |
29 |
30 | class Pastebin(BaseTextStore):
31 |
32 | api_url = "http://pastebin.com/api/api_post.php"
33 |
34 | def __init__(self, api_dev_key):
35 | self.api_dev_key = api_dev_key
36 |
37 | def new_paste(self, text, sender, **kwargs) -> str:
38 |
39 | ts = kwargs["date"] + kwargs["time"] \
40 | if "date" in kwargs and "time" in kwargs \
41 | else get_now().strftime("%Y%m%d%H%M")
42 |
43 | filename = "{sender}.{ts}.txt".format(
44 | sender=sender,
45 | ts=ts
46 | )
47 | data = {
48 | 'api_option': "paste",
49 | 'api_dev_key': self.api_dev_key,
50 | 'api_paste_code': text,
51 | 'api_paste_name': filename,
52 | }
53 | try:
54 | r = requests.post(self.api_url, data=data, timeout=5)
55 | except requests.exceptions.Timeout:
56 | logger.error("Timeout uploading to Pastebin")
57 | return None
58 |
59 | if r.text.startswith("http"):
60 | return r.text.strip()
61 |
62 | return None
63 |
64 |
65 | class Vinergy(BaseTextStore):
66 |
67 | api_url = "http://cfp.vim-cn.com/"
68 |
69 | def __init__(self, **kwargs):
70 | pass
71 |
72 | def new_paste(self, text, sender, **kwargs) -> str:
73 | data = {
74 | 'vimcn': text,
75 | }
76 | try:
77 | r = requests.post(self.api_url, data=data, timeout=5)
78 | except requests.exceptions.Timeout:
79 | logger.error("Timeout uploading to Vinergy")
80 | return None
81 |
82 | if r.text.startswith("http"):
83 | return r.text.strip()
84 |
85 | return None
86 |
87 |
88 | class RedisStore(BaseTextStore):
89 |
90 | KEY_TMPL = ":".join([config["redis"]["prefix"], "text_store", "{id}"])
91 | URL_TMPL = config["baseurl"] + "/text/{id}"
92 |
93 | def __init__(self, redis_client, **kwargs):
94 | self.r = redis_client
95 |
96 | def new_paste(self, text, sender, **kwargs):
97 | now = get_now().strftime("%Y-%m-%d %H:%M:%S")
98 | s = hashlib.sha1()
99 | s.update((text+sender+now).encode("utf-8"))
100 | _id = s.hexdigest()[:16]
101 | key = self.KEY_TMPL.format(id=_id)
102 | value = json.dumps({
103 | "title": "Text from {}".format(sender),
104 | "time": now,
105 | "content": text,
106 | })
107 | self.r.set(key, value)
108 | return self.URL_TMPL.format(id=_id)
109 |
110 |
111 | class ChatLoggerStore(BaseTextStore):
112 |
113 | URL_TMPL = config["baseurl"] + "/log/{channel}/{date}/{msg_id}"
114 |
115 | def __init__(self, *args, **kwargs):
116 | pass
117 |
118 | def new_paste(self, text, sender, **kwargs):
119 | channel = kwargs.get("channel", None)
120 | date = kwargs.get("date", None)
121 | msg_id = kwargs.get("msg_id", None)
122 | if not (channel and date and msg_id):
123 | return None
124 | return self.URL_TMPL.format(
125 | channel=channel,
126 | date=date, msg_id=msg_id,
127 | )
128 |
129 | # vim: ts=4 sw=4 sts=4 expandtab
130 |
--------------------------------------------------------------------------------
/fishroom/web/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tuna/fishroom/a76daf5b88bb116a136123b270d8064ddfca4401/fishroom/web/__init__.py
--------------------------------------------------------------------------------
/fishroom/web/__main__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import tornado.ioloop
3 | import tornado.web
4 | from .handlers import (
5 | DefaultHandler, TextStoreHandler, ChatLogHandler, MessageStreamHandler,
6 | PostMessageHandler, APILongPollingHandler, APIPostMessageHandler,
7 | RobotsTxtHandler, GitHubOAuth2LoginHandler
8 | )
9 | from ..config import config
10 |
11 |
12 | def main():
13 | debug = config.get("debug", False)
14 | application = tornado.web.Application([
15 | (r"/", DefaultHandler),
16 | (r"/robots.txt", RobotsTxtHandler),
17 | (r"/log/([a-zA-Z0-9_-]+)/([a-zA-Z0-9-]+)", ChatLogHandler),
18 | (r"/log/([a-zA-Z0-9_-]+)/([a-zA-Z0-9-]+)/([0-9]+)", TextStoreHandler),
19 | (r"/messages/([a-zA-Z0-9_-]+)/", PostMessageHandler),
20 | (r"/msg_stream", MessageStreamHandler),
21 | (r"/api/messages", APILongPollingHandler),
22 | (r"/api/messages/([a-zA-Z0-9_-]+)/", APIPostMessageHandler),
23 | (r"/login", GitHubOAuth2LoginHandler),
24 | ], debug=debug, autoreload=debug, login_url='/login', cookie_secret=config['cookie_secret'])
25 | application.listen(config['chatlog']['port'],address=config['chatlog'].get('host', '0.0.0.0'))
26 | print("Serving on",config['chatlog'].get('host', '0.0.0.0'),":",format(config['chatlog']['port']))
27 | tornado.ioloop.IOLoop.instance().start()
28 |
29 |
30 | if __name__ == "__main__":
31 | main()
32 |
33 | # vim: ts=4 sw=4 sts=4 expandtab
34 |
--------------------------------------------------------------------------------
/fishroom/web/base.html:
--------------------------------------------------------------------------------
1 | {% import fishroom.config %}
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{title}}
9 |
10 |
11 | {% block css %}
12 | {% end %}
13 |
14 |
15 |
16 |
17 |
18 |
19 | {% block content %}
20 | {% end %}
21 | {% block js %}
22 | {% end %}
23 |
24 |
25 |
26 | {#
27 | vim: ts=2 sts=2 sw=2 noexpandtab
28 | #}
29 |
--------------------------------------------------------------------------------
/fishroom/web/chat_log.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 |
5 | {% if not embedded %}
6 | {% include "navbar.html" %}
7 | {% end %}
8 |
9 |
10 |
11 |
12 |
13 |
Loading ...
14 |
15 | {% if not embedded %}
16 |
17 | Load More
18 |
19 | {% end %}
20 |
21 | -
22 | {{!msg.sender}}
23 | {{!msg.time}}
24 |
25 |
26 | {{{! msg.content | fishify }}}
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | via {{!msg.channel}}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | {% if fishroom.config.config.get("bindings", {}).get(room, {}).get("web_post", True) %}
45 |
54 |
68 | {% end %}
69 | {% if not embedded %}
70 |
76 | {% end %}
77 |
78 |
79 |
80 | {% end %}
81 |
82 | {% block js %}
83 |
84 |
85 |
290 | {% end %}
291 |
292 | {% block css %}
293 |
374 |
375 | {% end %}
376 | {#
377 | vim: ts=2 sts=2 sw=2 noexpandtab
378 | #}
379 |
--------------------------------------------------------------------------------
/fishroom/web/handlers.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import functools
3 | import json
4 | import re
5 | import tornado.escape
6 | import tornado.web
7 | import tornado.websocket
8 | import tornado.gen as gen
9 | import tornadoredis
10 |
11 | import hashlib
12 | from urllib.parse import urlparse, urljoin, urlencode
13 | from datetime import datetime, timedelta
14 | from .oauth import GitHubOAuth2Mixin
15 | from ..db import get_redis as get_pyredis
16 | from ..base import BaseBotInstance
17 | from ..bus import MessageBus, MsgDirection
18 | from ..helpers import get_now, tz
19 | from ..models import Message, ChannelType, MessageType
20 | from ..chatlogger import ChatLogger
21 | from ..api_client import APIClientManager
22 | from ..config import config
23 |
24 |
25 | def get_redis():
26 | if config['redis'].get('unix_socket_path') is not None:
27 | r = tornadoredis.Client(
28 | unix_socket_path=config['redis']['unix_socket_path'])
29 | else:
30 | r = tornadoredis.Client(
31 | host=config['redis']['host'], port=config['redis']['port'])
32 |
33 | r.connect()
34 | return r
35 |
36 | r = get_redis()
37 | pr = get_pyredis()
38 |
39 | mgb_im2fish = MessageBus(pr, MsgDirection.im2fish)
40 |
41 |
42 | def authenticated(method):
43 | @functools.wraps(method)
44 | def wrapper(self, *args, **kwargs):
45 | if config.get('github', False) and not self.current_user:
46 | if self.request.method in ("GET", "HEAD"):
47 | url = self.get_login_url()
48 | self.redirect(url + "?" + urlencode(dict(next=self.request.uri)))
49 | return
50 | raise tornado.web.HTTPError(403)
51 | return method(self, *args, **kwargs)
52 | return wrapper
53 |
54 |
55 | class BaseHandler(tornado.web.RequestHandler):
56 | def get_current_user(self):
57 | return self.get_secure_cookie("session")
58 |
59 |
60 | class GitHubOAuth2LoginHandler(tornado.web.RequestHandler,
61 | GitHubOAuth2Mixin):
62 |
63 | @gen.coroutine
64 | def get(self):
65 | if self.get_argument('code', False):
66 | logged_in = yield self.get_authenticated_user(code=self.get_argument('code'))
67 | if logged_in:
68 | self.set_secure_cookie('session', 'ok')
69 | self.redirect(self.get_argument('next', '/'))
70 | else:
71 | self.set_status(401)
72 | self.finish('Unauthorized')
73 | else:
74 | yield self.authorize_redirect(
75 | redirect_uri=config['baseurl'] + '/login?next=' + self.get_argument('next', '/'),
76 | client_id=config['github']['client_id'],
77 | )
78 |
79 |
80 | class DefaultHandler(BaseHandler):
81 |
82 | @authenticated
83 | def get(self):
84 | url = "log/{room}/today".format(
85 | room=config["chatlog"]["default_channel"]
86 | )
87 | self.redirect(urljoin(config["baseurl"] + "/", url))
88 |
89 |
90 | class RobotsTxtHandler(tornado.web.RequestHandler):
91 |
92 | def get(self):
93 | self.set_header('Content-Type', 'text/plain')
94 | self.write("User-agent: *\nDisallow: /")
95 | self.finish()
96 |
97 |
98 | class TextStoreHandler(BaseHandler):
99 |
100 | @authenticated
101 | @gen.coroutine
102 | def get(self, room, date, msg_id):
103 | key = ChatLogger.LOG_QUEUE_TMPL.format(channel=room, date=date)
104 | msg_id = int(msg_id)
105 | val = pr.lrange(key, msg_id, msg_id)
106 | if not val:
107 | self.clear()
108 | self.set_status(404)
109 | self.finish("text not found")
110 | return
111 | msg = Message.loads(val[0].decode('utf-8'))
112 | # self.set_header('Content-Type', 'text/html')
113 | self.render(
114 | "text_store.html",
115 | title="Text from {}".format(msg.sender),
116 | content=msg.content,
117 | time="{date} {time}".format(date=msg.date, time=msg.time),
118 | )
119 |
120 |
121 | class ChatLogHandler(BaseHandler):
122 |
123 | @authenticated
124 | @gen.coroutine
125 | def get(self, room, date):
126 | if room not in config["bindings"] or \
127 | room in config.get("private_rooms", []):
128 | self.set_status(404)
129 | self.finish("Room not found")
130 | return
131 |
132 | enable_ws = False
133 | if date == "today":
134 | enable_ws = True
135 | date = get_now().strftime("%Y-%m-%d")
136 |
137 | if ((get_now() - tz.localize(datetime.strptime(date, "%Y-%m-%d"))) >
138 | timedelta(days=7)):
139 | self.set_status(403)
140 | self.finish("Dark History Coverred")
141 | return
142 |
143 | embedded = self.get_argument("embedded", None)
144 |
145 | key = ChatLogger.LOG_QUEUE_TMPL.format(channel=room, date=date)
146 | mlen = pr.llen(key)
147 |
148 | last = int(self.get_argument("last", mlen)) - 1
149 | limit = int(self.get_argument("limit", 15 if embedded else mlen))
150 |
151 | start = max(last - limit + 1, 0)
152 |
153 | if self.get_argument("json", False):
154 | logs = pr.lrange(key, start, last)
155 | msgs = [json.loads(jmsg.decode("utf-8")) for jmsg in logs]
156 | for i, m in zip(range(start, last+1), msgs):
157 | m['id'] = i
158 | m.pop('opt', None)
159 | m.pop('receiver', None)
160 | self.set_header("Content-Type", "application/json")
161 | self.write(json.dumps(msgs))
162 | self.finish()
163 | return
164 |
165 | baseurl = config["baseurl"]
166 | p = urlparse(baseurl)
167 |
168 | dates = [(get_now() - timedelta(days=i)).strftime("%Y-%m-%d")
169 | for i in range(7)]
170 |
171 | self.render(
172 | "chat_log.html",
173 | title="#{room} @ {date}".format(
174 | room=room, date=date),
175 | next_id=mlen,
176 | enable_ws=enable_ws,
177 | room=room,
178 | rooms=[
179 | x for x in config["bindings"].keys()
180 | if x not in config.get("private_rooms", ())
181 | ],
182 | date=date,
183 | dates=dates,
184 | basepath=p.path,
185 | embedded=(embedded is not None),
186 | limit=int(limit),
187 | )
188 |
189 | def name_style_num(self, text):
190 | m = hashlib.md5(text.encode('utf-8'))
191 | return "%d" % (int(m.hexdigest()[:8], 16) & 0x07)
192 |
193 |
194 | class PostMessageHandler(BaseHandler):
195 |
196 | def set_default_headers(self):
197 | self.set_header("Content-Type", "application/json")
198 |
199 | def write_json(self, status_code, **kwargs):
200 | self.set_status(status_code)
201 | self.write(json.dumps(kwargs))
202 |
203 | @authenticated
204 | def post(self, room):
205 | if room not in config["bindings"] or \
206 | room in config.get("private_rooms", []):
207 | self.set_status(404)
208 | self.finish("Room not found")
209 | return
210 |
211 | if not config["bindings"].get(room, {}).get("web_post", True):
212 | message = "Web post is disabled."
213 | self.write_json(403, message=message)
214 | self.finish()
215 | return
216 |
217 | try:
218 | self.json_data = json.loads(self.request.body.decode('utf-8'))
219 | except ValueError:
220 | message = 'Unable to parse JSON.'
221 | self.write_json(400, message=message) # Bad Request
222 | self.finish()
223 | return
224 |
225 | content = self.json_data.get("content", None)
226 | if not content:
227 | self.write_json(400, msg="Cannot send empty message")
228 | self.finish()
229 | return
230 |
231 | sender = str(self.json_data.get("nickname", '').strip())
232 | if not sender:
233 | self.write_json(400, msg="Nickname must be set")
234 | self.finish()
235 | return
236 | if not re.match(r'^\w', sender, flags=re.UNICODE):
237 | self.write_json(
238 | 400, msg="Invalid char found, use a human's nickname instead!")
239 | self.finish()
240 | return
241 |
242 | now = get_now()
243 | date, time = now.strftime("%Y-%m-%d"), now.strftime("%H:%M:%S")
244 | mtype = MessageType.Command \
245 | if BaseBotInstance.is_cmd(content) \
246 | else MessageType.Text
247 | msg = Message(
248 | ChannelType.Web, sender, room, content=content,
249 | mtype=mtype, date=date, time=time, room=room
250 | )
251 |
252 | mgb_im2fish.publish(msg)
253 | self.write_json(200, msg="OK")
254 | self.finish()
255 |
256 |
257 | class MessageStreamHandler(tornado.websocket.WebSocketHandler):
258 |
259 | def __init__(self, *args, **kwargs):
260 | super(MessageStreamHandler, self).__init__(*args, **kwargs)
261 | self.r = None
262 |
263 | def check_origin(self, origin):
264 | return True
265 |
266 | def on_message(self, jmsg):
267 | try:
268 | msg = json.loads(jmsg)
269 | self.r = get_redis()
270 | room = msg["room"]
271 | if room not in config["bindings"] or \
272 | room in config.get("private_rooms", []):
273 | self.close()
274 | return
275 | self._listen(room)
276 | except:
277 | self.close()
278 |
279 | @gen.engine
280 | def _listen(self, room):
281 | self.redis_chan = ChatLogger.CHANNEL.format(channel=room)
282 | yield gen.Task(self.r.subscribe, self.redis_chan)
283 | self.r.listen(self._on_update)
284 |
285 | @gen.coroutine
286 | def _on_update(self, msg):
287 | if msg.kind == "message":
288 | self.write_message(msg.body)
289 | elif msg.kind == "subscribe":
290 | self.write_message("OK")
291 | elif msg.kind == "disconnect":
292 | self.close()
293 |
294 | def on_close(self):
295 | if self.r:
296 | if self.r.subscribed:
297 | self.r.unsubscribe(self.redis_chan)
298 | self.r.disconnect()
299 |
300 |
301 | class APIRequestHandler(tornado.web.RequestHandler):
302 |
303 | mgr = APIClientManager(pr)
304 |
305 | def set_default_headers(self):
306 | self.set_header("Content-Type", "application/json")
307 |
308 | def write_json(self, status_code=200, **kwargs):
309 | self.set_status(status_code)
310 | self.write(json.dumps(kwargs))
311 |
312 | def auth(self):
313 | token_id = self.request.headers.get(
314 | "X-TOKEN-ID",
315 | self.get_argument("id", "")
316 | )
317 | token_key = self.request.headers.get(
318 | "X-TOKEN-KEY",
319 | self.get_argument("key", "")
320 | )
321 | fine = self.mgr.auth(token_id, token_key)
322 | if not fine:
323 | self.set_status(403)
324 | return
325 | return token_id
326 |
327 |
328 | class APILongPollingHandler(APIRequestHandler):
329 |
330 | @gen.coroutine
331 | def get(self):
332 | token_id = self.auth()
333 | if token_id is None:
334 | self.finish("Invalid Token")
335 | return
336 |
337 | room = self.get_argument("room", None)
338 | if room not in config["bindings"] or \
339 | room in config.get("private_rooms", []):
340 | self.set_status(404)
341 | self.finish("Room not found")
342 | return
343 |
344 | queue = APIClientManager.queue_key.format(token_id=token_id)
345 | l = yield gen.Task(r.llen, queue)
346 | msgs = []
347 | if l > 0:
348 | msgs = yield gen.Task(r.lrange, queue, 0, -1)
349 | pr.delete(queue)
350 | msgs = [json.loads(m) for m in msgs]
351 | else:
352 | ret = yield gen.Task(r.blpop, queue, timeout=10)
353 | if queue in ret:
354 | msgs = [json.loads(ret[queue])]
355 |
356 | if room:
357 | msgs = [m for m in msgs if m['room'] == room]
358 |
359 | self.write_json(messages=msgs)
360 | self.finish()
361 |
362 |
363 | class APIPostMessageHandler(APIRequestHandler):
364 |
365 | def prepare(self):
366 | if self.request.body:
367 | try:
368 | self.json_data = json.loads(self.request.body.decode('utf-8'))
369 | except ValueError:
370 | message = 'Unable to parse JSON.'
371 | self.write_json(400, message=message) # Bad Request
372 | self.finish()
373 | return
374 |
375 | self.write_json(400, message="Cannot handle empty request")
376 | self.finish()
377 |
378 | def post(self, room):
379 | if room not in config["bindings"] or \
380 | room in config.get("private_rooms", []):
381 | self.set_status(404)
382 | self.finish("Room not found")
383 | return
384 |
385 | token_id = self.auth()
386 | if token_id is None:
387 | self.finish("Invalid Token")
388 | return
389 |
390 | content = self.json_data.get("content", None)
391 | if not content:
392 | self.write_json(400, message="Cannot send empty message")
393 | self.finish()
394 |
395 | apiname = self.mgr.get_name(token_id)
396 | sender = self.json_data.get("sender", apiname)
397 | now = get_now()
398 | date, time = now.strftime("%Y-%m-%d"), now.strftime("%H:%M:%S")
399 | chantag = "{}-{}".format(ChannelType.API, apiname)
400 | mtype = MessageType.Command \
401 | if BaseBotInstance.is_cmd(content) \
402 | else MessageType.Text
403 | msg = Message(
404 | chantag, sender, room, content=content,
405 | mtype=mtype, date=date, time=time, room=room
406 | )
407 |
408 | mgb_im2fish(msg)
409 | self.write_json(message="OK")
410 | self.finish()
411 |
--------------------------------------------------------------------------------
/fishroom/web/navbar.html:
--------------------------------------------------------------------------------
1 |
39 | {#
40 | vim: ts=2 sts=2 sw=2 noexpandtab
41 | #}
42 |
--------------------------------------------------------------------------------
/fishroom/web/nickcolors.css:
--------------------------------------------------------------------------------
1 | ul#logs .nick-color-0 {
2 | color: #D73C2C;
3 | }
4 | ul#logs .nick-color-1 {
5 | color: #870000;
6 | }
7 | ul#logs .nick-color-2 {
8 | color: #ca2c68;
9 | }
10 | ul#logs .nick-color-3 {
11 | color: #fa5c98;
12 | }
13 | ul#logs .nick-color-4 {
14 | color: #7e349d;
15 | }
16 | ul#logs .nick-color-5 {
17 | color: #3e005d;
18 | }
19 | ul#logs .nick-color-6 {
20 | color: #0067b0;
21 | }
22 | ul#logs .nick-color-7 {
23 | color: #22A7F0;
24 | }
25 | ul#logs .nick-color-8 {
26 | color: #009b90;
27 | }
28 | ul#logs .nick-color-9 {
29 | color: #106b60;
30 | }
31 | ul#logs .nick-color-10 {
32 | color: #106b60;
33 | }
34 | ul#logs .nick-color-11 {
35 | color: #006c11;
36 | }
37 | ul#logs .nick-color-12 {
38 | color: #f9b32f;
39 | }
40 | ul#logs .nick-color-13 {
41 | color: #e67e22;
42 | }
43 | ul#logs .nick-color-14 {
44 | color: #5c6a79;
45 | }
46 | ul#logs .nick-color-15 {
47 | color: #2c3a49;
48 | }
49 |
--------------------------------------------------------------------------------
/fishroom/web/oauth.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import urllib.parse as urllib_parse
3 | import tornado.auth
4 | import tornado.escape
5 | from ..config import config
6 |
7 |
8 | class GitHubOAuth2Mixin(tornado.auth.OAuth2Mixin):
9 | _OAUTH_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'
10 | _OAUTH_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
11 |
12 | @tornado.auth._auth_return_future
13 | def get_authenticated_user(self, code, callback):
14 | http = self.get_auth_http_client()
15 | body = urllib_parse.urlencode({
16 | 'code': code,
17 | 'client_id': config['github']['client_id'],
18 | 'client_secret': config['github']['client_secret'],
19 | })
20 |
21 | http.fetch(self._OAUTH_ACCESS_TOKEN_URL,
22 | functools.partial(self._on_access_token, callback),
23 | method="POST", headers={'Content-Type': 'application/x-www-form-urlencoded'},
24 | body=body)
25 |
26 |
27 | @staticmethod
28 | def _on_access_token(future, response):
29 | if response.error:
30 | future.set_exception(tornado.auth.AuthError('GitHub auth error: %s' % str(response)))
31 | return
32 |
33 | args = tornado.escape.parse_qs_bytes(tornado.escape.native_str(response.body))
34 | future.set_result(bool(args.get('access_token')))
35 |
--------------------------------------------------------------------------------
/fishroom/web/text_store.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 |
5 |
6 |
7 |
8 |
9 |
10 | {{title}}
11 | {{time}}
12 | {{time}}
13 |
14 |
15 | {{ escape(content).replace('\n', '
') }}
16 |
17 |
18 |
19 |
20 |
21 |
22 | {% end %}
23 |
24 | {% block css %}
25 |
30 | {% end %}
31 |
32 | {% block js %}
33 |
40 | {% end %}
41 |
42 | {#
43 | vim: ts=2 sts=2 sw=2 noexpandtab
44 | #}
45 |
--------------------------------------------------------------------------------
/fishroom/wechat.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # Using the ItChat web WeChat API (https://github.com/littlecodersh/itchat)
3 | # to forward WeChat messages
4 |
5 | import itchat
6 | from itchat.content import TEXT,MAP,CARD,NOTE,SHARING,PICTURE,RECORDING,VOICE,ATTACHMENT,VIDEO,FRIENDS,SYSTEM
7 |
8 | from requests.exceptions import MissingSchema
9 | from .bus import MessageBus, MsgDirection
10 | from .base import BaseBotInstance, EmptyBot
11 | from .models import Message, ChannelType, MessageType
12 | from .helpers import get_now_date_time, get_logger
13 | from .config import config
14 | import sys
15 | from .db import get_redis
16 | from .filestore import get_qiniu
17 | from .photostore import Imgur, VimCN, BasePhotoStore
18 | import io
19 | import imghdr
20 |
21 | logger = get_logger("WeChat")
22 |
23 | # TODO: Do not use global variables if we have better solutions
24 | wxHandle, wxRooms, wxRoomNicks, myUid = None, {}, {}, ''
25 | photo_store = None
26 |
27 |
28 | def upload_photo(data):
29 | global photo_store
30 |
31 | if not photo_store:
32 | return None, "No photo store available"
33 |
34 | url = photo_store.upload_image(filedata=data)
35 | if url is None:
36 | return None, "Failed to upload Image"
37 |
38 | return url, None
39 |
40 |
41 | def log_message(msgtype, msg):
42 | logger.info(msgtype + " message.")
43 | logger.info(msg)
44 |
45 |
46 | def handle_message(msg, content):
47 | global wxHandle, wxRooms, myUid
48 | room = msg["FromUserName"]
49 | nick = msg["ActualNickName"]
50 | if wxRooms.get(room) is None:
51 | logger.info("Not in rooms to forward!!!")
52 | return
53 | if msg["ActualUserName"] == myUid:
54 | logger.info("My own message:)")
55 | return
56 |
57 | date, time = get_now_date_time()
58 | fish_msg = Message(
59 | ChannelType.Wechat, nick, wxRooms[room], content,
60 | mtype=MessageType.Text, date=date, time=time)
61 | wxHandle.send_to_bus(wxHandle,fish_msg)
62 |
63 |
64 | def wechatExit():
65 | global wxHandle, wxRoomNicks
66 | date, time = get_now_date_time()
67 | for i in list(wxRoomNicks.keys()):
68 | exit_msg = Message(
69 | ChannelType.Wechat, "_fishroom_", i, "Wechat is logged out!",
70 | mtype=MessageType.Text, date=date, time=time)
71 | wxHandle.send_to_bus(wxHandle, exit_msg)
72 |
73 |
74 | @itchat.msg_register(TEXT, isFriendChat=False, isGroupChat=True, isMpChat=False)
75 | def on_text_message(msg):
76 | log_message(TEXT, msg)
77 | content = msg["Content"]
78 | handle_message(msg, content)
79 |
80 |
81 | @itchat.msg_register(MAP, isFriendChat=False, isGroupChat=True, isMpChat=False)
82 | def on_map_message(msg):
83 | log_message(MAP, msg)
84 | content = "(Map message received)"
85 | handle_message(msg, content)
86 |
87 |
88 | @itchat.msg_register(CARD, isFriendChat=False, isGroupChat=True, isMpChat=False)
89 | def on_card_message(msg):
90 | log_message(CARD, msg)
91 | content = "(Card message received)"
92 | handle_message(msg, content)
93 |
94 |
95 | @itchat.msg_register(NOTE, isFriendChat=False, isGroupChat=True, isMpChat=False)
96 | def on_note_message(msg):
97 | log_message(NOTE, msg)
98 | content = "(Note message received)"
99 | handle_message(msg, content)
100 |
101 |
102 | @itchat.msg_register(SHARING, isFriendChat=False, isGroupChat=True, isMpChat=False)
103 | def on_sharing_message(msg):
104 | log_message(SHARING, msg)
105 | content = msg["Url"]
106 | handle_message(msg, content)
107 |
108 |
109 | @itchat.msg_register(PICTURE, isFriendChat=False, isGroupChat=True, isMpChat=False)
110 | def on_picture_message(msg):
111 | log_message(PICTURE, msg)
112 | dlfn = msg["Text"]
113 | filename = msg["FileName"]
114 | photo = dlfn()
115 | if len(photo) == 0:
116 | return
117 | url, err = upload_photo(photo)
118 | if url is None:
119 | logger.info("Failed to upload photo")
120 | else:
121 | handle_message(msg, url)
122 |
123 |
124 | @itchat.msg_register(RECORDING, isFriendChat=False, isGroupChat=True, isMpChat=False)
125 | def on_recording_message(msg):
126 | log_message(RECORDING, msg)
127 | content = "(Recording message received)"
128 | handle_message(msg, content)
129 |
130 |
131 | @itchat.msg_register(VOICE, isFriendChat=False, isGroupChat=True, isMpChat=False)
132 | def on_voice_message(msg):
133 | log_message(VOICE, msg)
134 | content = "(Voice message received)"
135 | handle_message(msg, content)
136 |
137 |
138 | @itchat.msg_register(ATTACHMENT, isFriendChat=False, isGroupChat=True, isMpChat=False)
139 | def on_attachment_message(msg):
140 | log_message(ATTACHMENT, msg)
141 | dlfn = msg["Text"]
142 | filename = msg["FileName"]
143 | att = dlfn()
144 | if len(att)==0:
145 | return
146 | url, err = upload_photo(att)
147 | if url is None:
148 | logger.info("Failed to upload attachment.")
149 | else:
150 | handle_message(msg, url)
151 |
152 |
153 | @itchat.msg_register(VIDEO, isFriendChat=False, isGroupChat=True, isMpChat=False)
154 | def on_video_message(msg):
155 | log_message(VIDEO, msg)
156 | content = "(Video message received)"
157 | handle_message(msg, content)
158 |
159 |
160 | def wxdebug():
161 | # Test if these global variables are set
162 | global wxHandle, wxRooms, wxRoomNicks, myUid
163 | logger.info("Debugging...")
164 | logger.info(wxHandle)
165 | logger.info(wxRooms)
166 | logger.info(wxRoomNicks)
167 | logger.info(myUid)
168 |
169 |
170 | class WechatHandle(BaseBotInstance):
171 |
172 | ChanTag = ChannelType.Wechat
173 | SupportMultiline = True
174 | SupportPhoto = True
175 |
176 | def __init__(self, roomNicks):
177 | global wxRooms, myUid
178 | itchat.auto_login(hotReload=True, enableCmdQR=2, exitCallback=wechatExit)
179 | all_rooms = itchat.get_chatrooms(update=True)
180 | for r in all_rooms:
181 | if r['NickName'] in roomNicks:
182 | wxRooms[r['UserName']] = r['NickName']
183 | wxRoomNicks[r['NickName']] = r['UserName']
184 | logger.info('Room {} found.'.format(r["NickName"]))
185 | else:
186 | logger.info('{}: {}'.format(r['UserName'], r['NickName']))
187 |
188 | friends = itchat.get_friends()
189 | myUid = friends[0]["UserName"]
190 |
191 | def send_to_bus(self, msg):
192 | raise NotImplementedError()
193 |
194 | def send_photo(self, target, photo_data, sender=None):
195 | ft = imghdr.what('', photo_data)
196 | if ft is None:
197 | return
198 | filename = "image." + ft
199 | data_io = io.BytesIO(photo_data)
200 | roomid = wxRoomNicks[target]
201 | if sender is not None:
202 | itchat.send(msg="{} sent a photo...".format(sender), toUserName=roomid)
203 | itchat.send_image(fileDir=filename, toUserName=roomid, file_=data_io)
204 |
205 | def send_msg(self, target, content, sender=None, first=False, **kwargs):
206 | logger.info("Sending message to " + target)
207 | roomid = wxRoomNicks[target]
208 | if sender is not None:
209 | itchat.send(msg="[{}] {}".format(sender,content), toUserName=roomid)
210 | else:
211 | itchat.send(content, toUserName=roomid)
212 |
213 |
214 | def Wechat2FishroomThread(wx: WechatHandle, bus: MessageBus):
215 | if wx is None or isinstance(wx, EmptyBot):
216 | return
217 |
218 | def send_to_bus(self, msg):
219 | bus.publish(msg)
220 |
221 | wx.send_to_bus = send_to_bus
222 |
223 |
224 | def Fishroom2WechatThread(wx: WechatHandle, bus: MessageBus):
225 | if wx is None or isinstance(wx, EmptyBot):
226 | logger.info("Error creating Fishroom2WechatThread")
227 | return
228 | for msg in bus.message_stream():
229 | logger.info("message opt from bus is: " + str(msg.opt))
230 | myid_chn = config[msg.channel].get("me")
231 |
232 | if msg.opt is not None:
233 | id_chn = msg.opt.get(msg.channel)
234 |
235 | if myid_chn is not None and myid_chn == id_chn:
236 | logger.info("message from " + id_chn + ", setting sender to None.")
237 | msg.sender = None
238 | wx.forward_msg_from_fishroom(msg)
239 |
240 |
241 | def init():
242 | global photo_store, wxHandle
243 | redis_client = get_redis()
244 |
245 | provider = config['photo_store']['provider']
246 | if provider == "imgur":
247 | options = config['photo_store']['options']
248 | photo_store = Imgur(**options)
249 | elif provider == "vim-cn":
250 | photo_store = VimCN()
251 | elif provider == "qiniu":
252 | photo_store = get_qiniu(redis_client, config)
253 |
254 | im2fish_bus = MessageBus(redis_client, MsgDirection.im2fish)
255 | fish2im_bus = MessageBus(redis_client, MsgDirection.fish2im)
256 |
257 | roomNicks = [b["wechat"]
258 | for _, b in config['bindings'].items() if "wechat" in b]
259 | wxHandle = WechatHandle(roomNicks)
260 |
261 | return (
262 | wxHandle,
263 | im2fish_bus, fish2im_bus,
264 | )
265 |
266 |
267 | def main():
268 | if "wechat" not in config:
269 | return
270 |
271 | from .runner import run_threads
272 | bot, im2fish_bus, fish2im_bus = init()
273 | wxdebug()
274 | # The two threads and itchat.run are all blocking,
275 | # so put all of them in run_threads
276 | run_threads([
277 | (Wechat2FishroomThread, (bot, im2fish_bus, ), ),
278 | (Fishroom2WechatThread, (bot, fish2im_bus, ), ),
279 | (itchat.run, (), )
280 | ])
281 |
282 |
283 | def test():
284 | global wxHandle
285 | roomNicks = [b["wechat"] for _, b in config['bindings'].items()]
286 | wxHandle = WechatHandle(roomNicks)
287 |
288 | def send_to_bus(self, msg):
289 | logger.info(msg.dumps())
290 | wxHandle.send_to_bus = send_to_bus
291 | wxHandle.process(block=True)
292 |
293 |
294 | if __name__ == "__main__":
295 | import argparse
296 | parser = argparse.ArgumentParser()
297 | parser.add_argument("--test", default=False, action="store_true")
298 | args = parser.parse_args()
299 |
300 | if args.test:
301 | test()
302 | else:
303 | main()
304 |
305 | # vim: ts=4 sw=4 sts=4 expandtab
306 |
--------------------------------------------------------------------------------
/fishroom/xmpp.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import sleekxmpp
3 | from .bus import MessageBus, MsgDirection
4 | from .base import BaseBotInstance, EmptyBot
5 | from .models import Message, ChannelType, MessageType
6 | from .helpers import get_now_date_time
7 | from .config import config
8 |
9 |
10 | class XMPPHandle(sleekxmpp.ClientXMPP, BaseBotInstance):
11 | ChanTag = ChannelType.XMPP
12 |
13 | def __init__(self, server, port, jid, password, rooms, nick):
14 | sleekxmpp.ClientXMPP.__init__(self, jid, password)
15 |
16 | self.rooms = rooms
17 | self.nick = nick
18 |
19 | self.add_event_handler("session_start", self.on_start)
20 | self.add_event_handler("groupchat_message", self.on_muc_message)
21 |
22 | self.register_plugin('xep_0045') # Multi-User Chat
23 | self.register_plugin('xep_0199') # XMPP Ping
24 |
25 | self.srvaddress = (server, port)
26 |
27 | # if not self.connect((server, port)):
28 | # raise Exception("Unable to connect to XMPP server")
29 |
30 | def on_start(self, event):
31 | self.get_roster()
32 | self.send_presence()
33 | for room in self.rooms:
34 | self.plugin['xep_0045'].joinMUC(
35 | room, self.nick, wait=True)
36 | print("[xmpp] joined room %s" % room)
37 |
38 | def on_muc_message(self, msg):
39 | if msg['mucnick'] != self.nick and msg['id']:
40 | date, time = get_now_date_time()
41 | mtype = MessageType.Command \
42 | if self.is_cmd(msg['body']) \
43 | else MessageType.Text
44 |
45 | msg = Message(
46 | ChannelType.XMPP,
47 | msg['mucnick'], msg['from'].bare, msg['body'],
48 | mtype=mtype, date=date, time=time)
49 | self.send_to_bus(self, msg)
50 |
51 | def msg_tmpl(self, sender=None, reply_quote="", reply_to=""):
52 | return "{content}" if sender is None else \
53 | "[{sender}] {reply_quote}{content}"
54 |
55 | def send_msg(self, target, content, sender=None, first=False, **kwargs):
56 | tmpl = self.msg_tmpl(sender)
57 | reply_quote = ""
58 | if first and 'reply_text' in kwargs:
59 | reply_to = kwargs['reply_to']
60 | reply_text = kwargs['reply_text']
61 | if len(reply_text) > 5:
62 | reply_text = reply_text[:5] + '...'
63 | reply_quote = "「Re {reply_to}: {reply_text}」".format(
64 | reply_text=reply_text, reply_to=reply_to)
65 |
66 | mbody = tmpl.format(sender=sender, content=content,
67 | reply_quote=reply_quote)
68 |
69 | self.send_message(mto=target, mbody=mbody, mtype='groupchat')
70 |
71 | def send_to_bus(self, msg):
72 | raise Exception("Not implemented")
73 |
74 |
75 | def XMPP2FishroomThread(xmpp_handle: XMPPHandle, bus: MessageBus):
76 | if xmpp_handle is None or isinstance(xmpp_handle, EmptyBot):
77 | return
78 |
79 | def send_to_bus(self, msg):
80 | bus.publish(msg)
81 |
82 | xmpp_handle.send_to_bus = send_to_bus
83 | xmpp_handle.connect(xmpp_handle.srvaddress, reattempt=True)
84 | xmpp_handle.process(block=True)
85 |
86 |
87 | def Fishroom2XMPPThread(xmpp_handle: XMPPHandle, bus: MessageBus):
88 | if xmpp_handle is None or isinstance(xmpp_handle, EmptyBot):
89 | return
90 | for msg in bus.message_stream():
91 | xmpp_handle.forward_msg_from_fishroom(msg)
92 |
93 |
94 | def init():
95 | from .db import get_redis
96 | redis_client = get_redis()
97 | im2fish_bus = MessageBus(redis_client, MsgDirection.im2fish)
98 | fish2im_bus = MessageBus(redis_client, MsgDirection.fish2im)
99 |
100 | rooms = [b["xmpp"] for _, b in config['bindings'].items() if "xmpp" in b]
101 | server = config['xmpp']['server']
102 | port = config['xmpp']['port']
103 | nickname = config['xmpp']['nick']
104 | jid = config['xmpp']['jid']
105 | password = config['xmpp']['password']
106 |
107 | return (
108 | XMPPHandle(server, port, jid, password, rooms, nickname),
109 | im2fish_bus, fish2im_bus,
110 | )
111 |
112 |
113 | def main():
114 | if "xmpp" not in config:
115 | return
116 |
117 | from .runner import run_threads
118 | bot, im2fish_bus, fish2im_bus = init()
119 | run_threads([
120 | (XMPP2FishroomThread, (bot, im2fish_bus, ), ),
121 | (Fishroom2XMPPThread, (bot, fish2im_bus, ), ),
122 | ])
123 |
124 |
125 | def test():
126 | rooms = [b["xmpp"] for _, b in config['bindings'].items()]
127 | server = config['xmpp']['server']
128 | port = config['xmpp']['port']
129 | nickname = config['xmpp']['nick']
130 | jid = config['xmpp']['jid']
131 | password = config['xmpp']['password']
132 |
133 | xmpp_handle = XMPPHandle(server, port, jid, password, rooms, nickname)
134 |
135 | def send_to_bus(self, msg):
136 | print(msg.dumps())
137 | xmpp_handle.send_to_bus = send_to_bus
138 | xmpp_handle.process(block=True)
139 |
140 |
141 | if __name__ == "__main__":
142 | import argparse
143 | parser = argparse.ArgumentParser()
144 | parser.add_argument("--test", default=False, action="store_true")
145 | args = parser.parse_args()
146 |
147 | if args.test:
148 | test()
149 | else:
150 | main()
151 |
152 | # vim: ts=4 sw=4 sts=4 expandtab
153 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pytz
2 | redis
3 | marshmallow==2.1.0
4 | tornado==4.5.2
5 | tornado-redis
6 | irc
7 | requests
8 | sleekxmpp
9 | pillow
10 | qiniu
11 | python-magic
12 | dateutils
13 | aiohttp==2.3.3
14 | matrix-client
15 | itchat
16 |
--------------------------------------------------------------------------------
/test/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tuna/fishroom/a76daf5b88bb116a136123b270d8064ddfca4401/test/__init__.py
--------------------------------------------------------------------------------
/test/test_pastebin.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding:utf-8 -*-
3 | from __future__ import print_function, division, unicode_literals
4 | import sys
5 | from os.path import dirname
6 | sys.path.insert(0, dirname(dirname(__file__)))
7 |
8 | from fishroom.textstore import Pastebin
9 | from .config import pastebin_api_key
10 |
11 | if __name__ == "__main__":
12 |
13 | p = Pastebin(pastebin_api_key)
14 | print(p.new_paste("test new paste, lallala", "bigeagle"))
15 |
16 | # vim: ts=4 sw=4 sts=4 expandtab
17 |
--------------------------------------------------------------------------------
/test/test_vinergy.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding:utf-8 -*-
3 | from __future__ import print_function, division, unicode_literals
4 | import sys
5 | from os.path import dirname
6 | sys.path.insert(0, dirname(dirname(__file__)))
7 | from fishroom.textstore import Vinergy
8 |
9 |
10 | if __name__ == "__main__":
11 |
12 | p = Vinergy()
13 | print(p.new_paste("test new paste, lallala", "bigeagle"))
14 |
15 | # vim: ts=4 sw=4 sts=4 expandtab
16 |
--------------------------------------------------------------------------------