# Copyright (C) 2010, Aleksey Lim # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, # this list of conditions, and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions, and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the author of this software nor the name of # contributors to this software may be used to endorse or promote products # derived from this software without specific prior written consent. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import time import json import urllib import htmllib from supybot.commands import * import supybot.irclib as irclib import supybot.ircmsgs as ircmsgs import supybot.callbacks as callbacks import supybot.utils as utils import supybot.world as world import supybot.ircdb as ircdb import supybot.conf as conf import config _TRANSLATE_URL = 'http://ajax.googleapis.com/ajax/services/language/translate' _QUOTES = "\"" _LANGUAGE_CODES = [i[0] for i in config.LANGUAGES] class LingvaGroup(dict): channel = None lang = None topic = None class Lingvo(callbacks.Plugin): def __init__(self, irc): callbacks.Plugin.__init__(self, irc) self._irc = irc self.lastMsgs = {} self.lastStates = {} self._auto_parts = {} self._groups_value = None @property def _groups(self): if self._groups_value is None: self._groups_value = {} for lang, name, __ in config.CHANNELS + \ [('en', str(i), None) for i in \ self.registryValue('channels')]: if not lang: continue group = LingvaGroup() group.lang = lang group.channel = name for i in self._irc.state.channels: group_channel, lang = _normalize_channel(i) if group_channel == group.channel and lang: group[i] = lang self.log.info('[lingvo] Add lingva channel %s', name) self._groups_value[name] = group return self._groups_value def __call__(self, irc, msg): try: callbacks.Plugin.__call__(self, irc, msg) if irc in self.lastMsgs: if irc not in self.lastStates: self.lastStates[irc] = irc.state.copy() self.lastStates[irc].addMsg(irc, self.lastMsgs[irc]) finally: # We must make sure this always gets updated. self.lastMsgs[irc] = msg def cmd_join(self, irc, msg, args, optlist, channel, lang): """[--silent] [] [] Starts relaying between specified (or current one if is omitted) and newly joined, satellite, channel of specified language. If language is omitted, it will be parsed from channel name. """ if lang: lang = lang.lower() else: __, lang = _normalize_channel(channel) if not lang or lang not in _LANGUAGE_CODES: all_langs = [] for code, name, __ in config.LANGUAGES: all_langs.append('%s(%s)' % (code, name)) irc.error('Wrong "%s" language, use one of %s' % \ (lang, ', '.join(all_langs))) return silent = False for (option, arg) in optlist: if option == 'silent': silent = True group_channel, __ = _normalize_channel(channel) if group_channel not in self._groups: irc.error('Channel "%s" cannot be translated, ' \ 'request it using %s contact, ' \ 'permitted channels are %s' % \ (group_channel, self.registryValue('contact'), ', '.join(self._groups.keys()))) return group = self._groups[group_channel] if lang == group.lang: irc.error('Channel %s has the same default language' % \ group_channel) return if channel in group: lingva_channel = channel else: lingva_channel = '%s-%s' % (group_channel, lang) if lingva_channel in self.registryValue('blacklist'): irc.error('Channel "%s" is a regular channel and cannot be used ' \ 'for translation. If it is not true any more, please ' \ 'email to %s.' % \ (lingva_channel, self.registryValue('contact'))) return if lingva_channel not in irc.state.channels: network = conf.supybot.networks.get(irc.network) network.channels().add(lingva_channel) irc.sendMsg(network.channels.join(lingva_channel)) if not silent: irc.reply('Follow me on %s' % lingva_channel) join = wrap(cmd_join, [getopts({'silent': ''}), 'channel', optional('text')]) def cmd_part(self, irc, msg, args, optlist, channel, lang): """[--silent] [] [|all] Close current translation channel or translation channel(s) for specified language. """ if lang: lang = lang.lower() silent = False for (option, arg) in optlist: if option == 'silent': silent = True for group in self._groups.values(): if channel not in group and channel != group.channel: continue left = [] for i in group.keys(): if lang == 'all' or lang == group[i] or \ not lang and channel == i: irc.queueMsg(ircmsgs.part(i, 'Lingvo part')) del group[i] left.append(i) if not left: break if not silent: irc.reply('Has left %s channel(s)' % ', '.join(left)) return if not silent: irc.error('Cannot find translation channels') part = wrap(cmd_part, [getopts({'silent': ''}), 'channel', optional('text'), 'admin']) def cmd_list(self, irc, msg, args, channel): """[] List active translation languages for specified primal channel(s). """ if channel: if not channel.startswith('#'): channel = '#' + channel channel, __ = _normalize_channel(channel) for group in sorted(self._groups.values(), cmp=lambda x, y: cmp(x.channel, y.channel)): if channel and channel != group.channel: continue if group: irc.reply('%s is being translated into %s' % \ (group.channel, ', '.join(group.values()))) else: irc.reply(group.channel) list = wrap(cmd_list, [optional('text')]) def cmd_langs(self, irc, msg, args): """ List of supported languages. """ langs = ['%s %s' % (code, name) for code, name, __ in config.LANGUAGES] irc.reply(', '.join(langs)) langs = wrap(cmd_langs, []) def cmd_pin(self, irc, msg, args, channel): """[] Prevent auto closing empty translation channels. """ group_channel, lang = _normalize_channel(channel) if not lang: irc.reply('Not translation channel connot be pinned') return self.setRegistryValue('pinned', True, channel) irc.replySuccess() pin = wrap(cmd_pin, ['channel']) def cmd_unpin(self, irc, msg, args, channel): """[] Allow auto closing empty translation channels. """ group_channel, lang = _normalize_channel(channel) if not lang: irc.reply('Not translation channel connot be pinned') return self.setRegistryValue('pinned', False, channel) irc.replySuccess() unpin = wrap(cmd_unpin, ['channel']) def cmd_lingvanicks(self, irc, msg, args, channel): """[] List all nicks that are on translation channels. """ if channel: if not channel.startswith('#'): channel = '#' + channel channel, __ = _normalize_channel(channel) for group in sorted(self._groups.values(), cmp=lambda x, y: cmp(x.channel, y.channel)): if channel and channel != group.channel or not group: continue for lingva_channel in group.keys(): nicks = irc.state.channels[lingva_channel].users irc.reply('%s: %s' % (lingva_channel, ', '.join(nicks))) lingvanicks = wrap(cmd_lingvanicks, [optional('channel')]) def cmd_nicks(self, irc, msg, args, channel): """[] List all nicks that take part in conversation, i.e., nicks from the primal channel and nicks from all translation channels. """ group_channel, lang = _normalize_channel(channel) if group_channel not in self._groups: irc.reply('Channel %s is not translated' % group_channel) return nicks = irc.state.channels[group_channel].users for channel, lang in self._groups[group_channel].items(): nicks |= irc.state.channels[channel].users irc.reply(', '.join(sorted(nicks))) nicks = wrap(cmd_nicks, ['channel']) def doPrivmsg(self, irc, msg): if ircmsgs.isCtcp(msg) and not ircmsgs.isAction(msg) or \ msg.nick == irc.nick: return group = self._relay(irc, msg) or {} for channel in group.keys(): if self.registryValue('pinned', group.channel) or \ self.registryValue('pinned', channel): continue if len(irc.state.channels[channel].users) <= 1: time_last = self._auto_parts.get(channel) time_current = int(time.time()) if time_last is None: self._auto_parts[channel] = time_current elif time_current - time_last >= self.registryValue('timeout'): self.log.info('[lingvo] Timeout part from %s', channel) irc.queueMsg(ircmsgs.part(channel, 'Lingvo part')) elif channel in self._auto_parts: del self._auto_parts[channel] def doJoin(self, irc, msg): if msg.nick != irc.nick: self._relay(irc, msg, 'has joined', skip_joined_users=True) else: channel = msg.args[0] group_channel, lang = _normalize_channel(channel) group = self._groups.get(group_channel) if group is not None and lang: group[channel] = lang for code, name, topic in config.LANGUAGES: if code == lang: topic_native = topic dst_lang_name = name if code == 'en': topic_en = topic if code == group.lang: src_lang_name = name topic_args = {'src_channel': group.channel, 'dst_channel': channel, 'src_lang': src_lang_name, 'dst_lang': dst_lang_name, 'docs': self.registryValue('documentation_url'), } if topic_native: group.topic = topic_native % topic_args else: group.topic = self._translate( topic_en % topic_args, group.lang, lang) if not group.topic: group.topic = topic_en % topic_args irc.queueMsg(ircmsgs.topic(channel, group.topic)) text = 'Translation has started for' msg_format = '* %%s %s' % group_channel self._relay_to(irc, channel, msg_format, text, 'en', lang) def doMode(self, irc, msg): if msg.nick != irc.nick: return channel = msg.args[0] group = self._groups.get(channel) if group is not None: irc.queueMsg(ircmsgs.topic(channel, group.topic)) def doPart(self, irc, msg): if msg.nick != irc.nick: if len(msg.args) == 1: text = 'has left' else: text = 'has left (%s)' % msg.args[1] self._relay(irc, msg, text, skip_joined_users=True) else: channel = msg.args[0] group_channel, lang = _normalize_channel(channel) group = self._groups.get(group_channel) if group is not None and channel in group: network = conf.supybot.networks.get(irc.network) if channel in network.channels(): network.channels().remove(channel) del group[channel] def doKick(self, irc, msg): if len(msg.args) == 3: text = 'kicked %s (%s)' % (msg.args[1], msg.args[2]) else: text = 'kicked %s' % msg.args[1] self._relay(irc, msg, text) def doTopic(self, irc, msg): text = 'changed topic to %s' % msg.args[1] self._relay(irc, msg, text) def outFilter(self, irc, msg): if msg.command == 'PRIVMSG' and not msg.relayed and \ msg.nick != irc.nick: self._relay(irc, msg) return msg def doNick(self, irc, msg): new_nick = msg.args[0] msg_format = '* %s %%s %s' % (msg.nick, new_nick) text = 'nick changed to' self._relay_farewell(irc, irc.state.channels, new_nick, msg_format, text) def doQuit(self, irc, msg): if msg.args: text = 'has quit (%s)' % msg.args[0] else: text = 'has quit' msg_format = '* %s %%s' % msg.nick if not isinstance(irc, irclib.Irc): irc = irc.getRealIrc() if irc not in self.lastStates: return self._relay_farewell(irc, self.lastStates[irc].channels, msg.nick, msg_format, text) def _translate(self, text, src_lang, dst_lang, nicks=None): if src_lang == dst_lang: return text quoted_text, replaces = quote(text, nicks) if not unquote(quoted_text, replaces, '').strip(): return text url_opts = { 'v': '1.0', 'q': quoted_text, 'langpair': '%s|%s' % (src_lang, dst_lang), } url = '%s?%s' % (_TRANSLATE_URL, urllib.urlencode(url_opts)) fd = utils.web.getUrlFd(url) reply = json.load(fd) fd.close() if reply['responseStatus'] != 200: self.log.warn('Bad Google resonce for "%s"' % url) else: quoted_text = _unescape(reply['responseData']['translatedText']) return unquote(quoted_text, replaces) def _relay_to(self, irc, channel, msg_format, text, src_lang, dst_lang, nicks=None): text = self._translate(text, src_lang, dst_lang, nicks) if not text: return msg = ircmsgs.privmsg(channel, msg_format % text) assert msg.command in ('PRIVMSG', 'NOTICE', 'TOPIC') msg.tag('relayed') irc.queueMsg(msg) def _relay_farewell(self, irc, channels, nick, msg_format, text): for group in self._groups.values(): if not [i for i in group.keys() + [group.channel] \ if i in channels and nick in channels[i].users]: continue for channel, lang in group.items() + [(group.channel, group.lang)]: irc_channel = channels.get(channel) if irc_channel is None or len(irc_channel.users) == 1 or \ nick in irc_channel.users: continue self._relay_to(irc, channel, msg_format, text, 'en', lang) def _relay(self, irc, msg, text=None, skip_joined_users=False): nick = msg.nick or irc.nick if nick == irc.nick: return src_channel = msg.args[0] if not src_channel: return group_channel, src_lang = _normalize_channel(src_channel) group = self._groups.get(group_channel) if group is None: return if not src_lang: src_lang = group.lang if text is None: if ircmsgs.isAction(msg): text = ircmsgs.unAction(msg) else: text = msg.args[1] if text.startswith(irc.nick + ':'): return if msg.command == 'PRIVMSG': msg_format = '<%s-%s> %%s' % (nick, src_lang) else: msg_format = '* %s-%s %%s' % (nick, src_lang) src_lang = 'en' group_nicks = irc.state.channels[group_channel].users for dst_channel, dst_lang in \ group.items() + [(group.channel, group.lang)]: irc_channel = irc.state.channels[dst_channel] if irc_channel is None or dst_channel == src_channel or \ len(irc_channel.users) == 1 or \ (skip_joined_users and nick in irc_channel.users): continue nicks = group_nicks | irc.state.channels[src_channel].users | \ irc_channel.users self._relay_to(irc, dst_channel, msg_format, text, src_lang, dst_lang, nicks) return group def quote(text, ext_replaces=None): replaces = {} new_text = '' next_key_value = [1] def next_key(): key = '%03d' % next_key_value[0] next_key_value[0] += 1 return key while text: start_quote = None for i in _QUOTES + '#': if i in text: start_quote = i break if start_quote is None: new_text += text break start = text.index(start_quote) key = next_key() new_text += text[:start] + key if start_quote == '#': end = start + 1 while end < len(text) and not text[end].isspace(): end += 1 end -= 1 else: end = text.find(start_quote, start + 1) if end == -1: end = len(text) replaces[key] = text[start:end + 1] text = text[end + 1:] for value in sorted(ext_replaces or [], cmp=lambda x, y: cmp(len(y), len(x))): text = new_text new_text = '' new_text_pos = 0 for match in re.finditer( r'(^|(?<=[\s:,]))(%s)(-([a-zA-Z-]+))*($|(?=[\s:,]))' % value, text): match_value = match.groups()[1] match_lang_postfix = match.groups()[2] match_lang = match.groups()[3] if match_lang and match_lang not in _LANGUAGE_CODES: match_value += match_lang_postfix key = next_key() replaces[key] = match_value new_text += text[new_text_pos:match.start()] + key new_text_pos = match.end() new_text += text[new_text_pos:] return (new_text, replaces) def unquote(text, replaces, stub=None): for key, value in replaces.items(): text = text.replace(key, value if stub is None else stub) return text def _unescape(text): parser = htmllib.HTMLParser(None) parser.save_bgn() parser.feed(text.encode('utf-8')) return parser.save_end() def _normalize_channel(name): match = re.match('(.*)-([-A-Za-z]+)$', name) if match: normalized_name = match.groups()[0] lang = match.groups()[1] if lang in _LANGUAGE_CODES: return (normalized_name, lang) return (name, None) Class = Lingvo