"""Tendril between Words and IRC servers.
A Tendril, attached to a Words service, signs on as a user to an IRC
server. It can then relay traffic for one or more channels/groups
between the two servers. Anything it hears on a Words group it will
repeat as a user in an IRC channel; anyone it hears on IRC will appear
to be logged in to the Words service and speaking in a group there.
How to Start a Tendril
======================
In manhole::
from twisted.internet import reactor as R
from twisted.internet.app import theApplication as A
from twisted.words import tendril as T
w = A.getServiceNamed('twisted.words')
f = T.TendrilFactory(w)
# Maybe do some customization of f here, i.e.
## f.nickname = 'PartyLink'
## f.groupList = ['this', 'that', 'other']
R.connectTCP(irchost, 6667, f)
Stability: No more stable than L{words<twisted.words.service>}.
Future plans: Use \"L{Policy<twisted.words.service.Policy>}\" to get
Perspectives.
@author: U{Kevin Turner<acapnotic@twistedmatrix.com>}
"""
from twisted import copyright
from twisted.cred import authorizer, error
from twisted.internet import defer, protocol
from twisted.persisted import styles
from twisted.protocols import irc
from twisted.python import log, reflect
from twisted.words import service
from twisted.spread.util import LocalAsyncForwarder
wordsService = service
del service
import string
import traceback
import types
True = (1==1)
False = not True
_LOGALL = False
class TendrilFactory(protocol.ReconnectingClientFactory, reflect.Accessor):
"""I build Tendril clients for a words service.
All of a tendril's configurable state is stored here with me.
"""
wordsService = None
wordsclient = None
networkSuffix = None
nickname = None
perspectiveName = None
protocol = None _groupList = ['tendril_test']
_errorGroup = 'TendrilErrors'
helptext = (
"Hi, I'm a Tendril bridge between here and %(service)s.",
"You can send a private message to someone like this:",
"/msg %(myNick)s msg theirNick Hi there!",
)
def __init__(self, service):
"""Initialize this factory with a words service."""
self.reallySet('wordsService', service)
def startFactory(self):
self.wordsclient = TendrilWords(
service=self.wordsService, ircFactory=self,
nickname=self.nickname, perspectiveName=self.perspectiveName,
networkSuffix=self.networkSuffix, groupList=self.groupList,
errorGroup=self.errorGroup)
def buildProtocol(self, addr):
if self.wordsclient.irc:
log.msg("Warning: building a new %s protocol while %s is still active."
% (self.protocol, self.wordsclient.irc))
proto = protocol.ClientFactory.buildProtocol(self, addr)
self.wordsclient.setIrc(proto)
for k in ('nickname', 'helptext'):
setattr(proto, k, getattr(self, k))
return proto
def __getstate__(self):
state = self.__dict__.copy()
try:
del state["wordsclient"]
except KeyError:
pass
return state
def set_wordsService(self, service):
raise TypeError, "%s.wordsService is a read-only attribute." % (repr(self),)
def set_groupList(self, groupList):
if self.wordsclient:
oldlist = self.wordsclient.groupList
if groupList != oldlist:
newgroups = filter(lambda g, ol=oldlist: g not in ol,
groupList)
deadgroups = filter(lambda o, gl=groupList: o not in gl,
oldlist)
self.wordsclient.groupList[:] = groupList
if self.wordsclient.irc:
for group in newgroups:
self.wordsclient.irc.join(groupToChannelName(group))
for group in deadgroups:
self.wordsclient.irc.part(groupToChannelName(group))
self._groupList = groupList
def get_groupList(self):
if self.wordsclient:
return self.wordsclient.groupList
else:
return self._groupList
def set_nickname(self, nick):
if self.wordsclient and self.wordsclient.irc:
self.wordsclient.irc.setNick(nick)
self.reallySet('nickname', nick)
def set_errorGroup(self, errorGroup):
if self.wordsclient:
oldgroup = self.wordsclient.errorGroup
if oldgroup != errorGroup:
self.wordsclient.joinGroup(errorGroup)
self.wordsclient.errorGroup = errorGroup
self.wordsclient.leaveGroup(oldgroup)
self._errorGroup = errorGroup
def get_errorGroup(self):
if self.wordsclient:
return self.wordsclient.errorGroup
else:
return self._errorGroup
def set_helptext(self, helptext):
if isinstance(helptext, types.StringType):
helptext = string.split(helptext, '\n')
if self.wordsclient and self.wordsclient.irc:
self.wordsclient.irc.helptext = helptext
self.reallySet('helptext', helptext)
class ProxiedParticipant(wordsService.WordsClient,
styles.Ephemeral):
"""I'm the client of a participant who is connected through Tendril.
"""
nickname = None
tendril = None
def __init__(self, tendril, nickname):
self.tendril = tendril
self.nickname = nickname
def setNick(self, nickname):
self.nickname = nickname
def receiveDirectMessage(self, sender, message, metadata=None):
"""Pass this message through tendril to my IRC counterpart.
"""
self.tendril.msgFromWords(self.nickname,
sender, message, metadata)
class TendrilIRC(irc.IRCClient, styles.Ephemeral):
"""I connect to the IRC server and broker traffic.
"""
realname = 'Tendril'
versionName = 'Tendril'
versionNum = '$Revision: 1.2 $'[11:-2]
versionEnv = copyright.longversion
helptext = TendrilFactory.helptext
words = None
def __init__(self):
"""Create a new Tendril IRC client."""
self.dcc_sessions = {}
def connectionLost(self, reason):
"""When I lose a connection, log out all my IRC participants.
"""
self.log("%s: Connection lost: %s" % (self.transport, reason), 'info')
self.words.ircConnectionLost()
def lineReceived(self, line):
try:
irc.IRCClient.lineReceived(self, line)
except:
log.deferr()
def sendLine(self, line):
"""Send a line through my transport, unless my transport isn't up.
"""
if (not self.transport) or (not self.transport.connected):
return
self.log(line, 'dump')
irc.IRCClient.sendLine(self, line)
def irc_JOIN(self, prefix, params):
"""Join IRC user to the corresponding group.
"""
nick = string.split(prefix,'!')[0]
groupName = channelToGroupName(params[0])
if nick == self.nickname:
self.words.joinGroup(groupName)
else:
self.words._getParticipant(nick).joinGroup(groupName)
def irc_NICK(self, prefix, params):
"""When an IRC user changes their nickname
this does *not* change the name of their perspectivee, just my
nickname->perspective and client->nickname mappings.
"""
old_nick = string.split(prefix,'!')[0]
new_nick = params[0]
if old_nick == self.nickname:
self.nickname = new_nick
else:
self.words.changeParticipantNick(old_nick, new_nick)
def irc_PART(self, prefix, params):
"""Parting IRC members leave the correspoding group.
"""
nick = string.split(prefix,'!')[0]
channel = params[0]
groupName = channelToGroupName(channel)
if nick == self.nickname:
self.words.groupMessage(groupName, "I've left %s" % (channel,))
self.words.leaveGroup(groupName)
self.words.evacuateGroup(groupName)
return
else:
self.words.ircPartParticipant(nick, groupName)
def irc_QUIT(self, prefix, params):
"""When a user quits IRC, log out their participant.
"""
nick = string.split(prefix,'!')[0]
if nick == self.nickname:
self.words.detach()
else:
self.words.logoutParticipant(nick)
def irc_KICK(self, prefix, params):
"""Kicked? Who? Not me, I hope.
"""
nick = string.split(prefix,'!')[0]
channel = params[0]
kicked = params[1]
group = channelToGroupName(channel)
if string.lower(kicked) == string.lower(self.nickname):
if self.words.participants.has_key(nick):
wordsname = " (%s)" % (self.words._getParticipant(nick).name,)
else:
wordsname = ''
if len(params) > 2:
reason = ' "%s"' % (params[2],)
else:
reason = ''
self.words.groupMessage(group, '%s%s kicked me off!%s'
% (prefix, wordsname, reason))
self.log("I've been kicked from %s: %s %s"
% (channel, prefix, params), 'NOTICE')
self.words.evacuateGroup(group)
else:
self.words.ircPartParticipant(kicked, group)
def irc_INVITE(self, prefix, params):
"""Accept an invitation, if it's in my groupList.
"""
group = channelToGroupName(params[1])
if group in self.groupList:
self.log("I'm accepting the invitation to join %s from %s."
% (group, prefix), 'NOTICE')
self.words.join(groupToChannelName(group))
def irc_TOPIC(self, prefix, params):
"""Announce the new topic.
"""
nick = string.split(prefix,'!')[0]
channel = params[0]
topic = params[1]
self.words.groupMessage(channelToGroupName(channel),
"%s has just decreed the topic to be: %s"
% (self.words._getParticipant(nick).name,
topic))
def irc_ERR_BANNEDFROMCHAN(self, prefix, params):
"""When I can't get on a channel, report it.
"""
self.log("Join failed: %s %s" % (prefix, params), 'NOTICE')
irc_ERR_CHANNELISFULL = \
irc_ERR_UNAVAILRESOURCE = \
irc_ERR_INVITEONLYCHAN =\
irc_ERR_NOSUCHCHANNEL = \
irc_ERR_BADCHANNELKEY = irc_ERR_BANNEDFROMCHAN
def irc_ERR_NOTREGISTERED(self, prefix, params):
self.log("Got ERR_NOTREGISTERED, re-running connectionMade().",
'NOTICE')
self.connectionMade()
def ctcpQuery_DCC(self, user, channel, data):
"""Accept DCC handshakes, for passing on to others.
"""
nick = string.split(user,"!")[0]
if len(string.split(data)) < 4:
self.ctcpMakeReply(nick, [('ERRMSG',
'DCC %s :Malformed DCC request.'
% (data))])
return
dcc_text = irc.dccDescribe(data)
self.notice(nick, "Got your DCC %s"
% (irc.dccDescribe(data),))
pName = self.words._getParticipant(nick).name
self.dcc_sessions[pName] = (user, dcc_text, data)
self.notice(nick, "If I should pass it on to another user, "
"/msg %s DCC PASSTO theirNick" % (self.nickname,))
def signedOn(self):
"""Join my groupList once I've signed on.
"""
self.log("Welcomed by IRC server.", 'info')
self.factory.resetDelay()
for group in self.words.groupList:
self.join(groupToChannelName(group))
def privmsg(self, user, channel, message):
"""Dispatch privmsg as a groupMessage or a command, as appropriate.
"""
nick = string.split(user,'!')[0]
if nick == self.nickname:
return
if string.lower(channel) == string.lower(self.nickname):
parts = string.split(message, ' ', 1)
cmd = parts[0]
if len(parts) > 1:
remainder = parts[1]
else:
remainder = None
method = getattr(self, "bot_%s" % cmd, None)
if method is not None:
method(user, remainder)
else:
self.botUnknown(user, channel, message)
else:
group = channelToGroupName(channel)
self.words.ircParticipantMsg(nick, group, message)
def noticed(self, user, channel, message):
"""Pass channel notices on to the group.
"""
nick = string.split(user,'!')[0]
if nick == self.nickname:
return
if string.lower(channel) == string.lower(self.nickname):
pass
else:
group = channelToGroupName(channel)
self.words.ircParticipantMsg(nick, group, message)
def action(self, user, channel, message):
"""Speak about a participant in third-person.
"""
group = channelToGroupName(channel)
nick = string.split(user,'!',1)[0]
self.words.ircParticipantMsg(nick, group, message, emote=True)
def bot_msg(self, sender, params):
"""Pass along a message as a directMessage to a words Participant
"""
(nick, message) = string.split(params, ' ', 1)
sender = string.split(sender, '!', 1)[0]
try:
self.words._getParticipant(sender).directMessage(nick, message)
except wordsService.WordsError, e:
self.notice(sender, "msg to %s failed: %s" % (nick, e))
def bot_help(self, user, params):
nick = string.split(user, '!', 1)[0]
for l in self.helptext:
self.notice(nick, l % {
'myNick': self.nickname,
'service': self.factory.wordsService,
})
def botUnknown(self, user, channel, message):
parts = string.split(message, ' ', 1)
cmd = parts[0]
if len(parts) > 1:
remainder = parts[1]
else:
remainder = None
if remainder is not None:
if ('!' in user) and ('@' in user):
self.bot_msg(user, message)
else:
nick = string.split(user, '!', 1)[0]
self.notice(nick, "I don't know what to do with '%s'. "
"`/msg %s help` for help."
% (cmd, self.nickname))
def bot_DCC(self, user, params):
"""Commands for brokering DCC handshakes.
DCC -- I'll tell you if I'm holding a DCC request from you.
DCC PASSTO nick -- give the DCC request you gave me to this nick.
DCC FORGET -- forget any DCC requests you offered to me.
"""
nick = string.split(user,"!")[0]
pName = self.words._getParticipant(nick).name
if not params:
if self.dcc_sessions.has_key(pName):
dcc_text = self.dcc_sessions[pName][1]
self.notice(nick,
"I have an offer from you for DCC %s"
% (dcc_text,))
else:
self.notice(nick, "I have no DCC offer from you.")
return
params = string.split(params)
if (params[0] == 'PASSTO') and (len(params) > 1):
(cmd, dst) = params[:2]
cmd = string.upper(cmd)
if self.dcc_sessions.has_key(pName):
(origUser, dcc_text, orig_data)=self.dcc_sessions[pName]
if dcc_text:
dcc_text = " for " + dcc_text
else:
dcc_text = ''
ctcpMsg = irc.ctcpStringify([('DCC',orig_data)])
try:
self.words._getParticipant(nick).directMessage(dst,
ctcpMsg)
except wordsService.WordsError, e:
self.notice(nick, "DCC offer to %s failed: %s"
% (dst, e))
else:
self.notice(nick, "DCC offer%s extended to %s."
% (dcc_text, dst))
del self.dcc_sessions[pName]
else:
self.notice(nick, "I don't have an active DCC"
" handshake from you.")
elif params[0] == 'FORGET':
if self.dcc_sessions.has_key(pName):
del self.dcc_sessions[pName]
self.notice(nick, "I have now forgotten any DCC offers"
" from you.")
else:
self.notice(nick,
"Valid DCC commands are: "
"DCC, DCC PASSTO <nick>, DCC FORGET")
return
def log(self, message, priority=None):
"""I need to give Twisted a prioritized logging facility one of these days.
"""
if _LOGALL:
log.msg(message)
elif not (priority in ('dump',)):
log.msg(message)
if priority in ('info', 'NOTICE', 'ERROR'):
self.words.groupMessage(self.words.errorGroup, message)
TendrilFactory.protocol = TendrilIRC
class TendrilWords(wordsService.WordsClient):
nickname = 'tl'
networkSuffix = '@opn'
perspectiveName = nickname + networkSuffix
participants = None
irc = None
ircFactory = None
def __init__(self, service, ircFactory,
nickname=None, networkSuffix=None, perspectiveName=None,
groupList=None, errorGroup=None):
"""
service -- a twisted.words.service.Service, or at least
something with a 'serviceName' attribute and 'createParticipant'
and 'getPerspectiveNamed' methods which work like a
words..Service.
groupList -- a list of strings naming groups on the Words
service to join and bridge to their counterparts on the IRC
server.
nickname -- a string to use as my nickname on the IRC network.
networkSuffix -- a string to append to the nickname of the
Participants I bring in through IRC, e.g. \"@opn\".
perspectiveName -- the name of my perspective with this
service. Defaults to nickname + networkSuffix.
"""
self.service = service
self.ircFactory = ircFactory
self.participants = {}
if nickname:
self.nickname = nickname
if networkSuffix:
self.networkSuffix = networkSuffix
if perspectiveName:
self.perspectiveName = perspectiveName
else:
self.perspectiveName = self.nickname + self.networkSuffix
if groupList:
self.groupList = groupList
else:
self.groupList = self.groupList[:]
if errorGroup:
self.errorGroup = errorGroup
self.attachToWords()
def setIrc(self, ircProtocol):
self.irc = ircProtocol
self.irc.realname = 'Tendril to %s' % (self.service.serviceName,)
self.irc.words = self
def setupBot(self, perspective):
self.perspective = perspective
self.joinGroup(self.errorGroup)
def attachToWords(self):
"""Get my perspective on the Words service; attach as a client.
"""
self.service.addBot(self.perspectiveName, self)
def memberJoined(self, member, group):
"""Tell the IRC Channel when someone joins the Words group.
"""
if (group == self.errorGroup) or self.isThisMine(member):
return
self.irc.say(groupToChannelName(group), "%s joined." % (member,))
def memberLeft(self, member, group):
"""Tell the IRC Channel when someone leaves the Words group.
"""
if (group == self.errorGroup) or self.isThisMine(member):
return
self.irc.say(groupToChannelName(group), "%s left." % (member,))
def receiveGroupMessage(self, sender, group, message, metadata=None):
"""Pass a message from the Words group on to IRC.
Or, if it's in our errorGroup, recognize some debugging commands.
"""
if not (group == self.errorGroup):
channel = groupToChannelName(group)
if not self.isThisMine(sender):
if message[:8] == irc.X_DELIM + 'ACTION ':
c = irc.ctcpExtract(message)
for tag, data in c['extended']:
if tag == 'ACTION':
self.irc.say(channel, "* %s %s" % (sender, data))
else:
msg = "%(X)s%(tag)s %(data)s%(X)s" % {
'X': irc.X_DELIM,
'tag': tag,
'data': data
}
msg = irc.ctcpQuote(msg)
c['normal'].append(msg)
for msg in c['normal']:
self.irc.say(channel, "<%s> %s" % (sender, msg))
return
elif irc.X_DELIM in message:
message = irc.ctcpQuote(message)
if metadata and metadata.has_key('style'):
if metadata['style'] == "emote":
self.irc.say(channel, "* %s %s" % (sender, message))
return
self.irc.say(channel, "<%s> %s" % (sender, message))
else:
if message == "participants":
s = map(lambda i: str(i[0]), self.participants.values())
s = string.join(s, ", ")
elif message == "groups":
s = map(str, self.perspective.groups)
s = string.join(s, ", ")
elif message == "transport":
s = "%s connected: %s" %\
(self.transport, getattr(self.transport, "connected"))
else:
s = None
if s:
self.groupMessage(group, s)
def joinGroup(self, groupName):
return self.perspective.joinGroup(groupName)
def leaveGroup(self, groupName):
return self.perspective.leaveGroup(groupName)
def groupMessage(self, groupName, message):
return self.perspective.groupMessage(groupName, message)
def directMessage(self, recipientName, message):
return self.perspective.directMessage(recipientName, message)
def attach(self):
self.perspective.attached(self, None)
def detach(self):
"""Pull everyone off Words, sign off, cut the IRC connection.
"""
if not (self is getattr(self.perspective,'client')):
return
for g in self.perspective.groups:
if g.name != self.errorGroup:
self.leaveGroup(g.name)
for nick in self.participants.keys()[:]:
self.logoutParticipant(nick)
self.perspective.detached(self, None)
if self.transport and getattr(self.transport, 'connected'):
self.ircFactory.doStop()
self.transport.loseConnection()
def msgFromWords(self, toNick, sender, message, metadata=None):
"""Deliver a directMessage as a privmsg over IRC.
"""
if message[0] != irc.X_DELIM:
if metadata and metadata.has_key('style'):
message = "[%s] %s" % (metadata['style'], message)
self.irc.msg(toNick, '<%s> %s' % (sender, message))
else:
dcc_data = None
if message[1:5] == 'DCC ':
dcc_query = irc.ctcpExtract(message)['extended'][0]
dcc_data = dcc_query[1]
if dcc_data:
desc = "DCC " + irc.dccDescribe(dcc_data)
else:
desc = "CTCP request"
self.irc.msg(toNick, 'The following %s is from %s'
% (desc, sender))
self.irc.msg(toNick, '%s' % (message,))
def ircConnectionLost(self):
for nick in self.participants.keys()[:]:
self.logoutParticipant(nick)
def ircPartParticipant(self, nick, groupName):
participant = self._getParticipant(nick)
try:
participant.leaveGroup(groupName)
except wordsService.NotInGroupError:
pass
if not participant.groups:
self.logoutParticipant(nick)
def ircParticipantMsg(self, nick, groupName, message, emote=False):
participant = self._getParticipant(nick)
if emote:
metadata = {'style': 'emote'}
else:
metadata = None
try:
participant.groupMessage(groupName, message, metadata)
except wordsService.NotInGroupError:
participant.joinGroup(groupName)
participant.groupMessage(groupName, message, metadata)
def evacuateGroup(self, groupName):
"""Pull all of my Participants out of this group.
"""
group = self.service.getGroup(groupName)
allMyMembers = map(lambda m: m[0], self.participants.values())
groupMembers = filter(lambda m, a=allMyMembers: m in a,
group.members)
for m in groupMembers:
m.leaveGroup(groupName)
def _getParticipant(self, nick):
"""Get a Perspective (words.service.Participant) for a IRC user.
And if I don't have one around, I'll make one.
"""
if not self.participants.has_key(nick):
self._newParticipant(nick)
return self.participants[nick][0]
def _getClient(self, nick):
if not self.participants.has_key(nick):
self._newParticipant(nick)
return self.participants[nick][1]
def _newParticipant(self, nick):
try:
p = self.service.getPerspectiveNamed(nick + self.networkSuffix)
except wordsService.UserNonexistantError:
p = self.service.createParticipant(nick + self.networkSuffix)
if not p:
raise wordsService.wordsError("Eeek! Couldn't get OR "
"make a perspective for "
"'%s%s'." %
(nick, self.networkSuffix))
c = ProxiedParticipant(self, nick)
p.attached(LocalAsyncForwarder(c, wordsService.IWordsClient, 1),
None)
self.participants[nick] = [p, c]
def changeParticipantNick(self, old_nick, new_nick):
if not self.participants.has_key(old_nick):
return
(p, c) = self.participants[old_nick]
c.setNick(new_nick)
self.participants[new_nick] = self.participants[old_nick]
del self.participants[old_nick]
def logoutParticipant(self, nick):
if not self.participants.has_key(nick):
return
(p, c) = self.participants[nick]
p.detached(c, None)
c.tendril = None
if not p.identityName:
self.service.uncachePerspective(p)
del self.participants[nick]
def isThisMine(self, sender):
"""Returns true if 'sender' is the name of a perspective I'm providing.
"""
if self.perspectiveName == sender:
return "That's ME!"
for (p, c) in self.participants.values():
if p.name == sender:
return 1
return 0
def channelToGroupName(channelName):
"""Map an IRC channel name to a Words group name.
IRC is case-insensitive, words is not. Arbitrtarily decree that all
IRC channels should be lowercase.
Warning: This prevents me from relaying text from IRC to
a mixed-case Words group. That is, any words group I'm
in should have an all-lowercase name.
"""
groupName = string.lower(channelName[1:])
return groupName
def groupToChannelName(groupName):
channelName = groupName
return channelName