├── requirements.txt ├── listener-mode ├── requirements.txt ├── log4f.py ├── README.md └── app.py ├── demo.py ├── doc ├── qrcode.jpg └── token_config.png ├── docker-compose.yml ├── settings.py ├── .gitignore ├── README.md ├── log4f.py ├── Dockerfile └── wechat.py /requirements.txt: -------------------------------------------------------------------------------- 1 | redis 2 | wechat_sdk 3 | -------------------------------------------------------------------------------- /listener-mode/requirements.txt: -------------------------------------------------------------------------------- 1 | tornado 2 | wechat_sdk 3 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | # -*- Encoding: utf-8 -*- 2 | from wechat import send 3 | 4 | send(u'亲爱的') 5 | -------------------------------------------------------------------------------- /doc/qrcode.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackonYang/wechat-notification/HEAD/doc/qrcode.jpg -------------------------------------------------------------------------------- /doc/token_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackonYang/wechat-notification/HEAD/doc/token_config.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | 5 | web: 6 | build: . 7 | ports: 8 | - "8000:8000" 9 | volumes: 10 | - .:/src 11 | environment: 12 | - REDIS_CONN=redis://redis:6379/2 13 | - REDIS_HOST=redis 14 | - DEBUG=True 15 | depends_on: 16 | - redis 17 | command: 18 | python wechat.py 19 | hostname: wechat-notification 20 | 21 | redis: 22 | image: daocloud.io/redis:3.2.4 23 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import os 3 | 4 | 5 | REDIS_CONN = { 6 | 'host': os.getenv('REDIS_HOST', '127.0.0.1'), 7 | 'port': 6379, 8 | 'db': 8, 9 | } 10 | 11 | WECHAT_CONN = { 12 | 'username': '', 13 | 'password': '', 14 | } 15 | 16 | NOTIFY_IDS = [ 17 | '2271762240', # k 18 | ] 19 | 20 | MSG_SIGNATURE = 'AutoSend by Python' 21 | 22 | 23 | try: 24 | from local_settings import * # noqa 25 | except Exception as e: 26 | pass 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # .gitignore by JackonYang 2 | 3 | database.sqlite3 4 | cookie/* 5 | 6 | 7 | ### Django ### 8 | media/* 9 | static/* 10 | db.sqlite3 11 | local_settings.py 12 | *.log 13 | 14 | 15 | ### python ### 16 | *.py[cod] 17 | __pycache__/ 18 | *.so # C extensions 19 | pip-log.txt # Installer logs 20 | 21 | 22 | ### Vim ### 23 | .ropeproject/* 24 | [._]*.s[a-w][a-z] 25 | [._]s[a-w][a-z] 26 | *~ 27 | 28 | ### Pycharm ### 29 | .idea/* 30 | 31 | 32 | ### Mac ### 33 | .DS_Store 34 | 35 | 36 | # SVN 37 | .svn 38 | 39 | 40 | !PLACEHOLDER 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wechat-notification 2 | 3 | 通过微信公众号, 将通知消息推送至个人微信. 无需认证公众号, 可群发. 4 | 5 | v1.0 版本, 支持用户主动订阅 / 退订推送消息, 因稳定性低, 应用场景少, 不再继续维护. 6 | 代码和文档统一移至 listener-mode 目录下. 7 | 8 | ## DEMO 9 | 10 | #### settings.py 11 | 12 | 配置 redis, 微信连接信息. 13 | 14 | 执行获取微信昵称对应的 ID 15 | 16 | ```bash 17 | $ python wechat.py 18 | ``` 19 | 20 | 将接受推送消息的帐号 ID 填写至 settings.py 的 `NOTIFY_IDS` 变量中. 21 | 22 | ```bash 23 | $ python demo.py 24 | ``` 25 | 26 | 微信号会会收到推送消息. 27 | 28 | 这个版本更加适合程序调用. 29 | 30 | ## 开发环境 31 | 32 | ```bash 33 | $ sudo pip install -r requirements.txt 34 | ``` 35 | -------------------------------------------------------------------------------- /log4f.py: -------------------------------------------------------------------------------- 1 | # -*- Encoding: utf-8 -*- 2 | """log in 4 files""" 3 | import logging 4 | import os 5 | 6 | 7 | DEFAULT_LOG_LEVEL = logging.DEBUG 8 | 9 | 10 | def get_4f_logger(formatter, path, name=''): 11 | log = logging.getLogger(name) 12 | log.setLevel(DEFAULT_LOG_LEVEL) 13 | 14 | if not os.path.exists(path): 15 | os.makedirs(path) 16 | 17 | lvls = ['debug', 'info', 'warn', 'error'] 18 | 19 | for lvl in lvls: 20 | logfile = os.path.join(path, '{}.log'.format(lvl.lower())) 21 | hdlr = logging.FileHandler(logfile) 22 | hdlr.setLevel(getattr(logging, lvl.upper())) 23 | hdlr.setFormatter(formatter) 24 | log.addHandler(hdlr) 25 | return log 26 | 27 | 28 | def debug_logger(log_dir='log', logger_name='debug'): 29 | log_format = ('%(asctime)s|%(levelname)s|%(message)s' 30 | '|%(filename)s-%(lineno)s') 31 | return get_4f_logger(logging.Formatter(log_format), log_dir, logger_name) 32 | 33 | 34 | if __name__ == '__main__': 35 | log = debug_logger() 36 | log.error('test log') 37 | log.info('info log') 38 | -------------------------------------------------------------------------------- /listener-mode/log4f.py: -------------------------------------------------------------------------------- 1 | # -*- Encoding: utf-8 -*- 2 | """log in 4 files""" 3 | import logging 4 | import os 5 | 6 | 7 | DEFAULT_LOG_LEVEL = logging.DEBUG 8 | 9 | 10 | def get_4f_logger(formatter, path, name=''): 11 | log = logging.getLogger(name) 12 | log.setLevel(DEFAULT_LOG_LEVEL) 13 | 14 | if not os.path.exists(path): 15 | os.makedirs(path) 16 | 17 | lvls = ['debug', 'info', 'warn', 'error'] 18 | 19 | for lvl in lvls: 20 | logfile = os.path.join(path, '{}.log'.format(lvl.lower())) 21 | hdlr = logging.FileHandler(logfile) 22 | hdlr.setLevel(getattr(logging, lvl.upper())) 23 | hdlr.setFormatter(formatter) 24 | log.addHandler(hdlr) 25 | return log 26 | 27 | 28 | def debug_logger(log_dir='log', logger_name='debug'): 29 | log_format = ('%(asctime)s|%(levelname)s|%(message)s' 30 | '|%(filename)s-%(lineno)s') 31 | return get_4f_logger(logging.Formatter(log_format), log_dir, logger_name) 32 | 33 | 34 | if __name__ == '__main__': 35 | log = debug_logger() 36 | log.error('test log') 37 | log.info('info log') 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # django APP 2 | # do not operate database in APP's docker 3 | # for there would be several apps, sharing one database 4 | # 5 | FROM daocloud.io/python:2.7 6 | MAINTAINER JackonYang > 7 | 8 | RUN apt-get update 9 | 10 | # install packages from source code 11 | RUN apt-get install -y wget 12 | RUN apt-get install -y cmake 13 | 14 | 15 | # http://stackoverflow.com/questions/23524976/capturing-output-of-python-script-run-inside-a-docker-container 16 | ENV PYTHONUNBUFFERED=0 17 | 18 | 19 | # https://docs.docker.com/engine/reference/builder/#arg 20 | ARG pip_host=pypi.douban.com 21 | ARG pip_root_url=http://pypi.douban.com/simple/ 22 | 23 | # upgrade pip to latest version 24 | RUN pip install --upgrade pip -i $pip_root_url --trusted-host $pip_host 25 | 26 | # logging 27 | RUN pip install -i $pip_root_url --trusted-host $pip_host rollbar==0.13.8 28 | 29 | 30 | # pygit2 and its dependencies 31 | RUN wget https://github.com/libgit2/libgit2/archive/v0.24.0.tar.gz && \ 32 | tar xzf v0.24.0.tar.gz && \ 33 | cd libgit2-0.24.0/ && \ 34 | cmake . && \ 35 | make && \ 36 | make install 37 | RUN ldconfig 38 | RUN pip install -i $pip_root_url --trusted-host $pip_host pygit2==0.24.2 39 | 40 | 41 | # common 42 | RUN pip install -i $pip_root_url --trusted-host $pip_host redis==2.10.5 43 | 44 | RUN pip install -i $pip_root_url --trusted-host $pip_host wechat_sdk 45 | 46 | 47 | COPY . /src 48 | WORKDIR /src 49 | 50 | RUN pip install -r requirements.txt 51 | -------------------------------------------------------------------------------- /wechat.py: -------------------------------------------------------------------------------- 1 | # -*- Encoding: utf-8 -*- 2 | import json 3 | import redis 4 | from os.path import join, dirname 5 | from wechat_sdk import WechatExt 6 | # from wechat_sdk.exceptions import NeedLoginError 7 | 8 | from log4f import debug_logger 9 | import settings 10 | 11 | 12 | LOGIN_TIMEOUT = 4 * 3600 # 4 hours 13 | r = redis.StrictRedis(**settings.REDIS_CONN) 14 | log = debug_logger(join(dirname(__file__), 'log/notify'), 'root.notify') 15 | 16 | 17 | def login(username, password): 18 | d = r.get(username) 19 | if d: 20 | log.info('lazy login. use cookie, username={}'.format(username)) 21 | return WechatExt(username, password, login=False, **json.loads(d)) 22 | else: 23 | print username, password 24 | wechat = WechatExt(username, password, login=False) 25 | wechat.login() 26 | log.info('login to wechat server. username={}'.format(username)) 27 | r.setex(username, LOGIN_TIMEOUT, 28 | json.dumps(wechat.get_token_cookies(), indent=4)) 29 | return wechat 30 | 31 | 32 | def init_info(): 33 | rsp_str = login(**settings.WECHAT_CONN).get_user_list() 34 | for u in json.loads(rsp_str)['contacts']: 35 | name = u['nick_name'].encode('utf8') 36 | fakeid = u['id'] 37 | r.set(fakeid, name) 38 | print '{:12} -- {}'.format(fakeid, name) 39 | 40 | 41 | def send(msg_text): 42 | msg = u'{}. {}'.format(msg_text, settings.MSG_SIGNATURE) 43 | for fakeid in settings.NOTIFY_IDS: 44 | name = r.get(fakeid) or fakeid 45 | log.info(u'msg sent. user={}. msg={}'.format(name, msg)) 46 | login(**settings.WECHAT_CONN).send_message(fakeid, msg) 47 | 48 | 49 | if __name__ == "__main__": 50 | init_info() 51 | # send(u'亲爱的') 52 | -------------------------------------------------------------------------------- /listener-mode/README.md: -------------------------------------------------------------------------------- 1 | # wechat-notification 2 | 3 | 通过微信公众号, 将通知消息推送至个人微信. 无需认证公众号, 可群发. 4 | 5 | ## DEMO 6 | 7 | demo 环境的公众号 8 | 9 | ![qrcode](../doc/qrcode.jpg) 10 | 11 | 关注后发送 `dy` 2 个英文字母, `订阅`消息推送. 12 | 13 | 通过 [http://safebang.org/wechat-api/send?msg=hello](http://safebang.org/wechat-api/send?msg=hello), 推送消息. 14 | 15 | 其中, hello 为要推送的消息 16 | 17 | 18 | 19 | ## 服务器部署 20 | 21 | #### clone 并安装依赖 22 | 23 | ```shell 24 | $ git clone git@github.com:JackonYang/wechat-notification.git 25 | $ cd wechat-notification 26 | $ sudo pip install -r requirements.txt 27 | ``` 28 | 29 | #### 启动服务器 30 | 31 | 微信要求走 80 端口, linux 监听 80 端口需要 root 权限. 32 | 33 | ```shell 34 | $ sudo python app.py --port=80 --username=xxxx --password=xxxx --token=xxxx 35 | ``` 36 | 37 | 其中, 38 | 39 | username 和 password 是微信公众号的用户名和密码. 40 | 41 | token 是 `开发者中心-服务器配置` 中设置的 token 42 | 43 | ![token 配置](../doc/token_config.png) 44 | 45 | 46 | ## 自定义操作指令 47 | 48 | 默认指令: 49 | 50 | - dy: 订阅 51 | - td: 退订 52 | - ls: 已订阅用户列表 53 | - help: 帮助文档 54 | 55 | 在 CmdRoot 类中增加 classmethod 方法即可. 56 | 57 | 方法名必须以 `cmd_` 为前缀, 依次接收 fakeid, user(nickname) 两个参数. 58 | 例如, `dy` 命令对应的 `cmd_dy` 定义如下: 59 | 60 | ```python 61 | class CmdRobot: 62 | @classmethod 63 | def cmd_dy(self, fakeid, user): 64 | user_fakeid[user] = fakeid 65 | return 'subscribed. nickname={}, fakeid={}'.format(user, fakeid) 66 | ``` 67 | 68 | ## 本地调试 69 | 70 | 每次都在 server 上进行代码调试, 不方便. 71 | 可以通过 nginx 转发, 实现本地调试. 72 | 73 | nginx 参考配置如下: 74 | 75 | ```shell 76 | upstream tornadoes { 77 | server 106.44.127.160:8002; # 开发环境 IP: port 78 | } 79 | 80 | server { 81 | listen 80; 82 | server_name safebang.org www.safebang.org; 83 | 84 | root /home/jackon/safebang; 85 | index index.html index.htm; 86 | 87 | location / { 88 | proxy_pass_header Server; 89 | proxy_set_header Host $http_host; 90 | proxy_redirect off; 91 | proxy_set_header X-Real-IP $remote_addr; 92 | proxy_set_header X-Scheme $scheme; 93 | proxy_pass http://tornadoes; 94 | } 95 | } 96 | ``` 97 | 98 | 其中, `server 106.44.127.160:8002` 改为真实的 IP, 端口即可. 99 | 100 | 开发环境启动服务器, 监听 8002 端口. 101 | ```shell 102 | $ python app.py --port=8002 --username=xxxx --password=xxxx --token=xxxx 103 | ``` 104 | -------------------------------------------------------------------------------- /listener-mode/app.py: -------------------------------------------------------------------------------- 1 | # -*- Encoding: utf-8 -*- 2 | import json 3 | import time 4 | from os.path import join, dirname, exists 5 | from os import makedirs 6 | 7 | import tornado.ioloop 8 | import tornado.web 9 | from tornado.options import define, options 10 | 11 | from wechat_sdk import WechatBasic, WechatExt 12 | from wechat_sdk.exceptions import NeedLoginError 13 | 14 | from log4f import debug_logger 15 | 16 | log = debug_logger(join(dirname(__file__), 'log'), 'root') 17 | 18 | define("username", default='username', help="username of wechat", type=str) 19 | define("password", default='password', help="password of wechat", type=str) 20 | define("token", default='', help="token of wechat", type=str) 21 | define("port", default=8000, help="run on the given port", type=int) 22 | define("debug", default=False, help="run in Debug mode", type=bool) 23 | 24 | 25 | today = lambda: time.strftime('%Y%m%d', time.localtime()) 26 | cookie_dir = join(dirname(__file__), 'cookie') 27 | 28 | 29 | def login_http(username, password): 30 | wechat = WechatExt(username, password) 31 | wechat.login() 32 | 33 | if not exists(cookie_dir): 34 | makedirs(cookie_dir) 35 | fn = join(cookie_dir, 'cookie_{}.html'.format(today())) 36 | with open(fn, 'w') as f: 37 | json.dump(wechat.get_token_cookies(), f, indent=4) 38 | 39 | return wechat 40 | 41 | 42 | def login_cookie(username, password): 43 | fn = join(cookie_dir, 'cookie_{}.html'.format(today())) 44 | if not exists(fn): 45 | raise NeedLoginError 46 | 47 | with open(fn, 'r') as f: 48 | kwargs = json.load(f) 49 | 50 | return WechatExt(username=username, password=password, 51 | login=False, **kwargs) 52 | 53 | 54 | def action(username, password, meth_name, *args, **kwargs): 55 | try: 56 | wechat = login_cookie(username, password) 57 | ret = getattr(wechat, meth_name)(*args, **kwargs) 58 | except NeedLoginError: 59 | wechat = login_http(username, password) 60 | ret = getattr(wechat, meth_name)(*args, **kwargs) 61 | 62 | return ret 63 | 64 | 65 | user_fakeid = dict() 66 | 67 | 68 | class CmdRobot: 69 | @classmethod 70 | def cmd_dy(self, fakeid, user): 71 | user_fakeid[user] = fakeid 72 | return 'subscribed. nickname={}, fakeid={}'.format(user, fakeid) 73 | 74 | @classmethod 75 | def cmd_td(self, fakeid, user): 76 | if user in user_fakeid: 77 | user_fakeid.pop(user) 78 | return 'unsubscribe successfully. nickname={}'.format(user) 79 | 80 | @classmethod 81 | def cmd_ls(self, fakeid, user): 82 | if user_fakeid: 83 | return 'user list:\n{}'.format('\n'.join(user_fakeid)) 84 | else: 85 | return 'no user subscribed' 86 | 87 | @classmethod 88 | def cmd_help(self, fakeid, user): 89 | return u'回复: \ndy -- 订阅\ntd -- 退订\nls -- 已订阅用户列表\nhelp -- 帮助文档' 90 | 91 | 92 | class WechatListener(tornado.web.RequestHandler): 93 | 94 | def auth(self): 95 | args = ['signature', 'timestamp', 'nonce'] 96 | kwargs = {k: self.get_argument(k, '') for k in args} 97 | 98 | wechat = WechatBasic(token=options.token) 99 | if not wechat.check_signature(**kwargs): 100 | self.set_status(403) 101 | self.write('auth failed') 102 | self.finish() 103 | 104 | def get(self): 105 | """auth 106 | 107 | """ 108 | self.auth() 109 | 110 | self.write(self.get_argument('echostr', 'echostr not found')) 111 | 112 | def post(self): 113 | log.debug('enter') 114 | self.auth() 115 | 116 | wechat_basic = WechatBasic(token=options.token) 117 | wechat_basic.parse_data(self.request.body) 118 | 119 | base_msg = wechat_basic.get_message() 120 | c, t = base_msg.content, base_msg.time 121 | 122 | log.info('recieve: {}, {}'.format(c.encode('utf8'), t)) 123 | 124 | cmd = CmdRobot.cmd_help 125 | fakeid, user = None, None 126 | try: 127 | raw_msg = action(options.username, options.password, 128 | 'get_message_list') 129 | msgs = json.loads(raw_msg)['msg_item'] 130 | for msg in msgs: 131 | if c == msg['content'] and t == msg['date_time']: 132 | fakeid = msg['fakeid'] 133 | user = msg['nick_name'].encode('utf8') 134 | log.info('found. {}, {}'.format(fakeid, user)) 135 | cmd = getattr(CmdRobot, 'cmd_{}'.format(c.encode('utf8')).lower(), 136 | CmdRobot.cmd_help) 137 | break 138 | else: 139 | log.error('no match. {}'.format(msgs[0]['content'])) 140 | self.write(wechat_basic.response_text( 141 | 'system error. please wait a second and send it again\n')) 142 | except Exception as e: 143 | log.error(e) 144 | pass 145 | 146 | self.write(wechat_basic.response_text(cmd(fakeid, user))) 147 | log.debug('exit') 148 | 149 | 150 | class WechatSender(tornado.web.RequestHandler): 151 | 152 | def get(self): 153 | msg = self.get_argument('msg') 154 | 155 | for fakeid in user_fakeid.values(): 156 | action(options.username, options.password, 157 | 'send_message', fakeid, msg) 158 | self.write('msg sent to {} users: {}'.format(len(user_fakeid), 159 | ', '.join(user_fakeid))) 160 | 161 | 162 | def main(): 163 | tornado.options.parse_command_line() 164 | 165 | application = tornado.web.Application([ 166 | (r"/wechat-api/listen", WechatListener), 167 | (r"/wechat-api/send", WechatSender), 168 | ], 169 | debug=options.debug) 170 | application.listen(options.port) 171 | tornado.ioloop.IOLoop.current().start() 172 | 173 | 174 | if __name__ == "__main__": 175 | main() 176 | --------------------------------------------------------------------------------