2
# Twisted, the Framework of Your Internet
3
# Copyright (C) 2001 Matthew W. Lefkowitz
5
# This library is free software; you can redistribute it and/or
6
# modify it under the terms of version 2.1 of the GNU Lesser General Public
7
# License as published by the Free Software Foundation.
9
# This library is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12
# Lesser General Public License for more details.
14
# You should have received a copy of the GNU Lesser General Public
15
# License along with this library; if not, write to the Free Software
16
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18
from twisted.persisted import styles
19
from twisted.protocols import irc
20
from twisted.python import log
21
from twisted.words import service
22
from twisted import copyright
23
from twisted.cred import authorizer
25
wordsService = service
34
# XXX FIXME -- This will need to be fixed to work asynchronously in order to
35
# support multiple-server twisted.words and database access to accounts
37
class ProxiedParticipant(wordsService.WordsClientInterface,
39
"""I'm the client of a participant who is connected through Tendril.
45
def __init__(self, tendril, nickname):
46
self.tendril = tendril
47
self.nickname = nickname
49
def setNick(self, nickname):
50
self.nickname = nickname
52
def receiveDirectMessage(self, sender, message, metadata=None):
53
"""Pass this message through tendril to my IRC counterpart.
55
self.tendril.msgFromWords(self.nickname,
56
sender, message, metadata)
59
class TendrilClient(irc.IRCClient, wordsService.WordsClientInterface):
60
"""I connect to the IRC server and broker traffic.
63
networkSuffix = '@opn'
65
groupList = ['tendril_test']
69
errorGroup = 'TendrilErrors'
70
perspectiveName = nickname + networkSuffix
73
versionName = 'Tendril'
74
versionNum = '$Revision: 1.14 $'[11:-2]
75
versionEnv = copyright.longversion
78
"Hi, I'm a Tendril bridge between here and %(service)s.",
79
"You can send a private message to someone like this:",
80
"/msg %(myNick)s msg theirNick Hi there!",
83
def __init__(self, service, groupList=None,
84
nickname=None, networkSuffix=None, perspectiveName=None):
85
"""Create a new Tendril client.
87
service -- a twisted.words.service.Service, or at least
88
something with a 'serviceName' attribute and 'createParticipant'
89
and 'getPerspectiveNamed' methods which work like a
92
groupList -- a list of strings naming groups on the Words
93
service to join and bridge to their counterparts on the IRC
96
nickname -- a string to use as my nickname on the IRC network.
98
networkSuffix -- a string to append to the nickname of the
99
Participants I bring in through IRC, e.g. \"@opn\".
101
perspectiveName -- the name of my perspective with this
102
service. Defaults to nickname + networkSuffix.
104
To connect me to an IRC server, pass me as the 'protocol' when
105
constructing a tcp.Client.
107
self.participants = {}
108
self.dcc_sessions = {}
111
self.nickname = nickname
113
self.networkSuffix = networkSuffix
115
if not perspectiveName:
116
perspectiveName = self.nickname + self.networkSuffix
118
self.perspectiveName = perspectiveName
121
self.groupList = list(groupList)
123
self.groupList = list(TendrilClient.groupList)
125
self.service = service
126
self.realname = 'Tendril to %s' % (service.serviceName,)
130
self.joinGroup(self.errorGroup)
133
def attachToWords(self):
134
"""Get my perspective on the Words service; attach as a client.
139
self.service.getPerspectiveNamed(self.perspectiveName))
140
except wordsService.UserNonexistantError:
142
self.service.createParticipant(self.perspectiveName))
143
if not self.perspective:
144
raise RuntimeError, ("service %s won't give me my "
145
"perspective named %s"
147
self.perspectiveName))
149
if self.perspective.client is self:
150
log.msg("I seem to be already attached.")
155
except authorizer.Unauthorized:
156
if self.perspective.client:
157
log.msg("%s is attached to my perspective: "
158
"kicking it off." % (self.perspective.client,))
159
self.perspective.detached(self.perspective.client, None)
164
def __getstate__(self):
165
dct = self.__dict__.copy()
166
# Don't save my imaginary friends.
167
dct["participants"] = {}
171
### Protocol-level methods
173
def connectionLost(self):
174
"""When I lose a connection, log out all my IRC participants.
176
self.log("%s: Connection lost." % (self.transport,), 'info')
177
for nick in self.participants.keys()[:]:
178
self.logoutParticipant(nick)
180
### Protocol LineReceiver-level methods
182
def lineReceived(self, line):
183
self.log(line, 'dump')
186
irc.IRCClient.lineReceived(self, line)
191
def sendLine(self, line):
192
"""Send a line through my transport, unless my transport isn't up.
194
if (not self.transport) or (not self.transport.connected):
197
self.log(line, 'dump')
198
irc.IRCClient.sendLine(self, line)
201
### Protocol IRCClient server->client methods
203
def irc_JOIN(self, prefix, params):
204
"""Join IRC user to the corresponding group.
206
nick = string.split(prefix,'!')[0]
207
groupName = channelToGroupName(params[0])
208
if nick == self.nickname:
209
self.joinGroup(groupName)
211
self._getParticipant(nick).joinGroup(groupName)
213
def irc_NICK(self, prefix, params):
214
"""When an IRC user changes their nickname
216
this does *not* change the name of their perspectivee, just my
217
nickname->perspective and client->nickname mappings.
219
old_nick = string.split(prefix,'!')[0]
221
if old_nick == self.nickname:
222
self.nickname = new_nick
224
self.changeParticipantNick(old_nick, new_nick)
226
def irc_PART(self, prefix, params):
227
"""Parting IRC members leave the correspoding group.
229
nick = string.split(prefix,'!')[0]
231
groupName = channelToGroupName(channel)
232
if nick == self.nickname:
233
self.groupMessage(groupName, "I've left %s" % (channel,))
234
self.leaveGroup(groupName)
235
self.evacuateGroup(groupName)
238
participant = self._getParticipant(nick)
240
participant.leaveGroup(groupName)
241
except wordsService.NotInGroupError:
244
if not participant.groups:
245
self.logoutParticipant(nick)
247
def irc_QUIT(self, prefix, params):
248
"""When a user quits IRC, log out their participant.
250
nick = string.split(prefix,'!')[0]
251
if nick == self.nickname:
254
self.logoutParticipant(nick)
256
def irc_KICK(self, prefix, params):
257
"""Kicked? Who? Not me, I hope.
259
nick = string.split(prefix,'!')[0]
262
group = channelToGroupName(channel)
263
if string.lower(kicked) == string.lower(self.nickname):
265
if self.participants.has_key(nick):
266
wordsname = " (%s)" % (self._getParticipant(nick).name,)
270
reason = ' "%s"' % (params[2],)
274
self.groupMessage(group, '%s%s kicked me off!%s'
275
% (prefix, wordsname, reason))
276
self.log("I've been kicked from %s: %s %s"
277
% (channel, prefix, params), 'NOTICE')
278
self.evacuateGroup(group)
282
self._getParticipant(kicked).leaveGroup(group)
283
except wordsService.NotInGroupError:
286
def irc_INVITE(self, prefix, params):
287
"""Accept an invitation, if it's in my groupList.
289
group = channelToGroupName(params[1])
290
if group in self.groupList:
291
self.log("I'm accepting the invitation to join %s from %s."
292
% (group, prefix), 'NOTICE')
293
self.join(groupToChannelName(group))
295
def irc_TOPIC(self, prefix, params):
296
"""Announce the new topic.
298
# XXX: words groups *do* have topics, but they're currently
299
# not used. Should we use them?
300
nick = string.split(prefix,'!')[0]
303
self.groupMessage(channelToGroupName(channel),
304
"%s has just decreed the topic to be: %s"
305
% (self._getParticipant(nick).name,
308
def irc_ERR_BANNEDFROMCHAN(self, prefix, params):
309
"""When I can't get on a channel, report it.
311
self.log("Join failed: %s %s" % (prefix, params), 'NOTICE')
313
irc_ERR_CHANNELISFULL = \
314
irc_ERR_UNAVAILRESOURCE = \
315
irc_ERR_INVITEONLYCHAN =\
316
irc_ERR_NOSUCHCHANNEL = \
317
irc_ERR_BADCHANNELKEY = irc_ERR_BANNEDFROMCHAN
319
def irc_ERR_NOTREGISTERED(self, prefix, params):
320
self.log("Got ERR_NOTREGISTERED, re-running connectionMade().",
322
self.connectionMade()
325
### Client-To-Client-Protocol methods
327
def ctcpQuery_DCC(self, user, channel, data):
328
"""Accept DCC handshakes, for passing on to others.
330
nick = string.split(user,"!")[0]
332
# We're pretty lenient about what we pass on, but the existance
333
# of at least four parameters (type, arg, host, port) is really
335
if len(string.split(data)) < 4:
336
self.ctcpMakeReply(nick, [('ERRMSG',
337
'DCC %s :Malformed DCC request.'
341
dcc_text = irc.dccDescribe(data)
343
self.notice(nick, "Got your DCC %s"
344
% (irc.dccDescribe(data),))
346
pName = self._getParticipant(nick).name
347
self.dcc_sessions[pName] = (user, dcc_text, data)
349
self.notice(nick, "If I should pass it on to another user, "
350
"/msg %s DCC PASSTO theirNick" % (self.nickname,))
353
### IRCClient client event methods
356
"""Join my groupList once I've signed on.
358
self.log("Welcomed by IRC server.", 'info')
359
for group in self.groupList:
360
self.join(groupToChannelName(group))
362
def privmsg(self, user, channel, message):
363
"""Dispatch privmsg as a groupMessage or a command, as appropriate.
365
nick = string.split(user,'!')[0]
366
if nick == self.nickname:
369
if string.lower(channel) == string.lower(self.nickname):
370
parts = string.split(message, ' ', 1)
377
method = getattr(self, "bot_%s" % cmd, None)
378
if method is not None:
379
method(user, remainder)
381
self.botUnknown(user, channel, message)
383
# The message isn't to me, so it must be to a group.
384
group = channelToGroupName(channel)
386
self._getParticipant(nick).groupMessage(group, message)
387
except wordsService.NotInGroupError:
388
self._getParticipant(nick).joinGroup(group)
389
self._getParticipant(nick).groupMessage(group, message)
391
def noticed(self, user, channel, message):
392
"""Pass channel notices on to the group.
394
nick = string.split(user,'!')[0]
395
if nick == self.nickname:
398
if string.lower(channel) == string.lower(self.nickname):
399
# A private notice is most likely an auto-response
400
# from something else, or a message from an IRC service.
401
# Don't treat at as a command.
404
# The message isn't to me, so it must be to a group.
405
group = channelToGroupName(channel)
407
self._getParticipant(nick).groupMessage(group, message)
408
except wordsService.NotInGroupError:
409
self._getParticipant(nick).joinGroup(group)
410
self._getParticipant(nick).groupMessage(group, message)
412
def action(self, user, channel, message):
413
"""Speak about a participant in third-person.
415
group = channelToGroupName(channel)
416
nick = string.split(user,'!',1)[0]
418
self._getParticipant(nick).groupMessage(group, message,
420
except wordsService.NotInGroupError:
421
self._getParticipant(nick).joinGroup(group)
422
self._getParticipant(nick).groupMessage(group, message,
425
### Bot event methods
427
def bot_msg(self, sender, params):
428
"""Pass along a message as a directMessage to a words Participant
430
(nick, message) = string.split(params, ' ', 1)
431
sender = string.split(sender, '!', 1)[0]
433
self._getParticipant(sender).directMessage(nick, message)
434
except wordsService.WordsError, e:
435
self.notice(sender, "msg to %s failed: %s" % (nick, e))
437
def bot_help(self, user, params):
438
nick = string.split(user, '!', 1)[0]
439
for l in self.helptext:
440
self.notice(nick, l % {
441
'myNick': self.nickname,
442
'service': self.service.serviceName,
445
def botUnknown(self, user, channel, message):
446
parts = string.split(message, ' ', 1)
453
if remainder is not None:
454
# Default action is to try anything as a 'msg'
455
# make sure the message is from a user and not a server.
456
if ('!' in user) and ('@' in user):
457
self.bot_msg(user, message)
459
# But if the msg would be empty, don't that.
461
nick = string.split(user, '!', 1)[0]
462
self.notice(nick, "I don't know what to do with '%s'. "
463
"`/msg %s help` for help."
464
% (cmd, self.nickname))
466
def bot_DCC(self, user, params):
467
"""Commands for brokering DCC handshakes.
469
DCC -- I'll tell you if I'm holding a DCC request from you.
471
DCC PASSTO nick -- give the DCC request you gave me to this nick.
473
DCC FORGET -- forget any DCC requests you offered to me.
475
nick = string.split(user,"!")[0]
476
pName = self._getParticipant(nick).name
479
# Do I have a DCC from you?
480
if self.dcc_sessions.has_key(pName):
481
dcc_text = self.dcc_sessions[pName][1]
483
"I have an offer from you for DCC %s"
486
self.notice(nick, "I have no DCC offer from you.")
489
params = string.split(params)
491
if (params[0] == 'PASSTO') and (len(params) > 1):
492
(cmd, dst) = params[:2]
493
cmd = string.upper(cmd)
494
if self.dcc_sessions.has_key(pName):
495
(origUser, dcc_text, orig_data)=self.dcc_sessions[pName]
497
dcc_text = " for " + dcc_text
501
ctcpMsg = irc.ctcpStringify([('DCC',orig_data)])
503
self._getParticipant(nick).directMessage(dst,
505
except wordsService.WordsError, e:
506
self.notice(nick, "DCC offer to %s failed: %s"
509
self.notice(nick, "DCC offer%s extended to %s."
511
del self.dcc_sessions[pName]
514
self.notice(nick, "I don't have an active DCC"
515
" handshake from you.")
517
elif params[0] == 'FORGET':
518
if self.dcc_sessions.has_key(pName):
519
del self.dcc_sessions[pName]
520
self.notice(nick, "I have now forgotten any DCC offers"
524
"Valid DCC commands are: "
525
"DCC, DCC PASSTO <nick>, DCC FORGET")
528
### WordsClient methods
529
## Words.Group --> IRC
531
def memberJoined(self, member, group):
532
"""Tell the IRC Channel when someone joins the Words group.
534
if (group == self.errorGroup) or self.isThisMine(member):
536
self.say(groupToChannelName(group), "%s joined." % (member,))
538
def memberLeft(self, member, group):
539
"""Tell the IRC Channel when someone leaves the Words group.
541
if (group == self.errorGroup) or self.isThisMine(member):
543
self.say(groupToChannelName(group), "%s left." % (member,))
545
def receiveGroupMessage(self, sender, group, message, metadata=None):
546
"""Pass a message from the Words group on to IRC.
548
Or, if it's in our errorGroup, recognize some debugging commands.
550
if not (group == self.errorGroup):
551
channel = groupToChannelName(group)
552
if not self.isThisMine(sender):
553
# Test for Special case:
554
# got CTCP, probably through words.ircservice
556
# ACTION is the only case we'll support here.
557
if message[:8] == irc.X_DELIM + 'ACTION ':
558
c = irc.ctcpExtract(message)
559
for tag, data in c['extended']:
561
self.say(channel, "* %s %s" % (sender, data))
563
# Not an action. Repackage the chunk,
564
msg = "%(X)s%(tag)s %(data)s%(X)s" % {
569
# ctcpQuote it to render it harmless,
570
msg = irc.ctcpQuote(msg)
571
# and let it continue on.
572
c['normal'].append(msg)
574
for msg in c['normal']:
575
self.say(channel, "<%s> %s" % (sender, msg))
578
elif irc.X_DELIM in message:
579
message = irc.ctcpQuote(message)
581
if metadata and metadata.has_key('style'):
582
if metadata['style'] == "emote":
583
self.say(channel, "* %s %s" % (sender, message))
586
self.say(channel, "<%s> %s" % (sender, message))
588
# A message in our errorGroup.
589
if message == "participants":
590
s = map(lambda i: str(i[0]), self.participants.values())
591
s = string.join(s, ", ")
592
elif message == "groups":
593
s = map(str, self.perspective.groups)
594
s = string.join(s, ", ")
595
elif message == "transport":
596
s = "%s connected: %s" %\
597
(self.transport, getattr(self.transport, "connected"))
602
self.groupMessage(group, s)
605
### My methods as a Participant
606
### (Shortcuts for self.perspective.foo())
608
def joinGroup(self, groupName):
609
return self.perspective.joinGroup(groupName)
611
def leaveGroup(self, groupName):
612
return self.perspective.leaveGroup(groupName)
614
def groupMessage(self, groupName, message):
615
return self.perspective.groupMessage(groupName, message)
617
def directMessage(self, recipientName, message):
618
return self.perspective.directMessage(recipientName, message)
620
### My methods as a bogus perspective broker
621
### (Since I grab my perspective directly from the service, it hasn't
622
### been issued by a Perspective Broker.)
625
self.perspective.attached(self, None)
628
"""Pull everyone off Words, sign off, cut the IRC connection.
630
if not (self is getattr(self.perspective,'client')):
634
for g in self.perspective.groups:
635
self.leaveGroup(g.name)
636
for nick in self.participants.keys()[:]:
637
self.logoutParticipant(nick)
638
self.perspective.detached(self, None)
639
if self.transport and getattr(self.transport, 'connected'):
640
self.transport.loseConnection()
642
### Participant event methods
643
## Words.Participant --> IRC
645
def msgFromWords(self, toNick, sender, message, metadata=None):
646
"""Deliver a directMessage as a privmsg over IRC.
648
if message[0] != irc.X_DELIM:
649
if metadata and metadata.has_key('style'):
650
# Damn. What am I supposed to do with this?
651
message = "[%s] %s" % (metadata['style'], message)
653
self.msg(toNick, '<%s> %s' % (sender, message))
655
# If there is a CTCP delimeter at the beginning of the
656
# message, let's leave it there to accomidate not-so-
659
if message[1:5] == 'DCC ':
660
dcc_query = irc.ctcpExtract(message)['extended'][0]
661
dcc_data = dcc_query[1]
664
desc = "DCC " + irc.dccDescribe(dcc_data)
666
desc = "CTCP request"
668
self.msg(toNick, 'The following %s is from %s'
670
self.msg(toNick, '%s' % (message,))
673
# IRC Participant Management
675
def evacuateGroup(self, groupName):
676
"""Pull all of my Participants out of this group.
678
# XXX: This marks another place where we get a little
679
# overly cozy with the words service.
680
group = self.service.getGroup(groupName)
682
allMyMembers = map(lambda m: m[0], self.participants.values())
683
groupMembers = filter(lambda m, a=allMyMembers: m in a,
686
for m in groupMembers:
687
m.leaveGroup(groupName)
689
def _getParticipant(self, nick):
690
"""Get a Perspective (words.service.Participant) for a IRC user.
692
And if I don't have one around, I'll make one.
694
if not self.participants.has_key(nick):
695
self._newParticipant(nick)
697
return self.participants[nick][0]
699
def _getClient(self, nick):
700
if not self.participants.has_key(nick):
701
self._newParticipant(nick)
702
return self.participants[nick][1]
704
# TODO: let IRC users authorize themselves and then give them a
705
# *real* perspective (one attached to their identity) instead
706
# of one of my @networkSuffix-Nobody perspectives.
708
def _newParticipant(self, nick):
710
p = self.service.getPerspectiveNamed(nick +
712
except wordsService.UserNonexistantError:
713
p = self.service.createParticipant(nick + self.networkSuffix)
715
raise wordsService.wordsError("Eeek! Couldn't get OR "
716
"make a perspective for "
718
(nick, self.networkSuffix))
720
c = ProxiedParticipant(self, nick)
723
self.participants[nick] = [p, c]
725
def changeParticipantNick(self, old_nick, new_nick):
726
if not self.participants.has_key(old_nick):
729
(p, c) = self.participants[old_nick]
732
self.participants[new_nick] = self.participants[old_nick]
733
del self.participants[old_nick]
735
def logoutParticipant(self, nick):
736
if not self.participants.has_key(nick):
739
(p, c) = self.participants[nick]
742
# XXX: This must change if we ever start giving people 'real'
744
if not p.identityName:
745
del self.service.participants[p.name]
746
del self.participants[nick]
748
def isThisMine(self, sender):
749
"""Returns true if 'sender' is the name of a perspective I'm providing.
751
if self.perspectiveName == sender:
754
for (p, c) in self.participants.values():
762
def log(self, message, priority=None):
763
"""I need to give Twisted a prioritized logging facility one of these days.
767
elif not (priority in ('dump',)):
770
if priority in ('info', 'NOTICE', 'ERROR'):
771
self.groupMessage(self.errorGroup, message)
774
def channelToGroupName(channelName):
775
"""Map an IRC channel name to a Words group name.
777
IRC is case-insensitive, words is not. Arbitrtarily decree that all
778
IRC channels should be lowercase.
780
Warning: This prevents me from relaying text from IRC to
781
a mixed-case Words group. That is, any words group I'm
782
in should have an all-lowercase name.
785
# Normalize case and trim leading '#'
786
groupName = string.lower(channelName[1:])
789
def groupToChannelName(groupName):
790
# Don't add a "#" here, because we do so in the outgoing IRC methods.
791
channelName = groupName