├── README.md ├── mq.js ├── orig └── mq.pretty.js ├── requirements.txt ├── webqqircd.py └── webqqircd.service /README.md: -------------------------------------------------------------------------------- 1 | # webqqircd 2 | 3 | webqqircd类似于bitlbee,在WebQQ(SmartQQ)和IRC间建起桥梁,可以使用IRC客户端收发消息。大部分代码来自[wechatircd](https://github.com/MaskRay/wechatircd),为适配QQ做了一些修改。 4 | 5 | ``` 6 | IRC WebSocket HTTPS 7 | IRC client --- webqqircd.py --------- browser ----- wx.qq.com 8 | modified mq.js 9 | ``` 10 | 11 | ## 原理 12 | 13 | 修改WebQQ(用的JS,通过WebSocket把信息发送到服务端,服务端兼做IRC服务端,把IRC客户端的命令通过WebSocket传送到网页版JS执行。未实现IRC客户端,因此无法把QQ群/讨论组的消息转发到另一个IRC服务器(打通两个群的bot)。 14 | 15 | ## WebQQ局限 16 | 17 | - WebQQ不支持发送图片,也无法获悉别人发送了图片 18 | - 消息发送后不知道成功与否,`mq.model.chat`中`sendMsg(h)`的`onSuccess`为空函数 19 | - 无法获知群/讨论组信息变化(如成员变化等)`mq.model.chat`中`addGroup(x)`只判断群/讨论组存在与否,不判断信息变化 20 | - 看不到WebQQ会话内新加入的群/讨论组友的消息 21 | - 没有办法区分`<`和`<`等 22 | 23 | ## 安装 24 | 25 | 需要Python 3.5或以上,支持`async/await`语法 26 | `pip install -r requirements.txt`安装依赖 27 | 28 | ### Arch Linux 29 | 30 | - `yaourt -S webqqircd-git`。会在`/etc/webqqircd/`下生成自签名证书。 31 | - 把`/etc/webqqircd/cert.pem`导入到浏览器(见下文) 32 | - `systemctl start webqqircd`会运行`/usr/bin/webqqircd --http-cert /etc/webqqircd/cert.pem --http-key /etc/webqqircd/key.pem --http-root /usr/share/webqqircd` 33 | 34 | IRC服务器默认监听127.0.0.1:6668 (IRC)和127.0.0.1:9002 (HTTPS + WebSocket over TLS)。 35 | 36 | 如果你在非本机运行,建议配置IRC over TLS,设置IRC connection password:`/usr/bin/webqqircd --http-cert /etc/webqqircd/cert.pem --http-key /etc/webqqircd/key.pem --http-root /usr/share/webqqircd --irc-cert /path/to/irc.key --irc-key /path/to/irc.cert --irc-password yourpassword` 37 | 38 | 可以把HTTPS私钥证书用作IRC over TLS私钥证书。使用WeeChat的话,如果觉得让WeeChat信任证书比较麻烦(gnutls会检查hostname),可以用: 39 | ``` 40 | set irc.server.qq.ssl on` 41 | set irc.server.qq.ssl_verify off 42 | set irc.server.qq.password yourpassword` 43 | ``` 44 | 45 | ### 其他发行版 46 | 47 | - `openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -out cert.pem -subj '/CN=127.0.0.1' -days 9999`创建密钥与证书。 48 | - 把`cert.pem`导入浏览器,见下文 49 | - `./webqqircd.py --http-cert cert.pem --http-key key.pem` 50 | 51 | ### 浏览器设置 52 | 53 | Chrome/Chromium 54 | 55 | - 访问`chrome://settings/certificates`,导入`cert.pem`,在Authorities标签页选择该证书,Edit->Trust this certificate for identifying websites. 56 | - 安装Switcheroo Redirector扩展,把重定向至。 57 | 58 | Firefox 59 | 60 | - 安装Redirector扩展,重定向js,设置` Applies to: Main window (address bar), Scripts`。 61 | - 访问重定向后的js URL,报告Your connection is not secure,Advanced->Add Exception->Confirm Security Exception 62 | 63 | ![](https://maskray.me/static/2016-04-11-webqqircd/demo.jpg) 64 | 65 | ## 使用 66 | 67 | - 运行`webqqircd.py` 68 | - 访问,修改后`mq.js`会向服务器发起WebSocket连接 69 | - IRC客户端连接127.1:6668(weechat的话使用`/server add qq 127.1/6668`),会自动加入`+qq` channel 70 | 71 | 在`+qq`发信并不会群发,只是为了方便查看有哪些朋友。 72 | 73 | 在`+qq` channel可以执行一些命令: 74 | 75 | - `help`,帮助 76 | - `status [pattern]`,已获取的QQ朋友、群/讨论组列表,支持 pattern 参数用来筛选满足 pattern 的结果,目前仅支持子串查询。如要查询所有群/讨论组,由于群/讨论组由 `&` 开头,所以可以执行 `status &`。 77 | - `eval $password $expr`: 如果运行时带上了`--password $password`选项,这里可以eval,方便调试,比如`eval $password client.webqq_users` 78 | 79 | ## 服务器选项 80 | 81 | - Join mode. There are three modes, the default is `--join auto`: join the channel upon receiving the first message. The other two are `--join all`: join all the channels; `--join manual`: no automatic join. 82 | - Groups that should not join automatically. This feature supplements join mode. 83 | + `--ignore 'fo[o]' bar`, do not auto join chatrooms whose channel name(generated from DisplayName) matches regex `fo[o]` or `bar` 84 | + `--ignore-display-name 'fo[o]' bar`, do not auto join chatrooms whose DisplayName matches regex `fo[o]` or `bar` 85 | - HTTP/WebSocket related options 86 | + `--http-cert cert.pem`, TLS certificate for HTTPS/WebSocket. You may concatenate certificate+key, specify a single PEM file and omit `--http-key`. Use HTTP if neither --http-cert nor --http-key is specified. 87 | + `--http-key key.pem`, TLS key for HTTPS/WebSocket. 88 | + `--http-listen 127.1 ::1`, change HTTPS/WebSocket listen address to `127.1` and `::1`, overriding `--listen`. 89 | + `--http-port 9000`, change HTTPS/WebSocket listen port to 9000. 90 | + `--http-root .`, the root directory to serve `injector.js`. 91 | - `-l 127.0.0.1`, change IRC/HTTP/WebSocket listen address to `127.0.0.1`. 92 | - IRC related options 93 | + `--irc-cert cert.pem`, TLS certificate for IRC over TLS. You may concatenate certificate+key, specify a single PEM file and omit `--irc-key`. Use plain IRC if neither --irc-cert nor --irc-key is specified. 94 | + `--irc-key key.pem`, TLS key for IRC over TLS. 95 | + `--irc-listen 127.1 ::1`, change IRC listen address to `127.1` and `::1`, overriding `--listen`. 96 | + `--irc-password pass`, set the connection password to `pass`. 97 | + `--irc-port 6667`, IRC server listen port. 98 | - Server side log 99 | + `--logger-ignore '&test0' '&test1'`, list of ignored regex, do not log contacts/groups whose names match 100 | + `--logger-mask '/tmp/webqq/$channel/%Y-%m-%d.log'`, format of log filenames 101 | + `--logger-time-format %H:%M`, time format of server side log 102 | 103 | ## IRC命令 104 | 105 | - 标准IRC channel名以`#`开头 106 | - QQ群/讨论组名以`&`开头。`SpecialChannel#update` 107 | - 联系人带有mode `+v` (voice, 通常显示为前缀`+`)。`SpecialChannel#update_detail` 108 | 109 | `server-time` extension from IRC version 3.1, 3.2. `webqqircd.py` includes the timestamp (obtained from JavaScript) in messages to tell IRC clients that the message happened at the given time. See . See for Client support of IRCv3. 110 | 111 | Configuration for WeeChat: 112 | ``` 113 | /set irc.server_default.capabilities "account-notify,away-notify,cap-notify,multi-prefix,server-time,znc.in/server-time-iso,znc.in/self-message" 114 | ``` 115 | 116 | Supported IRC commands: 117 | 118 | - `/cap`, supported capabilities. 119 | - `/dcc send $nick/$channel $filename`, send image or file。This feature borrows the command `/dcc send` which is well supported in IRC clients. See . 120 | - `/list`, list groups. 121 | - `/names`, update nicks in the channel. 122 | - `/part $channel`, no longer receive messages from the channel. It just borrows the command `/part` and it will not leave the group. 123 | - `/query $nick`, open a chat window with `$nick`. 124 | - `/who $channel`, see the member list. 125 | 126 | Multi-line messages: 127 | 128 | - `!m line0\nline1` 129 | 130 | ## JS改动 131 | 132 | 原始文件`mq.js`在Chrome DevTools里格式化后得到`orig/mq.pretty.js`,可以用`diff -u orig/mq.pretty.js mq.js`查看改动。 133 | 134 | 修改的地方都有`//@`标注,结合diff,方便WebQQ更新后重新应用这些修改。增加的代码中大多数地方都用`try catch`保护,出错则`consoleerr(ex.stack)`。 135 | 136 | 目前的改动如下: 137 | 138 | ### `mq.js`开头 139 | 140 | 创建到服务端的WebSocket连接,若`onerror`则自动重连。监听`onmessage`,收到的消息为服务端发来的控制命令:`send_text_message`等。 141 | 142 | ### 定期把通讯录发送到服务端 143 | 144 | 获取所有联系人(朋友、订阅号、群/讨论组),`deliveredContact`记录投递到服务端的联系人,`deliveredContact`记录同处一群/讨论组的非直接联系人。 145 | 146 | 每隔一段时间把未投递过的联系人发送到服务端。 147 | 148 | ### 收到QQ服务器消息`messageProcess` 149 | 150 | 原有代码会更新未读标记数及声音提醒,现在改为若成功发送到服务端则不再提醒,以免浏览器的这个标签页造成干扰。 151 | 152 | ## Python服务端代码 153 | 154 | 当前只有一个文件`webqqircd.py`,从miniircd抄了很多代码,后来自己又搬了好多RFC上的用不到的东西…… 155 | 156 | ``` 157 | . 158 | ├── Web HTTP(s)/WebSocket server 159 | ├── Server IRC server 160 | ├── Channel 161 | │   ├── StandardChannel `#`开头的IRC channel 162 | │   ├── StatusChannel `+qq`,查看控制当前QQ会话 163 | │   └── SpecialChannel QQ群/讨论组对应的channel,仅该客户端可见 164 | ├── (User) 165 | │   ├── Client IRC客户端连接 166 | │   ├── SpecialUser QQ用户对应的user,仅该客户端可见 167 | ├── (IRCCommands) 168 | │   ├── UnregisteredCommands 注册前可用命令:NICK USER QUIT 169 | │   ├── RegisteredCommands 注册后可用命令 170 | ``` 171 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | ipdb 3 | ipython 4 | -------------------------------------------------------------------------------- /webqqircd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from argparse import ArgumentParser, Namespace 3 | from aiohttp import web 4 | #from ipdb import set_trace as bp 5 | from datetime import datetime, timezone 6 | import aiohttp, asyncio, inspect, json, logging.handlers, os, pprint, random, re, \ 7 | signal, socket, ssl, string, sys, time, traceback, uuid, weakref 8 | 9 | logger = logging.getLogger('webqqircd') 10 | im_name = 'QQ' 11 | 12 | 13 | def debug(msg, *args): 14 | logger.debug(msg, *args) 15 | 16 | 17 | def info(msg, *args): 18 | logger.info(msg, *args) 19 | 20 | 21 | def warning(msg, *args): 22 | logger.warning(msg, *args) 23 | 24 | 25 | def error(msg, *args): 26 | logger.error(msg, *args) 27 | 28 | 29 | class ExceptionHook(object): 30 | instance = None 31 | 32 | def __call__(self, *args, **kwargs): 33 | if self.instance is None: 34 | from IPython.core import ultratb 35 | self.instance = ultratb.VerboseTB(call_pdb=True) 36 | return self.instance(*args, **kwargs) 37 | 38 | 39 | ### HTTP serving js & WebSocket server 40 | 41 | class Web(object): 42 | instance = None 43 | 44 | def __init__(self, http_root): 45 | self.http_root = http_root 46 | self.ws = weakref.WeakSet() 47 | assert not Web.instance 48 | Web.instance = self 49 | 50 | async def handle_app_js(self, request): 51 | with open(os.path.join(self.http_root, 'mq.js'), 'rb') as f: 52 | return web.Response(body=f.read(), 53 | headers={'Content-Type': 'application/javascript; charset=UTF-8', 54 | 'Access-Control-Allow-Origin': '*'}) 55 | 56 | async def handle_web_socket(self, request): 57 | ws = web.WebSocketResponse() 58 | self.ws.add(ws) 59 | peername = request.transport.get_extra_info('peername') 60 | info('WebSocket client connected from %r', peername) 61 | await ws.prepare(request) 62 | async for msg in ws: 63 | if msg.tp == web.MsgType.text: 64 | try: 65 | data = json.loads(msg.data) 66 | Server.instance.on_websocket(data) 67 | except AssertionError: 68 | info('WebSocket client error') 69 | break 70 | except: 71 | raise 72 | elif msg.tp == web.MsgType.ping: 73 | try: 74 | ws.pong() 75 | except: 76 | break 77 | elif msg.tp == web.MsgType.close: 78 | break 79 | info('WebSocket client disconnected from %r', peername) 80 | for client in Server.instance.clients: 81 | client.on_websocket_close(peername) 82 | return ws 83 | 84 | def start(self, listens, port, tls, loop): 85 | self.loop = loop 86 | self.app = aiohttp.web.Application() 87 | self.app.router.add_route('GET', '/mq.js', self.handle_app_js) 88 | self.app.router.add_route('GET', '/ws', self.handle_web_socket) 89 | self.handler = self.app.make_handler() 90 | self.srv = [] 91 | for i in listens: 92 | self.srv.append(loop.run_until_complete( 93 | loop.create_server(self.handler, i, port, ssl=tls))) 94 | 95 | def stop(self): 96 | for i in self.srv: 97 | i.close() 98 | self.loop.run_until_complete(i.wait_closed()) 99 | self.loop.run_until_complete(self.app.shutdown()) 100 | self.loop.run_until_complete(self.handler.finish_connections(0)) 101 | self.loop.run_until_complete(self.app.cleanup()) 102 | 103 | def close_connections(self): 104 | for ws in self.ws: 105 | ws.send_str(json.dumps({'command': 'close'})) 106 | 107 | def send_file(self, receiver, filename, body): 108 | for ws in self.ws: 109 | try: 110 | body = body.decode('latin-1') 111 | ws.send_str(json.dumps({ 112 | 'command': 'send_file', 113 | 'receiver': receiver, 114 | 'filename': filename, 115 | 'body': body, 116 | })) 117 | except: 118 | pass 119 | break 120 | 121 | def send_text_message(self, receiver, msg): 122 | for ws in self.ws: 123 | try: 124 | ws.send_str(json.dumps({ 125 | 'command': 'send_text_message', 126 | 'receiver': receiver, 127 | 'message': msg, 128 | })) 129 | except: 130 | pass 131 | 132 | def web_eval(self, expr): 133 | for ws in self.ws: 134 | try: 135 | ws.send_str(json.dumps({ 136 | 'command': 'eval', 137 | 'expr': expr, 138 | })) 139 | except: 140 | pass 141 | break 142 | 143 | ### IRC utilities 144 | 145 | def irc_lower(s): 146 | irc_trans = str.maketrans(string.ascii_uppercase + '[]\\^', 147 | string.ascii_lowercase + '{}|~') 148 | return s.translate(irc_trans) 149 | 150 | 151 | # loose 152 | def irc_escape(s): 153 | s = re.sub(r',', '.', s) # `,` is used as seprator in IRC messages 154 | s = re.sub(r'&?', '', s) # chatroom name may include `&` 155 | s = re.sub(r'<[^>]*>', '', s) # remove emoji 156 | return re.sub(r'[^-\w$%^*()=./]', '', s) 157 | 158 | ### Commands 159 | 160 | class UnregisteredCommands(object): 161 | @staticmethod 162 | def cap(client, *args): 163 | if not args: return 164 | comm = args[0].lower() 165 | if comm == 'ls' or comm == 'list': 166 | client.reply('CAP * {} :server-time', args[0]) 167 | elif comm == 'req': 168 | client.capabilities = set(['server-time']) & set(args[1].split()) 169 | client.reply('CAP * ACK :{}', ' '.join(client.capabilities)) 170 | 171 | @staticmethod 172 | def nick(client, *args): 173 | if len(client.server.options.irc_password) and not client.authenticated: 174 | client.err_passwdmismatch('NICK') 175 | return 176 | if not args: 177 | client.err_nonicknamegiven() 178 | return 179 | client.server.change_nick(client, args[0]) 180 | 181 | @staticmethod 182 | def pass_(client, password): 183 | if len(client.server.options.irc_password) and password == client.server.options.irc_password: 184 | client.authenticated = True 185 | 186 | @staticmethod 187 | def quit(client): 188 | client.disconnect('Client quit') 189 | 190 | @staticmethod 191 | def user(client, user, mode, _, realname): 192 | if len(client.server.options.irc_password) and not client.authenticated: 193 | client.err_passwdmismatch('USER') 194 | return 195 | client.user = user 196 | client.realname = realname 197 | 198 | 199 | class RegisteredCommands: 200 | @staticmethod 201 | def away(client): 202 | pass 203 | 204 | @staticmethod 205 | def cap(client, *args): 206 | UnregisteredCommands.cap(client, *args) 207 | 208 | @staticmethod 209 | def info(client): 210 | client.rpl_info('{} users', len(client.server.nicks)) 211 | client.rpl_info('{} {} users', im_name, len(client.nick2special_user)) 212 | client.rpl_info('{} {} friends', im_name, 213 | len(StatusChannel.instance.shadow_members.get(client, {}))) 214 | client.rpl_info('{} {} rooms', im_name, len(client.name2special_room)) 215 | 216 | @staticmethod 217 | def invite(client, nick, channelname): 218 | if client.is_in_channel(channelname): 219 | client.get_channel(channelname).on_invite(client, nick) 220 | else: 221 | client.err_notonchannel(channelname) 222 | 223 | @staticmethod 224 | def ison(client, *nicks): 225 | client.reply('303 {} :{}', client.nick, 226 | ' '.join(nick for nick in nicks 227 | if client.has_special_user(nick) or 228 | client.server.has_nick(nick))) 229 | 230 | @staticmethod 231 | def join(client, arg): 232 | if arg == '0': 233 | channels = list(client.channels.values()) 234 | for channel in channels: 235 | channel.on_part(client, channel.name) 236 | else: 237 | for channelname in arg.split(','): 238 | if client.has_special_room(channelname): 239 | client.get_special_room(channelname).on_join(client) 240 | else: 241 | try: 242 | client.server.ensure_channel(channelname).on_join(client) 243 | except ValueError: 244 | client.err_nosuchchannel(channelname) 245 | 246 | @staticmethod 247 | def kick(client, channelname, nick, reason=None): 248 | if client.is_in_channel(channelname): 249 | client.get_channel(channelname).on_kick(client, nick, reason) 250 | else: 251 | client.err_notonchannel(channelname) 252 | 253 | @staticmethod 254 | def list(client, arg=None): 255 | if arg: 256 | channels = [client.get_channel(channelname) 257 | for channelname in arg.split(',') 258 | if client.has_channel(channelname) or 259 | client.has_special_room(channelname)] 260 | else: 261 | channels = set(client.channels.values()) 262 | for channel in client.name2special_room.values(): 263 | channels.add(channel) 264 | channels = list(channels) 265 | channels.sort(key=lambda ch: ch.name) 266 | for channel in channels: 267 | client.reply('322 {} {} {} :{}', client.nick, channel.name, 268 | channel.n_members(client), channel.topic) 269 | client.reply('323 {} :End of LIST', client.nick) 270 | 271 | @staticmethod 272 | def lusers(client): 273 | client.reply('251 :There are {} users and {} {} users (local to you) on 1 server', 274 | len(client.server.nicks), 275 | len(client.nick2special_user), 276 | im_name 277 | ) 278 | 279 | @staticmethod 280 | def mode(client, target, *args): 281 | if client.has_special_user(target): 282 | if args: 283 | client.err_umodeunknownflag() 284 | else: 285 | client.rpl_umodeis('') 286 | elif client.server.has_nick(target): 287 | if args: 288 | client.err_umodeunknownflag() 289 | else: 290 | client2 = client.server.get_nick(target) 291 | client.rpl_umodeis(client2.mode) 292 | elif client.has_special_room(target): 293 | client.get_special_room(target).on_mode(client) 294 | elif client.server.has_channel(target): 295 | client.server.get_channel(target).on_mode(client) 296 | else: 297 | client.err_nosuchchannel(target) 298 | 299 | @staticmethod 300 | def names(client, target): 301 | if not client.is_in_channel(target): 302 | client.err_notonchannel(target) 303 | return 304 | client.get_channel(target).on_names(client) 305 | 306 | @staticmethod 307 | def nick(client, *args): 308 | if not args: 309 | client.err_nonicknamegiven() 310 | return 311 | client.server.change_nick(client, args[0]) 312 | 313 | @staticmethod 314 | def notice(client, *args): 315 | RegisteredCommands.notice_or_privmsg(client, 'NOTICE', *args) 316 | 317 | @staticmethod 318 | def part(client, arg, *args): 319 | partmsg = args[0] if args else None 320 | for channelname in arg.split(','): 321 | if client.is_in_channel(channelname): 322 | client.get_channel(channelname).on_part(client, partmsg) 323 | else: 324 | client.err_notonchannel(channelname) 325 | 326 | @staticmethod 327 | def ping(client, *args): 328 | if not args: 329 | client.err_noorigin() 330 | return 331 | client.reply('PONG {} :{}', client.server.name, args[0]) 332 | 333 | @staticmethod 334 | def pong(client, *args): 335 | pass 336 | 337 | @staticmethod 338 | def privmsg(client, *args): 339 | RegisteredCommands.notice_or_privmsg(client, 'PRIVMSG', *args) 340 | 341 | @staticmethod 342 | def quit(client, *args): 343 | client.disconnect(args[0] if args else client.prefix) 344 | 345 | @staticmethod 346 | def stats(client, query): 347 | if len(query) == 1: 348 | if query == 'u': 349 | td = datetime.now() - client.server._boot 350 | client.reply('242 {} :Server Up {} days {}:{:02}:{:02}', 351 | client.nick, td.days, td.seconds // 3600, 352 | td.seconds // 60 % 60, td.seconds % 60) 353 | client.reply('219 {} {} :End of STATS report', client.nick, query) 354 | 355 | @staticmethod 356 | def summon(client, nick, msg): 357 | client.err_nologin(nick) 358 | 359 | @staticmethod 360 | def time(client): 361 | client.reply('391 {} {} :{}Z', client.nick, client.server.name, 362 | datetime.utcnow().isoformat()) 363 | 364 | @staticmethod 365 | def topic(client, channelname, new=None): 366 | if not client.is_in_channel(channelname): 367 | client.err_notonchannel(channelname) 368 | return 369 | client.get_channel(channelname).on_topic(client, new) 370 | 371 | @staticmethod 372 | def who(client, target): 373 | if client.has_special_user(target): 374 | client.get_special_user(target).on_who_member( 375 | client, StatusChannel.instance.name) 376 | elif client.server.has_nick(target): 377 | client.server.get_nick(target).on_who_member( 378 | client, client.server.name) 379 | elif client.is_in_channel(target): 380 | client.get_channel(target).on_who(client) 381 | client.reply('315 {} {} :End of WHO list', client.nick, target) 382 | 383 | @staticmethod 384 | def whois(client, *args): 385 | if not args: 386 | client.err_nonicknamegiven() 387 | return 388 | elif len(args) == 1: 389 | target = args[0] 390 | else: 391 | target = args[1] 392 | if client.has_special_user(target): 393 | client.get_special_user(target).on_whois(client) 394 | elif client.server.has_nick(target): 395 | client.server.get_nick(target).on_whois(client) 396 | else: 397 | client.err_nosuchnick(target) 398 | return 399 | client.reply('318 {} {} :End of WHOIS list', client.nick, target) 400 | 401 | @classmethod 402 | def notice_or_privmsg(cls, client, command, *args): 403 | if not args: 404 | client.err_norecipient(command) 405 | return 406 | if len(args) == 1: 407 | client.err_notexttosend() 408 | return 409 | target = args[0] 410 | msg = args[1] 411 | # on name conflict, prefer to resolve special user first 412 | if client.has_special_user(target): 413 | user = client.get_special_user(target) 414 | if user.is_friend: 415 | user.on_notice_or_privmsg(client, command, msg) 416 | elif command == 'PRIVMSG': 417 | client.err_nosuchnick(target) 418 | # then IRC nick 419 | elif client.server.has_nick(target): 420 | client2 = client.server.get_nick(target) 421 | client2.write(':{} {} {} :{}'.format( 422 | client.prefix, 'PRIVMSG', target, msg)) 423 | # IRC channel or special chatroom 424 | elif client.is_in_channel(target): 425 | client.get_channel(target).on_notice_or_privmsg( 426 | client, command, msg) 427 | elif command == 'PRIVMSG': 428 | client.err_nosuchnick(target) 429 | 430 | 431 | class SpecialCommands: 432 | @staticmethod 433 | def contact(client, data): 434 | friend = data['friend'] 435 | record = data['record'] 436 | debug('{}: '.format('friend' if friend else 'room_contact') + ', '.join([k + ':' + repr(record.get(k)) for k in ['uin', 'nick']])) 437 | client.ensure_special_user(record, 1 if friend else -1) 438 | 439 | @staticmethod 440 | def message(client, data): 441 | client.ensure_special_user(data['receiver']).on_websocket_message(data) 442 | 443 | @staticmethod 444 | def room(client, data): 445 | record = data['record'] 446 | debug('room: ' + ', '.join([k + ':' + repr(record.get(k)) for k in ['gid', 'name', 'memo', 'owner']])) 447 | client.ensure_special_room(record).update_detail(record) 448 | 449 | @staticmethod 450 | def room_message(client, data): 451 | client.ensure_special_room(data['receiver']).on_websocket_message(data) 452 | 453 | @staticmethod 454 | def self(client, data): 455 | debug('self: %r', data) 456 | client.uin = data['uin'] 457 | 458 | @staticmethod 459 | def send_file_message_nak(client, data): 460 | receiver = data['receiver'] 461 | filename = data['filename'] 462 | if client.has_special_room(receiver): 463 | room = client.get_special_room(receiver) 464 | client.write(':{} PRIVMSG {} :[文件发送失败] {}'.format( 465 | client.prefix, room.nick, filename)) 466 | elif client.has_special_user(receiver): 467 | user = client.get_special_user(receiver) 468 | client.write(':{} PRIVMSG {} :[文件发送失败] {}'.format( 469 | client.prefix, user.nick, filename)) 470 | 471 | 472 | @staticmethod 473 | def send_text_message_nak(client, data): 474 | receiver = data['receiver'] 475 | msg = data['message'] 476 | if client.has_special_room(receiver): 477 | room = client.get_special_room(receiver) 478 | client.write(':{} PRIVMSG {} :[文字发送失败] {}'.format( 479 | client.prefix, room.nick, msg)) 480 | elif client.has_special_user(receiver): 481 | user = client.get_special_user(receiver) 482 | client.write(':{} PRIVMSG {} :[文字发送失败] {}'.format( 483 | client.prefix, user.nick, msg)) 484 | 485 | @staticmethod 486 | def web_debug(client, data): 487 | debug('web_debug: ' + repr(data)) 488 | 489 | ### Channels: StandardChannel, StatusChannel, SpecialChannel 490 | 491 | class Channel: 492 | def __init__(self, name): 493 | self.name = name 494 | self.topic = '' 495 | self.mode = 'n' 496 | self.members = {} 497 | 498 | @property 499 | def prefix(self): 500 | return self.name 501 | 502 | def log(self, source, fmt, *args): 503 | info('%s %s '+fmt, self.name, source.nick, *args) 504 | 505 | def multicast_group(self, source): 506 | raise NotImplemented 507 | 508 | def n_members(self, client): 509 | return len(self.members) 510 | 511 | def event(self, source, command, fmt, *args, include_source=True): 512 | line = fmt.format(*args) if args else fmt 513 | for client in self.multicast_group(source): 514 | if client != source or include_source: 515 | client.write(':{} {} {}'.format(source.prefix, command, line)) 516 | 517 | def dehalfop_event(self, user): 518 | self.event(self, 'MODE', '{} -h {}', self.name, user.nick) 519 | 520 | def deop_event(self, user): 521 | self.event(self, 'MODE', '{} -o {}', self.name, user.nick) 522 | 523 | def devoice_event(self, user): 524 | self.event(self, 'MODE', '{} -v {}', self.name, user.nick) 525 | 526 | def halfop_event(self, user): 527 | self.event(self, 'MODE', '{} +h {}', self.name, user.nick) 528 | 529 | def nick_event(self, user, new): 530 | self.event(user, 'NICK', new) 531 | 532 | def join_event(self, user): 533 | self.event(user, 'JOIN', self.name) 534 | 535 | def kick_event(self, kicker, channel, kicked, reason=None): 536 | if reason: 537 | self.event(kicker, 'KICK', '{} {}: {}', channel.name, kicked.nick, reason) 538 | else: 539 | self.event(kicker, 'KICK', '{} {}', channel.name, kicked.nick) 540 | self.log(kicker, 'kicked %s', kicked.prefix) 541 | 542 | def op_event(self, user): 543 | self.event(self, 'MODE', '{} +o {}', self.name, user.nick) 544 | 545 | def part_event(self, user, partmsg): 546 | if partmsg: 547 | self.event(user, 'PART', '{} :{}', self.name, partmsg) 548 | else: 549 | self.event(user, 'PART', self.name) 550 | 551 | def voice_event(self, user): 552 | self.event(user, 'MODE', '{} +v {}', self.name, user.nick) 553 | 554 | def on_invite(self, client, nick): 555 | # TODO 556 | client.err_chanoprivsneeded(self.name) 557 | 558 | # subclasses should return True if succeeded to join 559 | def on_join(self, client): 560 | client.enter(self) 561 | self.join_event(client) 562 | self.on_topic(client) 563 | self.on_names(client) 564 | 565 | def on_kick(self, client, nick, reason): 566 | client.err_chanoprivsneeded(self.name) 567 | 568 | def on_mode(self, client): 569 | client.rpl_channelmodeis(self.name, self.mode) 570 | 571 | def on_names(self, client): 572 | members = [] 573 | for u, mode in self.members.items(): 574 | nick = u.nick 575 | if 'o' in mode: 576 | nick = '@'+nick 577 | elif 'v' in mode: 578 | nick = '+'+nick 579 | members.append(nick) 580 | if members: 581 | client.reply('353 {} = {} :{}', client.nick, self.name, 582 | ' '.join(sorted(members))) 583 | client.reply('366 {} {} :End of NAMES list', client.nick, self.name) 584 | 585 | def on_topic(self, client, new=None): 586 | if new: 587 | client.err_nochanmodes(self.name) 588 | else: 589 | if self.topic: 590 | client.reply('332 {} {} :{}', client.nick, self.name, self.topic) 591 | else: 592 | client.reply('331 {} {} :No topic is set', client.nick, self.name) 593 | 594 | 595 | class StandardChannel(Channel): 596 | def __init__(self, server, name): 597 | super().__init__(name) 598 | self.server = server 599 | 600 | def multicast_group(self, source): 601 | return self.members.keys() 602 | 603 | def on_notice_or_privmsg(self, client, command, msg): 604 | self.event(client, command, '{} :{}', self.name, msg, include_source=False) 605 | 606 | def on_join(self, client): 607 | if client in self.members: 608 | return False 609 | # first user becomes op 610 | self.members[client] = 'o' if not self.members else '' 611 | super().on_join(client) 612 | return True 613 | 614 | def on_kick(self, client, nick, reason): 615 | if 'o' not in self.members[client]: 616 | client.err_chanoprivsneeded(self.name) 617 | elif not client.server.has_nick(nick): 618 | client.err_usernotinchannel(nick, self.name) 619 | else: 620 | user = client.server.get_nick(nick) 621 | if user not in self.members: 622 | client.err_usernotinchannel(nick, self.name) 623 | elif client != user: 624 | self.kick_event(client, self, user, reason) 625 | self.on_part(user, None) 626 | 627 | def on_part(self, client, msg=None): 628 | if client not in self.members: 629 | client.err_notonchannel(self.name) 630 | return False 631 | if msg: # explicit PART, not disconnection 632 | self.part_event(client, msg) 633 | if len(self.members) == 1: 634 | self.server.remove_channel(self.name) 635 | elif 'o' in self.members.pop(client): 636 | user = next(iter(self.members)) 637 | self.members[user] += 'o' 638 | self.op_event(user) 639 | client.leave(self) 640 | return True 641 | 642 | def on_topic(self, client, new=None): 643 | if new: 644 | self.log(client, 'set topic %r', new) 645 | self.topic = new 646 | self.event(client, 'TOPIC', '{} :{}', self.name, new) 647 | else: 648 | super().on_topic(client, new) 649 | 650 | def on_who(self, client): 651 | for member in self.members: 652 | member.on_who_member(client, self.name) 653 | 654 | 655 | # A special channel where each client can only see himself 656 | class StatusChannel(Channel): 657 | instance = None 658 | 659 | def __init__(self, server): 660 | super().__init__('+qq') 661 | self.server = server 662 | self.topic = "Your friends are listed here. Messages wont't be broadcasted to them. Type 'help' to see available commands" 663 | self.shadow_members = weakref.WeakKeyDictionary() 664 | assert not StatusChannel.instance 665 | StatusChannel.instance = self 666 | 667 | def multicast_group(self, source): 668 | client = source.client \ 669 | if isinstance(source, (SpecialUser, SpecialChannel)) \ 670 | else source 671 | return (client,) if client in self.members else () 672 | 673 | def n_members(self, client): 674 | return len(self.shadow_members.get(client, {})) + \ 675 | (1 if client in self.members else 0) 676 | 677 | def respond(self, client, fmt, *args): 678 | if args: 679 | client.write((':{} PRIVMSG {} :'+fmt).format(self.name, self.name, *args)) 680 | else: 681 | client.write((':{} PRIVMSG {} :').format(self.name, self.name)+fmt) 682 | 683 | def on_notice_or_privmsg(self, client, command, msg): 684 | if client not in self.members: 685 | client.err_notonchannel(self.name) 686 | return 687 | if msg == 'help': 688 | self.respond(client, 'help') 689 | self.respond(client, ' display this help') 690 | self.respond(client, 'eval [password] expression') 691 | self.respond(client, ' eval python expression') 692 | self.respond(client, 'status [pattern]') 693 | self.respond(client, ' show status for user, channel and wechat rooms') 694 | self.respond(client, 'reload_friend $name') 695 | self.respond(client, ' reload friend info in case of no such nick/channel in privmsg, and use __all__ as name if you want to reload all') 696 | elif msg.startswith('status'): 697 | pattern = None 698 | ary = msg.split(' ', 1) 699 | if len(ary) > 1: 700 | pattern = ary[1] 701 | self.respond(client, 'IRC channels:') 702 | for name, room in client.channels.items(): 703 | if pattern is not None and pattern not in name: continue 704 | if isinstance(room, StandardChannel): 705 | self.respond(client, ' ' + name) 706 | self.respond(client, '{} Friends:', im_name) 707 | for name, user in client.nick2special_user.items(): 708 | if user.is_friend: 709 | if pattern is not None and not (pattern in name or pattern in user.record.get('DisplayName', '') or pattern in user.record.get('NickName','')): continue 710 | line = name + ': friend (' 711 | line += ', '.join([k + ':' + repr(v) for k, v in user.record.items() if k in ['DisplayName', 'NickName']]) 712 | line += ')' 713 | self.respond(client, ' ' + line) 714 | self.respond(client, '{} Rooms:', im_name) 715 | for name, room in client.channels.items(): 716 | if pattern is not None and pattern not in name: continue 717 | if isinstance(room, SpecialChannel): 718 | self.respond(client, ' ' + name) 719 | elif msg.startswith('reload_friend'): 720 | who = None 721 | ary = msg.split(' ', 1) 722 | if len(ary) > 1: 723 | who = ary[1] 724 | if not who: 725 | self.respond(client, 'reload_friend ') 726 | else: 727 | Web.instance.reload_friend(who) 728 | elif msg.startswith('web_eval'): 729 | expr = None 730 | ary = msg.split(' ', 1) 731 | if len(ary) > 1: 732 | expr = ary[1] 733 | if not expr: 734 | self.respond(client, 'None') 735 | else: 736 | Web.instance.web_eval(expr) 737 | self.respond(client, 'expr sent, please use debug log to view eval result') 738 | else: 739 | m = re.match(r'eval (\S+) (.+)$', msg.strip()) 740 | if m and m.group(1) == client.server.options.password: 741 | try: 742 | r = pprint.pformat(eval(m.group(2))) 743 | except: 744 | r = traceback.format_exc() 745 | for line in r.splitlines(): 746 | self.respond(client, line) 747 | else: 748 | self.respond(client, 'Unknown command {}', msg) 749 | 750 | def on_join(self, member): 751 | if isinstance(member, Client): 752 | if member in self.members: 753 | return False 754 | self.members[member] = '' 755 | super().on_join(member) 756 | else: 757 | client = member.client 758 | if client not in self.shadow_members: 759 | self.shadow_members[client] = {} 760 | if member in self.shadow_members[client]: 761 | return False 762 | member.enter(self) 763 | self.join_event(member) 764 | self.shadow_members[client][member] = '' 765 | return True 766 | 767 | def on_names(self, client): 768 | members = [] 769 | if client in self.members: 770 | members.append(client.nick) 771 | for u, mode in self.shadow_members.get(client, {}).items(): 772 | nick = u.nick 773 | if 'o' in mode: 774 | nick = '@'+nick 775 | elif 'v' in mode: 776 | nick = '+'+nick 777 | members.append(nick) 778 | client.reply('353 {} = {} :{}', client.nick, self.name, ' '.join(sorted(members))) 779 | client.reply('366 {} {} :End of NAMES list', client.nick, self.name) 780 | 781 | def on_part(self, member, msg=None): 782 | if isinstance(member, Client): 783 | if member not in self.members: 784 | member.err_notonchannel(self.name) 785 | return False 786 | if msg: # explicit PART, not disconnection 787 | self.part_event(member, msg) 788 | del self.members[member] 789 | else: 790 | if member not in self.shadow_members.get(member.client, ()): 791 | return False 792 | self.part_event(member, msg) 793 | self.shadow_members[member.client].remove(member) 794 | member.leave(self) 795 | return True 796 | 797 | def on_who(self, client): 798 | if client in self.members: 799 | client.on_who_member(client, self.name) 800 | 801 | 802 | class SpecialChannel(Channel): 803 | def __init__(self, client, record): 804 | super().__init__(None) 805 | self.client = client 806 | self.gid = record['gid'] 807 | self.record = {} 808 | self.idle = True # no messages yet 809 | self.joined = False # `client` has not joined 810 | self.update(client, record) 811 | self.log_file = None 812 | 813 | @property 814 | def nick(self): 815 | return self.name 816 | 817 | def update(self, client, record): 818 | self.record.update(record) 819 | self.topic = self.record.get('memo', '').replace('\n', '\\n') 820 | old_name = getattr(self, 'name', None) 821 | base = '&' + irc_escape(self.record.get('name')) 822 | if base == '&': 823 | base += '.'.join(member.nick for member in self.members)[:20] 824 | suffix = '' 825 | while 1: 826 | name = base+suffix 827 | if name == old_name or not client.server.has_channel(base+suffix): 828 | break 829 | suffix = str(int(suffix or 0)+1) 830 | if name != old_name: 831 | # PART -> rename -> JOIN to notify the IRC client 832 | joined = self.joined 833 | if joined: 834 | self.on_part(client, 'Changing name') 835 | self.name = name 836 | if joined: 837 | self.on_join(client) 838 | 839 | def update_detail(self, record): 840 | if isinstance(record.get('members'), list): 841 | owner_uin = record.get('owner', 0) 842 | seen = {self.client: ''} 843 | for member in record['members']: 844 | user = self.client.ensure_special_user(member) 845 | if user is not self.client: 846 | if owner_uin == user.uin: 847 | seen[user] = 'o' 848 | elif user.is_friend: 849 | seen[user] = 'v' 850 | else: 851 | seen[user] = '' 852 | for user in self.members.keys() - seen.keys(): 853 | self.on_part(user, self.name) 854 | for user in seen.keys() - self.members.keys(): 855 | if user is not self.client: 856 | self.on_join(user) 857 | for user, mode in seen.items(): 858 | old = self.members.get(user, '') 859 | if 'h' in old and 'h' not in mode: 860 | self.dehalfop_event(user) 861 | if 'h' not in old and 'h' in mode: 862 | self.halfop_event(user) 863 | if 'o' in old and 'o' not in mode: 864 | self.deop_event(user) 865 | if 'o' not in old and 'o' in mode: 866 | self.op_event(user) 867 | if 'v' in old and 'v' not in mode: 868 | self.devoice_event(user) 869 | if 'v' not in old and 'v' in mode: 870 | self.voice_event(user) 871 | self.members = seen 872 | 873 | def multicast_group(self, source): 874 | if not self.joined: 875 | return () 876 | if isinstance(source, (SpecialUser, SpecialChannel)): 877 | return (source.client,) 878 | return (source,) 879 | 880 | def on_notice_or_privmsg(self, client, command, msg): 881 | Web.instance.send_text_message(self.gid, msg) 882 | 883 | def on_invite(self, client, nick): 884 | # Not supported 885 | client.err_chanoprivsneeded(self.name) 886 | 887 | def on_join(self, member): 888 | if isinstance(member, Client): 889 | if self.joined: 890 | return False 891 | self.joined = True 892 | super().on_join(member) 893 | else: 894 | if member in self.members: 895 | return False 896 | self.members[member] = '' 897 | member.enter(self) 898 | self.join_event(member) 899 | return True 900 | 901 | def on_kick(self, client, nick, reason): 902 | # Not supported 903 | client.err_chanoprivsneeded(self.name) 904 | 905 | def on_part(self, member, msg=None): 906 | if isinstance(member, Client): 907 | if not self.joined: 908 | member.err_notonchannel(self.name) 909 | return False 910 | if msg: # not msg implies being disconnected/kicked/... 911 | self.part_event(member, msg) 912 | self.joined = False 913 | else: 914 | if member not in self.members: 915 | return False 916 | self.part_event(member, msg) 917 | del self.members[member] 918 | member.leave(self) 919 | return True 920 | 921 | def on_topic(self, client, new=None): 922 | if new: 923 | client.err_nochanmodes() 924 | else: 925 | super().on_topic(client, new) 926 | 927 | def on_who(self, client): 928 | members = tuple(self.members)+(client,) 929 | for member in members: 930 | member.on_who_member(client, self.name) 931 | 932 | def on_websocket_message(self, data): 933 | msg = data['message'] 934 | if self.idle: 935 | self.idle = False 936 | if self.client.options.join == 'auto' and not self.joined: 937 | self.client.auto_join(self) 938 | if not self.joined: 939 | return 940 | sender = self.client.ensure_special_user(data['sender']) 941 | if not sender: 942 | return 943 | if sender not in self.members: 944 | self.on_join(sender) 945 | for line in msg.splitlines(): 946 | self.client.server.irc_log(self, datetime.fromtimestamp(data['time']/1000), sender, line) 947 | if 'server-time' in self.client.capabilities: 948 | self.client.write('@time={}Z :{} PRIVMSG {} :{}'.format( 949 | datetime.fromtimestamp(data['time']/1000, timezone.utc).strftime('%FT%T.%f')[:23], 950 | sender.prefix, self.name, line)) 951 | else: 952 | self.client.write(':{} PRIVMSG {} :{}'.format( 953 | sender.prefix, self.name, line)) 954 | 955 | 956 | class Client: 957 | def __init__(self, server, reader, writer, options): 958 | self.server = server 959 | self.options = Namespace() 960 | for k in ['heartbeat', 'ignore', 'ignore_display_name', 'join', 'dcc_send']: 961 | setattr(self.options, k, getattr(options, k)) 962 | self.reader = reader 963 | self.writer = writer 964 | peer = writer.get_extra_info('socket').getpeername() 965 | self.host = peer[0] 966 | self.user = None 967 | self.nick = None 968 | self.registered = False 969 | self.mode = '' 970 | self.channels = {} # joined, name -> channel 971 | self.name2special_room = {} # name -> QQ chatroom 972 | self.gid2special_room = {} # gid(group)/did(discuss) -> SpecialChannel 973 | self.nick2special_user = {} # nick -> IRC user or QQ user (friend or room contact) 974 | self.uin2special_user = {} # uin -> SpecialUser 975 | self.uin = 0 976 | self.capabilities = set() 977 | self.authenticated = False 978 | 979 | def enter(self, channel): 980 | self.channels[irc_lower(channel.name)] = channel 981 | 982 | def leave(self, channel): 983 | del self.channels[irc_lower(channel.name)] 984 | 985 | def auto_join(self, room): 986 | for regex in self.options.ignore or []: 987 | if re.search(regex, room.name): 988 | return 989 | for regex in self.options.ignore_display_name or []: 990 | if re.search(regex, room.topic): 991 | return 992 | room.on_join(self) 993 | 994 | def has_special_user(self, nick): 995 | return irc_lower(nick) in self.nick2special_user 996 | 997 | def has_special_room(self, name): 998 | return irc_lower(name) in self.name2special_room 999 | 1000 | def get_special_user(self, nick): 1001 | return self.nick2special_user[irc_lower(nick)] 1002 | 1003 | def get_special_room(self, name): 1004 | return self.name2special_room[irc_lower(name)] 1005 | 1006 | def remove_special_user(self, nick): 1007 | del self.nick2special_user[irc_lower(nick)] 1008 | 1009 | def ensure_special_user(self, record, friend=0): 1010 | assert isinstance(record['uin'], int) 1011 | assert isinstance(record['nick'], str) 1012 | if record['uin'] == self.uin: 1013 | return self 1014 | if record['uin'] in self.uin2special_user: 1015 | user = self.uin2special_user[record['uin']] 1016 | self.remove_special_user(user.nick) 1017 | user.update(self, record, friend) 1018 | else: 1019 | user = SpecialUser(self, record, friend) 1020 | self.uin2special_user[user.uin] = user 1021 | self.nick2special_user[irc_lower(user.nick)] = user 1022 | return user 1023 | 1024 | def is_in_channel(self, name): 1025 | return irc_lower(name) in self.channels 1026 | 1027 | def get_channel(self, channelname): 1028 | return self.channels[irc_lower(channelname)] 1029 | 1030 | def remove_channel(self, channelname): 1031 | del self.channels[irc_lower(channelname)] 1032 | 1033 | def ensure_special_room(self, record): 1034 | assert isinstance(record['gid'], int) 1035 | assert isinstance(record['name'], str) 1036 | assert isinstance(record.get('memo', ''), str) 1037 | assert isinstance(record.get('owner', -1), int) 1038 | if record['gid'] in self.gid2special_room: 1039 | room = self.gid2special_room[record['gid']] 1040 | del self.name2special_room[irc_lower(room.name)] 1041 | room.update(self, record) 1042 | else: 1043 | room = SpecialChannel(self, record) 1044 | self.gid2special_room[room.gid] = room 1045 | if self.options.join == 'all': 1046 | self.auto_join(room) 1047 | self.name2special_room[irc_lower(room.name)] = room 1048 | return room 1049 | 1050 | def disconnect(self, quitmsg): 1051 | self.write('ERROR :{}'.format(quitmsg)) 1052 | info('Disconnected from %s', self.prefix) 1053 | self.message_related(False, ':{} QUIT :{}', self.prefix, quitmsg) 1054 | self.writer.write_eof() 1055 | self.writer.close() 1056 | channels = list(self.channels.values()) 1057 | for channel in channels: 1058 | channel.on_part(self, None) 1059 | 1060 | def reply(self, msg, *args): 1061 | '''Respond to the client's request''' 1062 | self.write((':{} '+msg).format(self.server.name, *args)) 1063 | 1064 | def write(self, msg): 1065 | try: 1066 | self.writer.write(msg.encode()+b'\n') 1067 | except: 1068 | pass 1069 | 1070 | def status(self, msg): 1071 | '''A status message from the server''' 1072 | self.write(':{} NOTICE {} :{}'.format(self.server.name, self.server.name, msg)) 1073 | 1074 | @property 1075 | def prefix(self): 1076 | return '{}!{}@{}'.format(self.nick or '', self.user or '', self.host or '') 1077 | 1078 | def rpl_umodeis(self, mode): 1079 | self.reply('221 {} +{}', self.nick, mode) 1080 | 1081 | def rpl_channelmodeis(self, channelname, mode): 1082 | self.reply('324 {} {} +{}', self.nick, channelname, mode) 1083 | 1084 | def rpl_endofnames(self, channelname): 1085 | self.reply('366 {} {} :End of NAMES list', self.nick, channelname) 1086 | 1087 | def rpl_info(self, fmt, *args): 1088 | line = fmt.format(*args) if args else fmt 1089 | self.reply('371 {} :{}', self.nick, line) 1090 | 1091 | def rpl_endofinfo(self, msg): 1092 | self.reply('374 {} :End of INFO list', self.nick) 1093 | 1094 | def err_nosuchnick(self, name): 1095 | self.reply('401 {} {} :No such nick/channel', self.nick, name) 1096 | 1097 | def err_nosuchserver(self, name): 1098 | self.reply('402 {} {} :No such server', self.nick, name) 1099 | 1100 | def err_nosuchchannel(self, channelname): 1101 | self.reply('403 {} {} :No such channel', self.nick, channelname) 1102 | 1103 | def err_noorigin(self): 1104 | self.reply('409 {} :No origin specified', self.nick) 1105 | 1106 | def err_norecipient(self, command): 1107 | self.reply('411 {} :No recipient given ({})', self.nick, command) 1108 | 1109 | def err_notexttosend(self): 1110 | self.reply('412 {} :No text to send', self.nick) 1111 | 1112 | def err_unknowncommand(self, command): 1113 | self.reply('421 {} {} :Unknown command', self.nick, command) 1114 | 1115 | def err_nonicknamegiven(self): 1116 | self.reply('431 {} :No nickname given', self.nick) 1117 | 1118 | def err_errorneusnickname(self, nick): 1119 | self.reply('432 * {} :Erroneous nickname', nick) 1120 | 1121 | def err_nicknameinuse(self, nick): 1122 | self.reply('433 * {} :Nickname is already in use', nick) 1123 | 1124 | def err_usernotinchannel(self, nick, channelname): 1125 | self.reply("441 {} {} {} :They are't on that channel", self.nick, nick, channelname) 1126 | 1127 | def err_notonchannel(self, channelname): 1128 | self.reply("442 {} {} :You're not on that channel", self.nick, channelname) 1129 | 1130 | def err_useronchannel(self, nick, channelname): 1131 | self.reply('443 {} {} {} :is already on channel', self.nick, nick, channelname) 1132 | 1133 | def err_nologin(self, nick): 1134 | self.reply('444 {} {} :User not logged in', self.nick, nick) 1135 | 1136 | def err_needmoreparams(self, command): 1137 | self.reply('461 {} {} :Not enough parameters', self.nick, command) 1138 | 1139 | def err_passwdmismatch(self, command): 1140 | self.reply('464 * {} :Password incorrect', command) 1141 | 1142 | def err_nochanmodes(self, channelname): 1143 | self.reply("477 {} {} :Channel doesn't support modes", self.nick, channelname) 1144 | 1145 | def err_chanoprivsneeded(self, channelname): 1146 | self.reply("482 {} {} :You're not channel operator", self.nick, channelname) 1147 | 1148 | def err_umodeunknownflag(self): 1149 | self.reply('501 {} :Unknown MODE flag', self.nick) 1150 | 1151 | def message_related(self, include_self, fmt, *args): 1152 | '''Send a message to related clients which source is self''' 1153 | clients = set() 1154 | for channel in self.channels.values(): 1155 | if isinstance(channel, StandardChannel): 1156 | clients |= channel.members.keys() 1157 | if include_self: 1158 | clients.add(self) 1159 | else: 1160 | clients.discard(self) 1161 | line = fmt.format(*args) if args else fmt 1162 | for client in clients: 1163 | client.write(line) 1164 | 1165 | def handle_command(self, command, args): 1166 | cls = RegisteredCommands if self.registered else UnregisteredCommands 1167 | ret = False 1168 | cmd = irc_lower(command) 1169 | if cmd == 'pass': 1170 | cmd = cmd+'_' 1171 | if type(cls.__dict__.get(cmd)) != staticmethod: 1172 | self.err_unknowncommand(command) 1173 | else: 1174 | fn = getattr(cls, cmd) 1175 | try: 1176 | ba = inspect.signature(fn).bind(self, *args) 1177 | except TypeError: 1178 | self.err_needmoreparams(command) 1179 | else: 1180 | fn(*ba.args) 1181 | if not self.registered and self.user and self.nick: 1182 | info('%s registered', self.prefix) 1183 | self.reply('001 {} :Hi, welcome to IRC', self.nick) 1184 | self.reply('002 {} :Your host is {}', self.nick, self.server.name) 1185 | RegisteredCommands.lusers(self) 1186 | self.registered = True 1187 | 1188 | status_channel = StatusChannel.instance 1189 | RegisteredCommands.join(self, status_channel.name) 1190 | status_channel.respond(self, 'Visit w.qq.com and then you will see your friend list in this channel') 1191 | Web.instance.close_connections() 1192 | 1193 | async def handle_irc(self): 1194 | sent_ping = False 1195 | while 1: 1196 | try: 1197 | line = await asyncio.wait_for( 1198 | self.reader.readline(), loop=self.server.loop, 1199 | timeout=self.options.heartbeat) 1200 | except asyncio.TimeoutError: 1201 | if sent_ping: 1202 | self.disconnect('ping timeout') 1203 | return 1204 | else: 1205 | sent_ping = True 1206 | self.write('PING :'+self.server.name) 1207 | continue 1208 | if not line: 1209 | return 1210 | line = line.rstrip(b'\r\n').decode('utf-8', 'ignore') 1211 | sent_ping = False 1212 | if not line: 1213 | continue 1214 | x = line.split(' ', 1) 1215 | command = x[0] 1216 | if len(x) == 1: 1217 | args = [] 1218 | elif len(x[1]) > 0 and x[1][0] == ':': 1219 | args = [x[1][1:]] 1220 | else: 1221 | y = x[1].split(' :', 1) 1222 | args = y[0].split(' ') 1223 | if len(y) == 2: 1224 | args.append(y[1]) 1225 | self.handle_command(command, args) 1226 | 1227 | def on_who_member(self, client, channelname): 1228 | client.reply('352 {} {} {} {} {} {} H :0 {}', client.nick, channelname, 1229 | self.user, self.host, client.server.name, 1230 | self.nick, self.realname) 1231 | 1232 | def on_whois(self, client): 1233 | client.reply('311 {} {} {} {} * :{}', client.nick, self.nick, 1234 | self.user, self.host, self.realname) 1235 | client.reply('319 {} {} :{}', client.nick, self.nick, 1236 | ' '.join(name for name in 1237 | client.channels.keys() & self.channels.keys())) 1238 | 1239 | def on_websocket(self, data): 1240 | command = data['command'] 1241 | if type(SpecialCommands.__dict__.get(command)) == staticmethod: 1242 | getattr(SpecialCommands, command)(self, data) 1243 | 1244 | def on_websocket_open(self, peername): 1245 | status = StatusChannel.instance 1246 | #self.status('WebSocket client connected from {}'.format(peername)) 1247 | 1248 | def on_websocket_close(self, peername): 1249 | # PART all special channels, these chatrooms will be garbage collected 1250 | for room in self.name2special_room.values(): 1251 | if room.joined: 1252 | room.on_part(self, 'WebSocket client disconnection') 1253 | self.name2special_room.clear() 1254 | self.gid2special_room.clear() 1255 | 1256 | # instead of flooding +qq with massive PART messages, 1257 | # take the shortcut by rejoining the client 1258 | self.nick2special_user.clear() 1259 | self.uin2special_user.clear() 1260 | status = StatusChannel.instance 1261 | status.shadow_members.get(self, set()).clear() 1262 | if self in status.members: 1263 | status.on_part(self, 'WebSocket client disconnected from {}'.format(peername)) 1264 | status.on_join(self) 1265 | 1266 | def on_websocket_message(self, data): 1267 | msg = data['message'] 1268 | sender = self.ensure_special_user(data['sender']) 1269 | for line in msg.splitlines(): 1270 | self.server.irc_log(sender, datetime.fromtimestamp(data['time']/1000), sender, line) 1271 | if 'server-time' in self.capabilities: 1272 | self.write('@time={}Z :{} PRIVMSG {} :{}'.format( 1273 | datetime.fromtimestamp(data['time']/1000, timezone.utc).strftime('%FT%T.%f')[:23], 1274 | sender.prefix, self.nick, line)) 1275 | else: 1276 | self.write(':{} PRIVMSG {} :{}'.format( 1277 | sender.prefix, self.nick, line)) 1278 | 1279 | 1280 | class SpecialUser: 1281 | def __init__(self, client, record, friend): 1282 | self.client = client 1283 | self.channels = set() 1284 | self.is_friend = False 1285 | self.record = {} 1286 | self.uin = 0 1287 | self.update(client, record, friend) 1288 | self.log_file = None 1289 | 1290 | @property 1291 | def prefix(self): 1292 | return '{}!{}@{}'.format(self.nick, self.uin, im_name) 1293 | 1294 | def name(self): 1295 | base = re.sub('^[&#!+]*', '', irc_escape(self.record.get('nick', ''))) 1296 | return base or 'Guest' 1297 | 1298 | def update(self, client, record, friend): 1299 | self.record.update(record) 1300 | self.uin = record['uin'] 1301 | old_nick = getattr(self, 'nick', None) 1302 | base = self.name() 1303 | suffix = '' 1304 | while 1: 1305 | nick = base+suffix 1306 | if nick and (nick == old_nick or 1307 | irc_lower(nick) != irc_lower(client.nick) and 1308 | not client.has_special_user(nick)): 1309 | break 1310 | suffix = str(int(suffix or 0)+1) 1311 | if nick != old_nick: 1312 | for channel in self.channels: 1313 | channel.nick_event(self, nick) 1314 | self.nick = nick 1315 | # friend 1316 | if friend > 0: 1317 | if not self.is_friend: 1318 | self.is_friend = True 1319 | StatusChannel.instance.on_join(self) 1320 | for channel in self.channels: 1321 | if isinstance(channel, SpecialChannel): 1322 | channel.members[self] = 'v' 1323 | channel.voice_event(self) 1324 | # non_friend 1325 | elif friend < 0: 1326 | if self.is_friend: 1327 | self.is_friend = False 1328 | StatusChannel.instance.on_part(self) 1329 | for channel in self.channels: 1330 | if isinstance(channel, SpecialChannel): 1331 | channel.members[self] = '' 1332 | channel.devoice_event(self) 1333 | # unsure 1334 | 1335 | def enter(self, channel): 1336 | self.channels.add(channel) 1337 | 1338 | def leave(self, channel): 1339 | self.channels.remove(channel) 1340 | 1341 | def on_notice_or_privmsg(self, client, command, msg): 1342 | Web.instance.send_text_message(self.uin, msg) 1343 | 1344 | def on_who_member(self, client, channelname): 1345 | client.reply('352 {} {} {} {} {} {} H :0 {}', client.nick, channelname, 1346 | self.uin, im_name, client.server.name, 1347 | self.nick, self.record['nick']) 1348 | 1349 | def on_whois(self, client): 1350 | client.reply('311 {} {} {} {} * :{}', client.nick, self.nick, 1351 | self.uin, im_name, self.record['nick']) 1352 | 1353 | def on_websocket_message(self, data): 1354 | msg = data['message'] 1355 | for line in msg.splitlines(): 1356 | self.client.server.irc_log(self, datetime.fromtimestamp(data['time']/1000), self.client, line) 1357 | if 'server-time' in self.client.capabilities: 1358 | self.client.write('@time={}Z :{} PRIVMSG {} :{}'.format( 1359 | datetime.fromtimestamp(data['time']/1000, timezone.utc).strftime('%FT%T.%f')[:23], 1360 | self.client.prefix, self.nick, line)) 1361 | else: 1362 | self.client.write(':{} PRIVMSG {} :{}'.format( 1363 | self.client.prefix, self.nick, line)) 1364 | 1365 | 1366 | class Server: 1367 | valid_nickname = re.compile(r"^[][\`_^{|}A-Za-z][][\`_^{|}A-Za-z0-9-]{0,50}$") 1368 | # initial character `+` is reserved for special channels 1369 | # initial character `&` is reserved for special chatrooms 1370 | valid_channelname = re.compile(r"^[#!][^\x00\x07\x0a\x0d ,:]{0,50}$") 1371 | instance = None 1372 | 1373 | def __init__(self, options): 1374 | self.options = options 1375 | status = StatusChannel(self) 1376 | self.channels = {status.name: status} 1377 | self.name = 'webqqircd.maskray.me' 1378 | self.nicks = {} 1379 | self.clients = weakref.WeakSet() 1380 | 1381 | self._boot = datetime.now() 1382 | 1383 | assert not Server.instance 1384 | Server.instance = self 1385 | 1386 | def _accept(self, reader, writer): 1387 | def done(task): 1388 | if client.nick: 1389 | self.remove_nick(client.nick) 1390 | 1391 | try: 1392 | client = Client(self, reader, writer, self.options) 1393 | self.clients.add(client) 1394 | task = self.loop.create_task(client.handle_irc()) 1395 | task.add_done_callback(done) 1396 | except Exception as e: 1397 | traceback.print_exc() 1398 | 1399 | def has_channel(self, channelname): 1400 | return irc_lower(channelname) in self.channels 1401 | 1402 | def get_channel(self, channelname): 1403 | return self.channels[irc_lower(channelname)] 1404 | 1405 | # IRC channel or special chatroom 1406 | def ensure_channel(self, channelname): 1407 | if self.has_channel(channelname): 1408 | return self.channels[irc_lower(channelname)] 1409 | if not Server.valid_channelname.match(channelname): 1410 | raise ValueError 1411 | channel = StandardChannel(self, channelname) 1412 | self.channels[irc_lower(channelname)] = channel 1413 | return channel 1414 | 1415 | def remove_channel(self, channelname): 1416 | del self.channels[irc_lower(channelname)] 1417 | 1418 | def change_nick(self, client, new): 1419 | lower = irc_lower(new) 1420 | if lower in self.nicks or lower in client.nick2special_user: 1421 | client.err_nicknameinuse(new) 1422 | elif not Server.valid_nickname.match(new): 1423 | client.err_errorneusnickname(new) 1424 | else: 1425 | if client.nick: 1426 | info('%s changed nick to %s', client.prefix, new) 1427 | self.remove_nick(client.nick) 1428 | client.message_related(True, '{} NICK {}', client.prefix, new) 1429 | self.nicks[lower] = client 1430 | client.nick = new 1431 | 1432 | def has_nick(self, nick): 1433 | return irc_lower(nick) in self.nicks 1434 | 1435 | def get_nick(self, nick): 1436 | return self.nicks[irc_lower(nick)] 1437 | 1438 | def remove_nick(self, nick): 1439 | del self.nicks[irc_lower(nick)] 1440 | 1441 | def start(self, loop, tls): 1442 | self.loop = loop 1443 | self.servers = [] 1444 | for i in self.options.irc_listen if self.options.irc_listen else self.options.listen: 1445 | self.servers.append(loop.run_until_complete( 1446 | asyncio.streams.start_server(self._accept, i, self.options.irc_port, ssl=tls))) 1447 | 1448 | def stop(self): 1449 | for i in self.servers: 1450 | i.close() 1451 | self.loop.run_until_complete(i.wait_closed()) 1452 | 1453 | ## WebSocket 1454 | def on_websocket(self, data): 1455 | for client in self.clients: 1456 | client.on_websocket(data) 1457 | 1458 | def irc_log(self, channel, local_time, sender, line): 1459 | if self.options.logger_mask is None: 1460 | return 1461 | for regex in self.options.logger_ignore or []: 1462 | if re.search(regex, channel.name): 1463 | return 1464 | filename = local_time.strftime(self.options.logger_mask.replace('$channel', channel.nick)) 1465 | time_str = local_time.strftime(self.options.logger_time_format.replace('$channel', channel.nick)) 1466 | if channel.log_file is None or channel.log_file.name != filename: 1467 | if channel.log_file is not None: 1468 | channel.log_file.close() 1469 | os.makedirs(os.path.dirname(filename), exist_ok=True) 1470 | channel.log_file = open(filename, 'a') 1471 | channel.log_file.write('{}\t{}\t{}\n'.format(time_str, sender.nick, line)) 1472 | channel.log_file.flush() 1473 | 1474 | 1475 | def main(): 1476 | ap = ArgumentParser(description='webqqircd brings wx.qq.com to IRC clients') 1477 | ap.add_argument('-d', '--debug', action='store_true', help='run ipdb on uncaught exception') 1478 | ap.add_argument('--dcc-send', type=int, default=10*1024*1024, help='size limit receiving from DCC SEND. 0: disable DCC SEND') 1479 | ap.add_argument('--heartbeat', type=int, default=30, help='time to wait for IRC commands. The server will send PING and close the connection after another timeout of equal duration if no commands is received.') 1480 | ap.add_argument('--http-cert', help='TLS certificate for HTTPS/WebSocket over TLS. You may concatenate certificate+key, specify a single PEM file and omit `--http-key`. Use HTTP if neither --http-cert nor --http-key is specified') 1481 | ap.add_argument('--http-key', help='TLS key for HTTPS/WebSocket over TLS') 1482 | ap.add_argument('--http-listen', nargs='*', 1483 | help='HTTP/WebSocket listen addresses (overriding --listen)') 1484 | ap.add_argument('--http-port', type=int, default=9002, help='HTTP/WebSocket listen port, default: 9002') 1485 | ap.add_argument('--http-root', default=os.path.dirname(__file__), help='HTTP root directory (serving injector.js)') 1486 | ap.add_argument('-i', '--ignore', nargs='*', 1487 | help='list of ignored regex, do not auto join to a '+im_name+' chatroom whose channel name(generated from DisplayName) matches') 1488 | ap.add_argument('-I', '--ignore-display-name', nargs='*', 1489 | help='list of ignored regex, do not auto join to a '+im_name+' chatroom whose DisplayName matches') 1490 | ap.add_argument('--irc-cert', help='TLS certificate for IRC over TLS. You may concatenate certificate+key, specify a single PEM file and omit `--irc-key`. Use plain IRC if neither --irc-cert nor --irc-key is specified') 1491 | ap.add_argument('--irc-key', help='TLS key for IRC over TLS') 1492 | ap.add_argument('--irc-listen', nargs='*', 1493 | help='IRC listen addresses (overriding --listen)') 1494 | ap.add_argument('--irc-password', default='', help='Set the IRC connection password') 1495 | ap.add_argument('--irc-port', type=int, default=6668, 1496 | help='IRC server listen port. defalt: 6668') 1497 | ap.add_argument('-j', '--join', choices=['all', 'auto', 'manual'], default='auto', 1498 | help='join mode for '+im_name+' chatrooms. all: join all after connected; auto: join after the first message arrives; manual: no automatic join. default: auto') 1499 | ap.add_argument('-l', '--listen', nargs='*', default=['127.0.0.1'], 1500 | help='IRC/HTTP/WebSocket listen addresses, default: 127.0.0.1') 1501 | ap.add_argument('--logger-ignore', nargs='*', help='list of ignored regex, do not log contacts/chatrooms whose names match') 1502 | ap.add_argument('--logger-mask', help='WeeChat logger.mask.irc') 1503 | ap.add_argument('--logger-time-format', default='%H:%M', help='WeeChat logger.file.time_format') 1504 | ap.add_argument('--password', help='admin password') 1505 | ap.add_argument('-q', '--quiet', action='store_const', const=logging.WARN, dest='loglevel') 1506 | ap.add_argument('-v', '--verbose', action='store_const', const=logging.DEBUG, dest='loglevel') 1507 | options = ap.parse_args() 1508 | 1509 | if sys.platform == 'linux': 1510 | # send to syslog if run as a daemon (no controlling terminal) 1511 | try: 1512 | with open('/dev/tty'): 1513 | pass 1514 | logging.basicConfig(format='%(levelname)s: %(message)s') 1515 | except OSError: 1516 | logging.root.addHandler(logging.handlers.SysLogHandler('/dev/log')) 1517 | else: 1518 | logging.basicConfig(format='%(levelname)s: %(message)s') 1519 | logging.root.setLevel(options.loglevel or logging.INFO) 1520 | 1521 | if options.http_cert or options.http_key: 1522 | http_tls = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) 1523 | http_tls.load_cert_chain(options.http_cert or options.http_key, 1524 | options.http_key or options.http_cert) 1525 | else: 1526 | http_tls = None 1527 | if options.irc_cert or options.irc_key: 1528 | irc_tls = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) 1529 | irc_tls.load_cert_chain(options.irc_cert or options.irc_key, 1530 | options.irc_key or options.irc_cert) 1531 | else: 1532 | irc_tls = None 1533 | 1534 | loop = asyncio.get_event_loop() 1535 | if options.debug: 1536 | sys.excepthook = ExceptionHook() 1537 | server = Server(options) 1538 | web = Web(options.http_root) 1539 | 1540 | server.start(loop, irc_tls) 1541 | web.start(options.http_listen if options.http_listen else options.listen, 1542 | options.http_port, http_tls, loop) 1543 | try: 1544 | loop.run_forever() 1545 | except KeyboardInterrupt: 1546 | server.stop() 1547 | web.stop() 1548 | loop.stop() 1549 | 1550 | 1551 | if __name__ == '__main__': 1552 | sys.exit(main()) 1553 | -------------------------------------------------------------------------------- /webqqircd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=IRC server capable of controlling Web QQ 3 | Documentation=https://github.com/MaskRay/webqqircd 4 | After=network.target 5 | 6 | [Service] 7 | ExecStart=/usr/bin/webqqircd --http-cert /etc/webqqircd/cert.pem --http-key /etc/webqqircd/key.pem --http-root /usr/share/webqqircd 8 | #server side log, N.B. use '%%' in place of '%' to specify a single percent sign, see man 5 systemd.unit 9 | #ExecStart=/usr/bin/webqqircd --http-cert /etc/webqqircd/cert.pem --http-key /etc/webqqircd/key.pem --http-root /usr/share/webqqircd --logger-mask '/tmp/webqqircd/$channel/%%Y-%%m-%%d.log' 10 | #IRC over TLS + connection password 11 | #ExecStart=/usr/bin/webqqircd --http-cert /etc/webqqircd/cert.pem --http-key /etc/webqqircd/key.pem --http-root /usr/share/webqqircd --irc-cert /etc/webqqircd/irc-cert.pem --irc-key /etc/webqqircd/irc-key.pem --irc-password yourpassword 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | --------------------------------------------------------------------------------