1###
2# Copyright (c) 2002-2004, Jeremiah Fincher
3# Copyright (c) 2010,2015 James McCoy
4# All rights reserved.
5#
6# Redistribution and use in source and binary forms, with or without
7# modification, are permitted provided that the following conditions are met:
8#
9#   * Redistributions of source code must retain the above copyright notice,
10#     this list of conditions, and the following disclaimer.
11#   * Redistributions in binary form must reproduce the above copyright notice,
12#     this list of conditions, and the following disclaimer in the
13#     documentation and/or other materials provided with the distribution.
14#   * Neither the name of the author of this software nor the name of
15#     contributors to this software may be used to endorse or promote products
16#     derived from this software without specific prior written consent.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
22# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28# POSSIBILITY OF SUCH DAMAGE.
29###
30
31import time
32
33import supybot.conf as conf
34import supybot.utils as utils
35import supybot.world as world
36from supybot.commands import *
37import supybot.irclib as irclib
38import supybot.ircmsgs as ircmsgs
39import supybot.ircutils as ircutils
40import supybot.callbacks as callbacks
41from supybot.utils.structures import MultiSet, TimeoutQueue
42from supybot.i18n import PluginInternationalization, internationalizeDocstring
43_ = PluginInternationalization('Relay')
44
45class Relay(callbacks.Plugin):
46    """This plugin allows you to setup a relay between networks."""
47    noIgnore = True
48    def __init__(self, irc):
49        self.__parent = super(Relay, self)
50        self.__parent.__init__(irc)
51        self._whois = {}
52        self.queuedTopics = MultiSet()
53        self.lastRelayMsgs = ircutils.IrcDict()
54
55    def do376(self, irc, msg):
56        networkGroup = conf.supybot.networks.get(irc.network)
57        for channel in self.registryValue('channels'):
58            if self.registryValue('channels.joinOnAllNetworks', channel):
59                if channel not in irc.state.channels:
60                    irc.queueMsg(networkGroup.channels.join(channel))
61    do377 = do422 = do376
62
63    def _getRealIrc(self, irc):
64        if isinstance(irc, irclib.Irc):
65            return irc
66        else:
67            return irc.getRealIrc()
68
69    def _getIrcName(self, irc):
70        # We should allow abbreviations at some point.
71        return irc.network
72
73    @internationalizeDocstring
74    def join(self, irc, msg, args, channel):
75        """[<channel>]
76
77        Starts relaying between the channel <channel> on all networks.  If on a
78        network the bot isn't in <channel>, it'll join.  This commands is
79        required even if the bot is in the channel on both networks; it won't
80        relay between those channels unless it's told to join both
81        channels.  If <channel> is not given, starts relaying on the channel
82        the message was sent in.
83        """
84        self.registryValue('channels').add(channel)
85        for otherIrc in world.ircs:
86            if channel not in otherIrc.state.channels:
87                networkGroup = conf.supybot.networks.get(otherIrc.network)
88                otherIrc.queueMsg(networkGroup.channels.join(channel))
89        irc.replySuccess()
90    join = wrap(join, ['channel', 'admin'])
91
92    @internationalizeDocstring
93    def part(self, irc, msg, args, channel):
94        """<channel>
95
96        Ceases relaying between the channel <channel> on all networks.  The bot
97        will part from the channel on all networks in which it is on the
98        channel.
99        """
100        self.registryValue('channels').discard(channel)
101        for otherIrc in world.ircs:
102            if channel in otherIrc.state.channels:
103                otherIrc.queueMsg(ircmsgs.part(channel))
104        irc.replySuccess()
105    part = wrap(part, ['channel', 'admin'])
106
107    @internationalizeDocstring
108    def nicks(self, irc, msg, args, channel):
109        """[<channel>]
110
111        Returns the nicks of the people in the channel on the various networks
112        the bot is connected to.  <channel> is only necessary if the message
113        isn't sent on the channel itself.
114        """
115        realIrc = self._getRealIrc(irc)
116        if channel not in self.registryValue('channels'):
117            irc.error(format('I\'m not relaying in %s.', channel))
118            return
119        users = []
120        for otherIrc in world.ircs:
121            network = self._getIrcName(otherIrc)
122            ops = []
123            halfops = []
124            voices = []
125            usersS = []
126            if network != self._getIrcName(realIrc):
127                try:
128                    Channel = otherIrc.state.channels[channel]
129                except KeyError:
130                    users.append(format('(not in %s on %s)',channel,network))
131                    continue
132                numUsers = 0
133                for s in Channel.users:
134                    s = s.strip()
135                    if not s:
136                        continue
137                    numUsers += 1
138                    if s in Channel.ops:
139                        ops.append('@' + s)
140                    elif s in Channel.halfops:
141                        halfops.append('%' + s)
142                    elif s in Channel.voices:
143                        voices.append('+' + s)
144                    else:
145                        usersS.append(s)
146                utils.sortBy(ircutils.toLower, ops)
147                utils.sortBy(ircutils.toLower, voices)
148                utils.sortBy(ircutils.toLower, halfops)
149                utils.sortBy(ircutils.toLower, usersS)
150                usersS = ', '.join(filter(None, list(map(', '.join,
151                                  (ops,halfops,voices,usersS)))))
152                users.append(format('%s (%i): %s',
153                                    ircutils.bold(network), numUsers, usersS))
154        users.sort()
155        irc.reply('; '.join(users))
156    nicks = wrap(nicks, ['channel'])
157
158    def do311(self, irc, msg):
159        irc = self._getRealIrc(irc)
160        nick = ircutils.toLower(msg.args[1])
161        if (irc, nick) not in self._whois:
162            return
163        else:
164            self._whois[(irc, nick)][-1][msg.command] = msg
165
166    # These are all sent by a WHOIS response.
167    do301 = do311
168    do312 = do311
169    do317 = do311
170    do319 = do311
171    do320 = do311
172
173    def do318(self, irc, msg):
174        irc = self._getRealIrc(irc)
175        nick = msg.args[1]
176        loweredNick = ircutils.toLower(nick)
177        if (irc, loweredNick) not in self._whois:
178            return
179        (replyIrc, replyMsg, d) = self._whois[(irc, loweredNick)]
180        d['318'] = msg
181        s = ircutils.formatWhois(irc, d, caller=replyMsg.nick,
182                                 channel=replyMsg.args[0])
183        replyIrc.reply(s)
184        del self._whois[(irc, loweredNick)]
185
186    def do402(self, irc, msg):
187        irc = self._getRealIrc(irc)
188        nick = msg.args[1]
189        loweredNick = ircutils.toLower(nick)
190        if (irc, loweredNick) not in self._whois:
191            return
192        (replyIrc, replyMsg, d) = self._whois[(irc, loweredNick)]
193        del self._whois[(irc, loweredNick)]
194        s = format(_('There is no %s on %s.'), nick, self._getIrcName(irc))
195        replyIrc.reply(s)
196
197    do401 = do402
198
199    def _formatPrivmsg(self, nick, network, msg):
200        channel = msg.args[0]
201        if self.registryValue('includeNetwork', channel):
202            network = '@' + network
203        else:
204            network = ''
205        # colorize nicks
206        color = self.registryValue('color', channel) # Also used further down.
207        if color:
208            nick = ircutils.IrcString(nick)
209            newnick = ircutils.mircColor(nick, *ircutils.canonicalColor(nick))
210            colors = ircutils.canonicalColor(nick, shift=4)
211            nick = newnick
212        if ircmsgs.isAction(msg):
213            if color:
214                t = ircutils.mircColor('*', *colors)
215            else:
216                t = '*'
217            s = format('%s %s%s %s', t, nick, network, ircmsgs.unAction(msg))
218        else:
219            if color:
220                lt = ircutils.mircColor('<', *colors)
221                gt = ircutils.mircColor('>', *colors)
222            else:
223                lt = '<'
224                gt = '>'
225            s = format('%s%s%s%s %s', lt, nick, network, gt, msg.args[1])
226        return s
227
228    def _sendToOthers(self, irc, msg):
229        assert msg.command in ('PRIVMSG', 'NOTICE', 'TOPIC')
230        for otherIrc in world.ircs:
231            if otherIrc != irc and not otherIrc.zombie:
232                if msg.args[0] in otherIrc.state.channels:
233                    msg.tag('relayedMsg')
234                    otherIrc.queueMsg(msg)
235
236    def _checkRelayMsg(self, msg):
237        channel = msg.args[0]
238        if channel in self.lastRelayMsgs:
239            q = self.lastRelayMsgs[channel]
240            unformatted = ircutils.stripFormatting(msg.args[1])
241            normalized = utils.str.normalizeWhitespace(unformatted)
242            for s in q:
243                if s in normalized:
244                    return True
245        return False
246
247    def _punishRelayers(self, msg):
248        assert self._checkRelayMsg(msg), 'Punishing without checking.'
249        who = msg.prefix
250        channel = msg.args[0]
251        def notPunishing(irc, s, *args):
252            self.log.info('Not punishing %s in %s on %s: %s.',
253                          msg.prefix, channel, irc.network, s, *args)
254        for irc in world.ircs:
255            if channel in irc.state.channels:
256                if irc.nick in irc.state.channels[channel].ops:
257                    if who in irc.state.channels[channel].bans:
258                        notPunishing(irc, 'already banned')
259                    else:
260                        self.log.info('Punishing %s in %s on %s for relaying.',
261                                      who, channel, irc.network)
262                        irc.sendMsg(ircmsgs.ban(channel, who))
263                        kmsg = _('You seem to be relaying, punk.')
264                        irc.sendMsg(ircmsgs.kick(channel, msg.nick, kmsg))
265                else:
266                    notPunishing(irc, 'not opped')
267
268    def doPrivmsg(self, irc, msg):
269        if ircmsgs.isCtcp(msg) and not ircmsgs.isAction(msg):
270            return
271        (channel, text) = msg.args
272        if irc.isChannel(channel):
273            irc = self._getRealIrc(irc)
274            if channel not in self.registryValue('channels'):
275                return
276            ignores = self.registryValue('ignores', channel)
277            for ignore in ignores:
278                if ircutils.hostmaskPatternEqual(ignore, msg.prefix):
279                    self.log.debug('Refusing to relay %s, ignored by %s.',
280                                   msg.prefix, ignore)
281                    return
282            # Let's try to detect other relay bots.
283            if self._checkRelayMsg(msg):
284                if self.registryValue('punishOtherRelayBots', channel):
285                    self._punishRelayers(msg)
286                # Either way, we don't relay the message.
287                else:
288                    self.log.warning('Refusing to relay message from %s, '
289                                     'it appears to be a relay message.',
290                                     msg.prefix)
291            else:
292                network = self._getIrcName(irc)
293                s = self._formatPrivmsg(msg.nick, network, msg)
294                m = self._msgmaker(channel, s)
295                self._sendToOthers(irc, m)
296
297    def _msgmaker(self, target, s):
298        msg = dynamic.msg
299        channel = dynamic.channel
300        if self.registryValue('noticeNonPrivmsgs', dynamic.channel) and \
301           msg.command != 'PRIVMSG':
302            return ircmsgs.notice(target, s)
303        else:
304            return ircmsgs.privmsg(target, s)
305
306    def doJoin(self, irc, msg):
307        irc = self._getRealIrc(irc)
308        channel = msg.args[0]
309        if channel not in self.registryValue('channels'):
310            return
311        network = self._getIrcName(irc)
312        if self.registryValue('hostmasks', channel):
313            hostmask = format(' (%s)', msg.prefix.split('!')[1])
314        else:
315            hostmask = ''
316        s = format(_('%s%s has joined on %s'), msg.nick, hostmask, network)
317        m = self._msgmaker(channel, s)
318        self._sendToOthers(irc, m)
319
320    def doPart(self, irc, msg):
321        irc = self._getRealIrc(irc)
322        channel = msg.args[0]
323        if channel not in self.registryValue('channels'):
324            return
325        network = self._getIrcName(irc)
326        if self.registryValue('hostmasks', channel):
327            hostmask = format(' (%s)', msg.prefix.split('!')[1])
328        else:
329            hostmask = ''
330        if len(msg.args) > 1:
331            s = format(_('%s%s has left on %s (%s)'),
332                       msg.nick, hostmask, network, msg.args[1])
333        else:
334            s = format(_('%s%s has left on %s'), msg.nick, hostmask, network)
335        m = self._msgmaker(channel, s)
336        self._sendToOthers(irc, m)
337
338    def doMode(self, irc, msg):
339        irc = self._getRealIrc(irc)
340        channel = msg.args[0]
341        if channel not in self.registryValue('channels'):
342            return
343        network = self._getIrcName(irc)
344        s = format(_('mode change by %s on %s: %s'),
345                   msg.nick, network, ' '.join(msg.args[1:]))
346        m = self._msgmaker(channel, s)
347        self._sendToOthers(irc, m)
348
349    def doKick(self, irc, msg):
350        irc = self._getRealIrc(irc)
351        channel = msg.args[0]
352        if channel not in self.registryValue('channels'):
353            return
354        network = self._getIrcName(irc)
355        if len(msg.args) == 3:
356            s = format(_('%s was kicked by %s on %s (%s)'),
357                       msg.args[1], msg.nick, network, msg.args[2])
358        else:
359            s = format(_('%s was kicked by %s on %s'),
360                       msg.args[1], msg.nick, network)
361        m = self._msgmaker(channel, s)
362        self._sendToOthers(irc, m)
363
364    def doNick(self, irc, msg):
365        irc = self._getRealIrc(irc)
366        newNick = msg.args[0]
367        network = self._getIrcName(irc)
368        s = format(_('nick change by %s to %s on %s'), msg.nick,newNick,network)
369        for channel in self.registryValue('channels'):
370            m = self._msgmaker(channel, s)
371            self._sendToOthers(irc, m)
372
373    def doTopic(self, irc, msg):
374        irc = self._getRealIrc(irc)
375        (channel, newTopic) = msg.args
376        if channel not in self.registryValue('channels'):
377            return
378        network = self._getIrcName(irc)
379        if self.registryValue('topicSync', channel):
380            m = ircmsgs.topic(channel, newTopic)
381            for otherIrc in world.ircs:
382                if irc != otherIrc:
383                    try:
384                        if otherIrc.state.getTopic(channel) != newTopic:
385                            if (otherIrc, newTopic) not in self.queuedTopics:
386                                self.queuedTopics.add((otherIrc, newTopic))
387                                otherIrc.queueMsg(m)
388                            else:
389                                self.queuedTopics.remove((otherIrc, newTopic))
390
391                    except KeyError:
392                        self.log.warning('Not on %s on %s, '
393                                         'can\'t sync topics.',
394                                         channel, otherIrc.network)
395        else:
396            s = format(_('topic change by %s on %s: %s'),
397                       msg.nick, network, newTopic)
398            m = self._msgmaker(channel, s)
399            self._sendToOthers(irc, m)
400
401    def doQuit(self, irc, msg):
402        irc = self._getRealIrc(irc)
403        network = self._getIrcName(irc)
404        if msg.args:
405            s = format(_('%s has quit %s (%s)'), msg.nick, network, msg.args[0])
406        else:
407            s = format(_('%s has quit %s.'), msg.nick, network)
408        for channel in self.registryValue('channels'):
409            m = self._msgmaker(channel, s)
410            self._sendToOthers(irc, m)
411
412    def doError(self, irc, msg):
413        irc = self._getRealIrc(irc)
414        network = self._getIrcName(irc)
415        s = format(_('disconnected from %s: %s'), network, msg.args[0])
416        for channel in self.registryValue('channels'):
417            m = self._msgmaker(channel, s)
418            self._sendToOthers(irc, m)
419
420    def outFilter(self, irc, msg):
421        irc = self._getRealIrc(irc)
422        if msg.command == 'PRIVMSG':
423            if msg.relayedMsg:
424                self._addRelayMsg(msg)
425            else:
426                channel = msg.args[0]
427                if channel in self.registryValue('channels'):
428                    network = self._getIrcName(irc)
429                    s = self._formatPrivmsg(irc.nick, network, msg)
430                    relayMsg = self._msgmaker(channel, s)
431                    self._sendToOthers(irc, relayMsg)
432        return msg
433
434    def _addRelayMsg(self, msg):
435        channel = msg.args[0]
436        if channel in self.lastRelayMsgs:
437            q = self.lastRelayMsgs[channel]
438        else:
439            q = TimeoutQueue(60) # XXX Make this configurable.
440            self.lastRelayMsgs[channel] = q
441        unformatted = ircutils.stripFormatting(msg.args[1])
442        normalized = utils.str.normalizeWhitespace(unformatted)
443        q.enqueue(normalized)
444
445
446Class = Relay
447
448# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
449