├── micro_msg_bot ├── __init__.py ├── logger.py ├── testing_with_login.py ├── testing.py ├── meme.py ├── rule.py ├── bot.py ├── static │ └── index.html └── server.py ├── .dockerignore ├── single_run.py ├── .gitignore ├── Dockerfile └── README.md /micro_msg_bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.swp 2 | **/__pycache__/ 3 | */searched 4 | .git 5 | -------------------------------------------------------------------------------- /single_run.py: -------------------------------------------------------------------------------- 1 | from micro_msg_bot.bot import EmotionBot 2 | bot = EmotionBot(console_qr=True, cache_path='wxpy_bot.pkl') 3 | bot.join() 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .ropeproject/ 3 | __pycache__/ 4 | *.pkl 5 | .idea/ 6 | .mypy_cache/ 7 | searched 8 | settings 9 | bot_status 10 | -------------------------------------------------------------------------------- /micro_msg_bot/logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | 4 | logger = logging.getLogger('mm-bot') 5 | stdout = logging.StreamHandler(sys.stdout) 6 | formatter = logging.Formatter('%(asctime)s\t%(message)s') 7 | stdout.setFormatter(formatter) 8 | logger.addHandler(stdout) 9 | logger.setLevel('INFO') 10 | -------------------------------------------------------------------------------- /micro_msg_bot/testing_with_login.py: -------------------------------------------------------------------------------- 1 | from .testing import * 2 | from .bot import EmotionBot 3 | 4 | bot = EmotionBot(console_qr=True, cache_path='wxpy_bot.pkl') 5 | 6 | 7 | def test_get_media_id(): 8 | from .rule import _gif_media_id 9 | return _gif_media_id(*test_meme_url(), bot=bot) 10 | 11 | 12 | def test_send(): 13 | bot.file_helper.send_image('.gif', media_id=test_get_media_id()) 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | RUN apk add --no-cache libxslt-dev 3 | RUN apk add --no-cache --virtual .build-deps gcc g++ libxml2-dev libc-dev \ 4 | && pip install --no-cache-dir wxpy bs4 lxml flask gunicorn==18.0 'flask_socketio<5' eventlet \ 5 | && apk del .build-deps 6 | EXPOSE 80 7 | ADD micro_msg_bot /micro_msg_bot 8 | RUN pip install --no-cache-dir pytest \ 9 | && pytest /micro_msg_bot/testing.py 10 | VOLUME /data 11 | WORKDIR /data 12 | CMD PYTHONPATH=/ gunicorn -k eventlet -w 1 micro_msg_bot.server:app -b 0.0.0.0:80 -t 300 13 | -------------------------------------------------------------------------------- /micro_msg_bot/testing.py: -------------------------------------------------------------------------------- 1 | def test_keyword_by_suffix(): 2 | from .rule import keyword_by_suffix 3 | assert keyword_by_suffix('呵呵.jpg') == ('呵呵', 1) 4 | assert keyword_by_suffix(' 呵呵 .gIf ') == ('呵呵', 1) 5 | assert keyword_by_suffix(' 呵呵 .jpG *3 ') == ('呵呵', 3) 6 | assert keyword_by_suffix(' 呵呵 .Png *6 ') == ('呵呵', 5) 7 | assert keyword_by_suffix(' .WebP ') == ('', 1) 8 | assert keyword_by_suffix(' 故事.WebP ') == ('故事', 1) 9 | 10 | 11 | def test_keyword_by_at(): 12 | from .rule import keyword_by_at 13 | assert keyword_by_at('@流氓 呵呵 ', '流氓') == ('呵呵', 1) 14 | assert keyword_by_at('@流氓 @流氓 呵呵', '流氓') == ('@流氓 呵呵', 1) 15 | assert keyword_by_at('@流氓 呵呵 * 2', '流氓') == ('呵呵', 2) 16 | assert keyword_by_at('@流氓 呵呵 * 88', '流氓') == ('呵呵', 5) 17 | 18 | 19 | def test_meme_url(): 20 | from . import meme 21 | return meme.image_url('呵呵') 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # microMsg-bot 2 | 微信表情机器人 3 | 4 | ## 原理 5 | 6 | - 使用了 wxpy ,一个 Python 的微信机器人库 7 | - 表情利用了 doutula 的搜索接口 8 | - web 界面使用了 Flask,一个 Python的 HTTP 库 9 | - 浏览器端使用了 Socket.IO 来跟服务端通讯 10 | 11 | ## 使用方法 12 | 前往 [bot.libivan.com](http://bot.libivan.com),打开手机微信用摄像头扫描二维码登录。 13 | 14 | 登录后可以开启 [后缀发表情] 和 [被@回复表情] 两个功能。 15 | 16 | ## 后缀发表情 17 | 效果图: 18 | 19 | ![效果图](https://user-images.githubusercontent.com/7613160/30479150-80c80826-9a46-11e7-8629-324d85469511.png) 20 | 21 | ![效果图](https://user-images.githubusercontent.com/7613160/30423776-7a3519de-9976-11e7-81c2-5512fb906994.png) 22 | 23 | ## 被@回复表情 24 | 效果图: 25 | 26 | ![效果图](https://user-images.githubusercontent.com/7613160/30422855-b83876c0-9973-11e7-8a95-aefa4a0669b0.png) 27 | 28 | ![效果图](https://user-images.githubusercontent.com/7613160/30422854-b8371384-9973-11e7-97d4-f9f6cdb29496.png) 29 | 30 | ## 加入斗图测试群 31 | ![斗图群](https://user-images.githubusercontent.com/7613160/30469695-12ceba80-9a24-11e7-88d6-474af136e49f.png) 32 | 33 | ## 挂机 34 | 网页版微信每次离线后,都要扫二维码才能重新登录,因此可以用服务器挂着账号来维持session。 35 | 36 | 在bot.libivan.com成功登录后打开chrome控制台可以看到如图所示的log: 37 | ![图示](https://user-images.githubusercontent.com/7613160/30424325-2a5e5b12-9978-11e7-8042-8e1f4298ab55.png) 38 | 39 | 复制黄框内容,前往红线链接,在新打开的窗口控制台中粘贴进去执行,便可同时使用网页版微信和机器人。 40 | ![图示](https://user-images.githubusercontent.com/7613160/30424548-da97d562-9978-11e7-9c65-48680a4cf9cd.png) 41 | 42 | 当网页版微信离线后只需要刷新页面或者重新执行代码便可脱离手机使用网页版微信。 43 | 44 | ## 部署 45 | 46 | ### 简易方式 47 | ``` 48 | docker run -p 80:80 qwivan/micromsg-bot 49 | ``` 50 | 51 | ### 使用 docker volume 52 | ``` 53 | docker run -d --restart=always -p 80:80 -e KEY=YOUR_SECRET_KEY -e CORS_ALLOWED_ORIGINS='https://bot.libivan.com' --name mmbot -v mmbot:/data qwivan/micromsg-bot 54 | ``` 55 | -------------------------------------------------------------------------------- /micro_msg_bot/meme.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import shelve 3 | import time 4 | from bs4 import BeautifulSoup 5 | from functools import lru_cache 6 | from threading import Lock 7 | from .logger import logger 8 | 9 | user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.101 Safari/537.36' 10 | session = requests.Session() 11 | session.headers['user-agent'] = user_agent 12 | 13 | 14 | def large_img(url): 15 | if url.startswith('//'): 16 | url = 'http:' + url 17 | if url.endswith('!dtb'): 18 | url = url[:-4] 19 | return url.replace('/bmiddle/', '/large/', 1) 20 | 21 | 22 | @lru_cache() 23 | def search(keyword): 24 | resp = session.get('https://www.doutula.com/search', params={'keyword': keyword}) 25 | 26 | if resp.status_code != 200: 27 | logger.info('www.doutula.com new session %s', session.cookies.get_dict()) 28 | resp = session.get('https://www.doutula.com/search', params={'keyword': keyword}) 29 | 30 | soup = BeautifulSoup(resp.text, 'lxml') 31 | result = ((i.get('data-original'), i.get('data-backup')[:-4]) for i in soup.select('img[data-original]') if i.get('class') != ['gif']) 32 | return [[large_img(url) for url in imgs] for imgs in result] 33 | 34 | 35 | def download_gif(f, *url): 36 | for u in url: 37 | resp = requests.get(u, allow_redirects=False) 38 | if resp.status_code == 200: 39 | f.write(resp.content) 40 | f.flush() 41 | return 42 | 43 | 44 | keyword_dict_locks = Lock() 45 | keyword_locks = {} 46 | searched_lock = Lock() 47 | 48 | 49 | def image_url(keyword): 50 | with keyword_dict_locks: 51 | kw_lock = keyword_locks.get(keyword, None) 52 | if not kw_lock: 53 | kw_lock = Lock() 54 | keyword_locks[keyword] = kw_lock 55 | 56 | with kw_lock: 57 | img = None 58 | with searched_lock: 59 | with shelve.open('searched') as searched: 60 | imgs = searched.get(keyword, None) 61 | if imgs: 62 | img = imgs.pop(0) 63 | imgs.append(img) 64 | searched[keyword] = imgs 65 | if img: 66 | return img 67 | 68 | if not img: 69 | imgs = search(keyword) 70 | logger.info('New keyword "%s", %d result%s', keyword, len(imgs), 's' if len(imgs) > 1 else '') 71 | imgs = imgs[:10] 72 | if imgs: 73 | img = imgs.pop(0) 74 | imgs.append(img) 75 | with searched_lock: 76 | with shelve.open('searched') as searched: 77 | searched[keyword] = imgs 78 | return img 79 | -------------------------------------------------------------------------------- /micro_msg_bot/rule.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import functools 4 | from wxpy import * 5 | from tempfile import NamedTemporaryFile 6 | from concurrent.futures import ThreadPoolExecutor 7 | from . import meme 8 | from .logger import logger 9 | 10 | pool = ThreadPoolExecutor(100) 11 | 12 | 13 | class BotSetting: 14 | suffix_reply = True 15 | at_reply = False 16 | # TODO blacklist or white list 17 | 18 | 19 | def keyword_by_suffix(msg: str): 20 | prefix, _, suffix = msg.lower().strip().rpartition('.') 21 | if suffix.strip() in ('gif', 'jpg', 'png', 'webp'): 22 | return prefix.strip(), 1 23 | else: 24 | groups = re.findall('(.*)\.(jpg|gif|png|webp)\s*(x|×|X|\*)\s*(\d+)\s*$', msg, re.I) 25 | if groups and groups[0][-1].isdigit(): 26 | group = groups[0] 27 | keyword = group[0].strip() 28 | times = int(group[-1]) 29 | if times > 5: 30 | times = 5 31 | return keyword, times 32 | else: 33 | return None, 0 34 | 35 | 36 | def keyword_by_at(msg: str, name): 37 | keyword = re.sub('@%s' % name, '', msg, 1).strip() 38 | groups = re.findall('(.*)\s*(x|×|X|✖️|\*)\s*(\d+)\s*$', keyword) 39 | if groups and groups[0][-1].isdigit(): 40 | group = groups[0] 41 | keyword = group[0].strip() 42 | times = int(group[-1]) 43 | if times > 5: 44 | times = 5 45 | else: 46 | times = 1 47 | return keyword, times 48 | 49 | 50 | @functools.lru_cache() 51 | def _gif_media_id(*url, bot): 52 | tmp = NamedTemporaryFile() 53 | try: 54 | logger.info('Downloading image, URLs: %s', url) 55 | meme.download_gif(tmp, *url) 56 | logger.info('Uploading image, URLs: %s', url) 57 | media_id = bot.upload_file(tmp.name) 58 | finally: 59 | tmp.close() 60 | return media_id 61 | 62 | 63 | def reg_event(bot): 64 | gif_media_id = functools.partial(_gif_media_id, bot=bot) 65 | 66 | def media_id_by(keyword): 67 | img = meme.image_url(keyword) 68 | if img: 69 | media_id = gif_media_id(*img) 70 | logger.info('image: "%s", media_id: %s', img, media_id) 71 | return media_id 72 | 73 | @bot.register(msg_types=TEXT, except_self=False) 74 | def reply(msg: Message): 75 | keyword, times = None, 0 76 | if bot.setting.suffix_reply: 77 | keyword, times = keyword_by_suffix(msg.text) 78 | if not keyword and bot.setting.at_reply and msg.is_at and isinstance(msg.sender, Group): 79 | keyword, times = keyword_by_at(msg.text, msg.sender.self.name) 80 | 81 | if keyword: 82 | logger.info('%s searched keyword "%s" x %d', bot.self.name, keyword, times) 83 | for media_id in pool.map(media_id_by, [keyword] * times, chunksize=times): 84 | msg.reply_image('.gif', media_id=media_id) 85 | -------------------------------------------------------------------------------- /micro_msg_bot/bot.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import shelve 3 | from wxpy import * 4 | from threading import Lock 5 | from .rule import reg_event, BotSetting 6 | from .logger import logger 7 | 8 | settings_lock = Lock() 9 | 10 | 11 | class EmotionBot(Bot): 12 | class TimeoutException(Exception): 13 | def __init__(self, uuid, status): 14 | self.uuid = uuid 15 | self.status = status 16 | 17 | def __init__(self, name=None, need_login=True, timeout_max=15, qr_callback=None, *args, **kwargs): 18 | self.name = name 19 | self.timeout_count = 0 # QR code timeout count 20 | self.setting = None 21 | if need_login: 22 | self.login(timeout_max=timeout_max, qr_callback=qr_callback, *args, **kwargs) 23 | 24 | def login(self, timeout_max=15, qr_callback=None, *args, **kwargs): 25 | def _qr_callback(uuid, status, qrcode): 26 | if status == '408': 27 | self.timeout_count += 1 28 | if self.timeout_count > timeout_max: 29 | raise self.TimeoutException(uuid, status) 30 | elif status == '400': # exit thread when time out at QR code waiting for scan 31 | raise self.TimeoutException(uuid, status) 32 | if callable(qr_callback): 33 | qr_callback(uuid, status, qrcode) 34 | 35 | super().__init__(qr_callback=_qr_callback if qr_callback else None, *args, **kwargs) 36 | 37 | uin = str(self.self.uin) 38 | with settings_lock: 39 | with shelve.open('settings') as settings: 40 | self.setting = settings.get(uin, None) or BotSetting() 41 | 42 | def save_setting(setting, name, value): 43 | setting.__dict__[name] = value 44 | with settings_lock: 45 | with shelve.open('settings') as settings: 46 | settings[uin] = self.setting 47 | logger.info('%s updated setting', self.self.name) 48 | 49 | BotSetting.__setattr__ = save_setting 50 | 51 | reg_event(self) 52 | 53 | def self_msg(self, msg): 54 | try: 55 | self.self.send(msg) 56 | except exceptions.ResponseError: 57 | self.file_helper.send(msg) 58 | 59 | 60 | class SyncEmotionBot(EmotionBot): 61 | def __init__(self, need_login=True, *args, **kwargs): 62 | super().__init__(need_login=False, *args, **kwargs) 63 | self.uuid_lock = threading.Event() 64 | self.login_lock = threading.Event() 65 | self.timeout_count = 0 # QR code timeout count 66 | self.thread = None 67 | 68 | if need_login: 69 | self.login(*args, **kwargs) 70 | 71 | def login(self, qr_callback=None, *args, **kwargs): 72 | def _qr_callback(uuid, status, qrcode): 73 | if status == '0': 74 | self.uuid = uuid 75 | self.uuid_lock.set() 76 | if callable(qr_callback): 77 | qr_callback(uuid, status, qrcode) 78 | 79 | kwargs.update(qr_callback=_qr_callback) 80 | self.thread = threading.Thread(target=self._login_thread, args=args, kwargs=kwargs) 81 | self.thread.start() 82 | self.uuid_lock.wait() # lock release when QR code uuid got 83 | return self.uuid 84 | 85 | def _login_thread(self, *args, **kwargs): 86 | try: 87 | super().login(*args, **kwargs) 88 | except self.TimeoutException as e: 89 | logger.warning('uuid=%s, status=%s, timeout', e.uuid, e.status) 90 | return 91 | self.login_lock.set() 92 | 93 | def is_logged(self, timeout=None): 94 | return self.login_lock.wait(timeout) 95 | -------------------------------------------------------------------------------- /micro_msg_bot/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 微信表情机器人 6 | 7 | 61 | 62 | 63 | 64 | 65 |

二维码加载中...

66 | 90 | 使用教程 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /micro_msg_bot/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import shelve 3 | import secrets 4 | import os 5 | from flask import Flask, request, session 6 | from flask_socketio import SocketIO, emit, join_room 7 | from threading import Lock 8 | from .bot import EmotionBot 9 | from .logger import logger 10 | 11 | app = Flask(__name__) 12 | app.secret_key = os.environ.get('KEY', 'YOUR_SECRET_KEY') 13 | cors_allowed_origins = None 14 | if 'CORS_ALLOWED_ORIGINS' in os.environ: 15 | cors_allowed_origins = os.environ['CORS_ALLOWED_ORIGINS'].split(',') 16 | socketio = SocketIO(app, cors_allowed_origins=cors_allowed_origins) 17 | 18 | 19 | @app.route('/') 20 | def login(): 21 | if 'sessionID' not in session: 22 | session['sessionID'] = str(secrets.randbits(256)) 23 | return app.send_static_file('index.html') 24 | 25 | 26 | bots = {} 27 | bot_status_lock = Lock() 28 | 29 | 30 | def get_logout_callback_by_session_id(sessionID): 31 | def logout_callback(): 32 | with bot_status_lock: 33 | with shelve.open('bot_status') as bot_status: 34 | bot_status[sessionID] = False 35 | if sessionID in bots: 36 | logger.info('%s logged out!', bots[sessionID].self.name) 37 | del bots[sessionID] 38 | os.remove(sessionID) 39 | socketio.emit('logout', room=sessionID) 40 | 41 | return logout_callback 42 | 43 | 44 | @socketio.on('login') 45 | def login(): 46 | def success_ack(bot, sessionID, nickname): 47 | socketio.emit('setting', bot.setting.__dict__, room=sessionID) 48 | socketio.emit('success', (dict(bot.core.s.cookies.items()), bot.core.loginInfo['url'], nickname), room=sessionID) 49 | 50 | def background_thread(sid, sessionID): 51 | 52 | def qr_callback(uuid, status, qrcode): 53 | logger.info('%s %s', uuid, status) 54 | if status != '408': 55 | socketio.emit('qr', (uuid, status), room=sessionID) 56 | 57 | def login_callback(): 58 | if hasattr(bots.get(sessionID, None), 'logout'): 59 | bots[sessionID].logout() 60 | 61 | try: 62 | bot = EmotionBot(qr_callback=qr_callback, cache_path=sessionID, logout_callback=get_logout_callback_by_session_id(sessionID), login_callback=login_callback) 63 | # socketio.server.enter_room(room=cache_path, sid=sid) 64 | bots[sessionID] = bot # TODO 线程安全问题,用户有可能在此前已经logout 65 | with bot_status_lock: 66 | with shelve.open('bot_status') as bot_status: 67 | bot_status[sessionID] = bot.alive 68 | logger.info('%s logged in, cache at %s', bot.self.name, sessionID) 69 | success_ack(bot, sessionID, bot.self.name) 70 | bot.self_msg('已成功登录\n使用教程 git.io/wxbot') 71 | except EmotionBot.TimeoutException as e: 72 | logger.warning('uuid=%s, status=%s, timeout', e.uuid, e.status) 73 | socketio.emit('qr', (e.uuid, 'timeout'), room=sessionID) 74 | 75 | if 'sessionID' not in session: 76 | return 77 | join_room(room=session['sessionID'], sid=request.sid) 78 | bot = bots.get(session['sessionID'], None) 79 | if hasattr(bot, 'alive') and bot.alive and hasattr(bot, 'self') and hasattr(bot.self, 'name'): 80 | success_ack(bot, request.sid, bot.self.name) 81 | else: 82 | socketio.start_background_task(background_thread, sid=request.sid, sessionID=session['sessionID']) 83 | 84 | 85 | @socketio.on('at_reply') 86 | def at_reply(flag): 87 | if not isinstance(flag, bool): 88 | return 89 | bot = bots.get(session.get('sessionID', None), None) 90 | if bot: 91 | bot.setting.at_reply = flag 92 | logger.info('%s: set at_reply to %s' % (bot.self.name, flag)) 93 | emit('setting', bot.setting.__dict__) 94 | bot.self_msg('已%s被@回复表情' % ('开启' if flag else '关闭')) 95 | 96 | 97 | @socketio.on('suffix_reply') 98 | def suffix_reply(flag): 99 | if not isinstance(flag, bool): 100 | return 101 | bot = bots.get(session.get('sessionID', None), None) 102 | if bot: 103 | bot.setting.suffix_reply = flag 104 | logger.info('%s: set suffix_reply to %s' % (bot.self.name, flag)) 105 | emit('setting', bot.setting.__dict__) 106 | bot.self_msg('已%s后缀发表情' % ('开启' if flag else '关闭')) 107 | 108 | 109 | class SessionDeadException(Exception): 110 | pass 111 | 112 | 113 | def qr_callback(uuid, status, qrcode): 114 | if status != 200: 115 | raise SessionDeadException 116 | 117 | 118 | with shelve.open('bot_status') as bot_status: 119 | for sessionID, alive in bot_status.items(): 120 | if alive: 121 | logger.info('try to log back in %s', sessionID) 122 | try: 123 | bot = EmotionBot(timeout_max=0, cache_path=sessionID, qr_callback=qr_callback, logout_callback=get_logout_callback_by_session_id(sessionID)) 124 | except (EmotionBot.TimeoutException, SessionDeadException): 125 | logger.info('%s log back in failed', sessionID) 126 | del bot_status[sessionID] 127 | continue 128 | bots[sessionID] = bot # TODO 线程安全问题,用户有可能在此前已经logout 129 | bot_status[sessionID] = bot.alive 130 | logger.info('%s logged back in, cache at %s', bot.self.name, sessionID) 131 | 132 | if __name__ == '__main__': 133 | socketio.run(app) 134 | --------------------------------------------------------------------------------