├── .gitignore ├── LICENSE ├── README.md ├── pickups ├── __init__.py ├── __main__.py ├── irc.py ├── server.py └── util.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Michael Tom-Wing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pickups 2 | 3 | IRC gateway for Google Hangouts using 4 | [hangups](https://github.com/tdryer/hangups). 5 | 6 | This is currently just a prototype. 7 | 8 | 9 | ## Usage 10 | 11 | `$ python -m pickups` 12 | 13 | You will be prompted for your Google credentials and only the resulting cookies 14 | from authentication will be saved. Then connect your IRC client to `localhost` 15 | on port `6667`. 16 | -------------------------------------------------------------------------------- /pickups/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtomwing/pickups/b25967eab1ec6316995b1420b2e9d9b862094bf8/pickups/__init__.py -------------------------------------------------------------------------------- /pickups/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import sys 5 | 6 | import appdirs 7 | import hangups.auth 8 | 9 | from .server import Server 10 | 11 | if __name__ == '__main__': 12 | logging.basicConfig(level=logging.INFO, stream=sys.stdout) 13 | logging.getLogger('hangups').setLevel(logging.WARNING) 14 | dirs = appdirs.AppDirs('hangups', 'hangups') 15 | default_cookies_path = os.path.join(dirs.user_cache_dir, 'cookies.json') 16 | cookies = hangups.auth.get_auth_stdin(default_cookies_path) 17 | 18 | parser = argparse.ArgumentParser(description='IRC Gateway for Hangouts') 19 | parser.add_argument('--address', help='bind address', default='127.0.0.1') 20 | parser.add_argument('--port', help='bind port', default=6667) 21 | parser.add_argument('--ascii-smileys', action='store_true', 22 | help='display smileys in ascii') 23 | args = parser.parse_args() 24 | 25 | Server(cookies, args.ascii_smileys).run(args.address, args.port) 26 | -------------------------------------------------------------------------------- /pickups/irc.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | RPL_WELCOME = 1 4 | RPL_WHOISUSER = 311 5 | RPL_ENDOFWHO = 315 6 | RPL_LISTSTART = 321 7 | RPL_LIST = 322 8 | RPL_LISTEND = 323 9 | RPL_TOPIC = 332 10 | RPL_WHOREPLY = 352 11 | RPL_NAMREPLY = 353 12 | RPL_ENDOFNAMES = 366 13 | RPL_MOTD = 372 14 | RPL_MOTDSTART = 375 15 | RPL_ENDOFMOTD = 376 16 | 17 | ERR_NOSUCHCHANNEL = 403 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class Client(object): 24 | 25 | def __init__(self, reader, writer): 26 | self.reader = reader 27 | self.writer = writer 28 | 29 | self.nickname = None 30 | self.sent_messages = [] 31 | 32 | def readline(self): 33 | return self.reader.readline() 34 | 35 | def write(self, sender, command, *args): 36 | """Sends a message to the client on behalf of another client.""" 37 | if not isinstance(command, str): 38 | command = '{:03}'.format(command) 39 | params = ' '.join('{}'.format(arg) for arg in args) 40 | line = ':{} {} {}\r\n'.format(sender, command, params) 41 | logger.info('Sent: %r', line) 42 | self.writer.write(line.encode('utf-8')) 43 | 44 | def swrite(self, command, *args): 45 | """Sends a message from the server to the client.""" 46 | self.write('pickups', command, self.nickname, *args) 47 | 48 | def uwrite(self, command, *args): 49 | """Sends a message on behalf of the client.""" 50 | self.write(self.nickname, command, *args) 51 | 52 | # IRC Stuff 53 | 54 | def welcome(self): 55 | """Tells the client a welcome message.""" 56 | self.swrite(RPL_WELCOME, self.nickname, ':Welcome to pickups!') 57 | 58 | def list_channels(self, info): 59 | """Tells the client what channels are available.""" 60 | self.swrite(RPL_LISTSTART) 61 | for channel, num_users, topic in info: 62 | self.swrite(RPL_LIST, channel, num_users, ':{}'.format(topic)) 63 | self.swrite(RPL_LISTEND, ':End of /LIST') 64 | 65 | def join(self, channel): 66 | """Tells the client to join a channel.""" 67 | self.write(self.nickname, 'JOIN', ':{}'.format(channel)) 68 | 69 | def list_nicks(self, channel, nicks): 70 | """Tells the client what nicks are in channel.""" 71 | self.swrite(RPL_NAMREPLY, '=', channel, ':{}'.format(' '.join(nicks))) 72 | self.swrite(RPL_ENDOFNAMES, channel, ':End of NAMES list') 73 | 74 | def who(self, query, responses): 75 | """Tells the client a list of information matching a query.""" 76 | for response in responses: 77 | self.swrite( 78 | RPL_WHOREPLY, response['channel'], 79 | '~{}'.format(response['user']), 'localhost', 'pickups', 80 | response['nick'], 'H', ':0', response['real_name'] 81 | ) 82 | self.swrite(RPL_ENDOFWHO, query, ':End of WHO list') 83 | 84 | def topic(self, channel, topic): 85 | """Tells the client the topic of the channel.""" 86 | self.swrite(RPL_TOPIC, channel, ':{}'.format(topic)) 87 | 88 | def privmsg(self, hostmask, target, message): 89 | """Sends the client a message from someone.""" 90 | for line in message.splitlines(): 91 | if line: 92 | self.write(hostmask, 'PRIVMSG', target, ':{}'.format(line)) 93 | 94 | def tell_nick(self, nickname): 95 | """Tells the client its actual nick.""" 96 | self.uwrite('NICK', nickname) 97 | self.nickname = nickname 98 | 99 | def pong(self): 100 | """Replies to server pings.""" 101 | self.swrite('PONG', 'localhost') 102 | -------------------------------------------------------------------------------- /pickups/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import hangups 5 | import hangups.auth 6 | 7 | from . import irc, util 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Server: 13 | 14 | def __init__(self, cookies=None, ascii_smileys=False): 15 | self.clients = {} 16 | self._hangups = hangups.Client(cookies) 17 | self._hangups.on_connect.add_observer(self._on_hangups_connect) 18 | self.ascii_smileys = ascii_smileys 19 | 20 | def run(self, host, port): 21 | loop = asyncio.get_event_loop() 22 | loop.run_until_complete( 23 | asyncio.start_server(self._on_client_connect, host=host, port=port) 24 | ) 25 | logger.info('Waiting for hangups to connect...') 26 | loop.run_until_complete(self._hangups.connect()) 27 | 28 | # Hangups Callbacks 29 | 30 | @asyncio.coroutine 31 | def _on_hangups_connect(self): 32 | """Called when hangups successfully auths with hangouts.""" 33 | self._user_list, self._conv_list = ( 34 | yield from hangups.build_user_conversation_list(self._hangups) 35 | ) 36 | self._conv_list.on_event.add_observer(self._on_hangups_event) 37 | logger.info('Hangups connected. Connect your IRC clients!') 38 | 39 | def _on_hangups_event(self, conv_event): 40 | """Called when a hangups conversation event occurs.""" 41 | if isinstance(conv_event, hangups.ChatMessageEvent): 42 | conv = self._conv_list.get(conv_event.conversation_id) 43 | user = conv.get_user(conv_event.user_id) 44 | sender = util.get_nick(user) 45 | hostmask = util.get_hostmask(user) 46 | channel = util.conversation_to_channel(conv) 47 | message = conv_event.text 48 | for client in self.clients.values(): 49 | if message in client.sent_messages and sender == client.nickname: 50 | client.sent_messages.remove(message) 51 | else: 52 | if self.ascii_smileys: 53 | message = util.smileys_to_ascii(message) 54 | client.privmsg(hostmask, channel, message) 55 | 56 | # Client Callbacks 57 | 58 | def _on_client_connect(self, client_reader, client_writer): 59 | """Called when an IRC client connects.""" 60 | client = irc.Client(client_reader, client_writer) 61 | task = asyncio.Task(self._handle_client(client)) 62 | self.clients[task] = client 63 | logger.info("New Connection") 64 | task.add_done_callback(self._on_client_lost) 65 | 66 | def _on_client_lost(self, task): 67 | """Called when an IRC client disconnects.""" 68 | self.clients[task].writer.close() 69 | del self.clients[task] 70 | logger.info("End Connection") 71 | 72 | @asyncio.coroutine 73 | def _handle_client(self, client): 74 | username = None 75 | welcomed = False 76 | 77 | while True: 78 | line = yield from client.readline() 79 | line = line.decode('utf-8').strip('\r\n') 80 | 81 | if not line: 82 | logger.info("Connection lost") 83 | break 84 | logger.info('Received: %r', line) 85 | 86 | if line.startswith('NICK'): 87 | client.nickname = line.split(' ', 1)[1] 88 | elif line.startswith('USER'): 89 | username = line.split(' ', 1)[1] 90 | elif line.startswith('LIST'): 91 | info = ( 92 | (util.conversation_to_channel(conv), len(conv.users), 93 | util.get_topic(conv)) 94 | for conv in sorted(self._conv_list.get_all(), 95 | key=lambda x: len(x.users)) 96 | ) 97 | client.list_channels(info) 98 | elif line.startswith('PRIVMSG'): 99 | channel, message = line.split(' ', 2)[1:] 100 | conv = util.channel_to_conversation(channel, self._conv_list) 101 | client.sent_messages.append(message[1:]) 102 | segments = hangups.ChatMessageSegment.from_str(message[1:]) 103 | asyncio.async(conv.send_message(segments)) 104 | elif line.startswith('JOIN'): 105 | channel = line.split(' ')[1] 106 | conv = util.channel_to_conversation(channel, self._conv_list) 107 | if not conv: 108 | client.swrite(irc.ERR_NOSUCHCHANNEL, 109 | ':{}: Channel not found'.format(channel)) 110 | else: 111 | # If a JOIN is successful, the user receives a JOIN message 112 | # as confirmation and is then sent the channel's topic 113 | # (using RPL_TOPIC) and the list of users who are on the 114 | # channel (using RPL_NAMREPLY), which MUST include the user 115 | # joining. 116 | client.write(util.get_nick(self._user_list._self_user), 117 | 'JOIN', channel) 118 | client.topic(channel, util.get_topic(conv)) 119 | client.list_nicks(channel, (util.get_nick(user) 120 | for user in conv.users)) 121 | elif line.startswith('WHO'): 122 | query = line.split(' ')[1] 123 | if query.startswith('#'): 124 | conv = util.channel_to_conversation(channel, 125 | self._conv_list) 126 | if not conv: 127 | client.swrite(irc.ERR_NOSUCHCHANNEL, 128 | ':{}: Channel not found'.format(channel)) 129 | else: 130 | responses = [{ 131 | 'channel': query, 132 | 'user': util.get_nick(user), 133 | 'nick': util.get_nick(user), 134 | 'real_name': user.full_name, 135 | } for user in conv.users] 136 | client.who(query, responses) 137 | elif line.startswith('PING'): 138 | client.pong() 139 | 140 | if not welcomed and client.nickname and username: 141 | welcomed = True 142 | client.swrite(irc.RPL_WELCOME, ':Welcome to pickups!') 143 | client.tell_nick(util.get_nick(self._user_list._self_user)) 144 | 145 | # Sending the MOTD seems be required for Pidgin to connect. 146 | client.swrite(irc.RPL_MOTDSTART, 147 | ':- pickups Message of the Day - ') 148 | client.swrite(irc.RPL_MOTD, ':- insert MOTD here') 149 | client.swrite(irc.RPL_ENDOFMOTD, ':End of MOTD command') 150 | -------------------------------------------------------------------------------- /pickups/util.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | 3 | from hangups.ui.utils import get_conv_name 4 | import hashlib 5 | import re 6 | import unicodedata 7 | 8 | CONV_HASH_LEN = 7 9 | 10 | 11 | def strip_non_printable(s): 12 | return ''.join(c for c in s 13 | if unicodedata.category(c) not in ['Cc', 'Zs', 'So']) 14 | 15 | 16 | def conversation_to_channel(conv): 17 | """Return channel name for hangups.Conversation.""" 18 | # Must be 50 characters max and not contain space or comma. 19 | conv_hash = hashlib.sha1(conv.id_.encode()).hexdigest() 20 | name = get_conv_name(conv).replace(',', '_').replace(' ', '') 21 | name = strip_non_printable(name) 22 | return '#{}[{}]'.format(name[:50 - CONV_HASH_LEN - 3], 23 | conv_hash[:CONV_HASH_LEN]) 24 | 25 | 26 | def channel_to_conversation(channel, conv_list): 27 | """Return hangups.Conversation for channel name.""" 28 | match = re.search(r'\[([a-f0-9]+)\]$', channel) 29 | if match is None: 30 | return None 31 | conv_hash = match.group(1) 32 | return {hashlib.sha1(conv.id_.encode()).hexdigest()[:CONV_HASH_LEN]: conv 33 | for conv in conv_list.get_all()}.get(conv_hash, None) 34 | 35 | 36 | def get_nick(user): 37 | """Return nickname for a hangups.User.""" 38 | # Remove disallowed characters and limit to max length 15 39 | return re.sub(r'[^\w\[\]\{\}\^`|_\\-]', '', user.full_name)[:15] 40 | 41 | 42 | def get_hostmask(user): 43 | """Return hostmask for a hangups.User.""" 44 | return '{}!{}@hangouts'.format(get_nick(user), user.id_.chat_id) 45 | 46 | 47 | def get_topic(conv): 48 | """Return IRC topic for a conversation.""" 49 | return 'Hangouts conversation: {}'.format(get_conv_name(conv)) 50 | 51 | 52 | SMILEYS = {chr(k): v for k, v in { 53 | 0x263a: ':)', 54 | 0x1f494: ':(', 80 | 0x1f622: ';_;', 81 | 0x1f623: '>_<', 82 | 0x1f626: 'D:', 83 | 0x1f62E: ':o', 84 | 0x1f632: ':O', 85 | 0x1f635: 'x_x', 86 | 0x1f638: ':3', 87 | }.items()} 88 | 89 | def smileys_to_ascii(s): 90 | res = [] 91 | for i, c in enumerate(s): 92 | if c in SMILEYS: 93 | res.append(SMILEYS[c]) 94 | if i < len(s) - 1 and s[i + 1] in SMILEYS: # separate smileys 95 | res.append(' ') 96 | else: 97 | res.append(c) 98 | return ''.join(res) 99 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | hangups<0.4 2 | --------------------------------------------------------------------------------