# -*- test-case-name: twisted.test.test_tendril -*- # $Id: tendril.py,v 1.32 2003/01/08 10:34:29 acapnotic Exp $ # Twisted, the Framework of Your Internet # Copyright (C) 2001 Matthew W. Lefkowitz # # This library is free software; you can redistribute it and/or # modify it under the terms of version 2.1 of the GNU Lesser General Public # License as published by the Free Software Foundation. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA """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}. Future plans: Use \"L{Policy}\" to get Perspectives. @author: U{Kevin Turner} """ 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 # XXX FIXME -- This will need to be fixed to work asynchronously in order to # support multiple-server twisted.words and database access to accounts 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 # will be set to TendrilIRC as soon as it's defined. _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) # Ermm. ## self.protocol.__dict__.update(self.getConfiguration()) 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.32 $'[11:-2] versionEnv = copyright.longversion helptext = TendrilFactory.helptext words = None def __init__(self): """Create a new Tendril IRC client.""" self.dcc_sessions = {} ### Protocol-level methods 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() ### Protocol LineReceiver-level methods def lineReceived(self, line): try: irc.IRCClient.lineReceived(self, line) except: # If you *don't* catch exceptions here, any unhandled exception # raised by anything lineReceived calls (which is most of the # client code) ends up making Connection Lost happen, which # is almost certainly not necessary for us. 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) ### Protocol IRCClient server->client methods 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): # Yikes! 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. """ # XXX: words groups *do* have topics, but they're currently # not used. Should we use them? 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() ### Client-To-Client-Protocol methods def ctcpQuery_DCC(self, user, channel, data): """Accept DCC handshakes, for passing on to others. """ nick = string.split(user,"!")[0] # We're pretty lenient about what we pass on, but the existance # of at least four parameters (type, arg, host, port) is really # required. 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,)) ### IRCClient client event methods 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: # The message isn't to me, so it must be to a group. 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): # A private notice is most likely an auto-response # from something else, or a message from an IRC service. # Don't treat at as a command. pass else: # The message isn't to me, so it must be to a group. 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) ### Bot event methods 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: # Default action is to try anything as a 'msg' # make sure the message is from a user and not a server. if ('!' in user) and ('@' in user): self.bot_msg(user, message) else: # But if the msg would be empty, don't that. # Just act confused. 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: # Do I have a DCC from you? 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 , DCC FORGET") return ### Utility 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: # Copy the class default's list so as to not modify the original. 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) # XXX: Decide how much of this code belongs in words..Service.addBot # try: # self.perspective = ( # self.service.getPerspectiveNamed(self.perspectiveName)) # except wordsService.UserNonexistantError: # self.perspective = ( # self.service.createParticipant(self.perspectiveName)) # if not self.perspective: # raise RuntimeError, ("service %s won't give me my " # "perspective named %s" # % (self.service, # self.perspectiveName)) # if self.perspective.client is self: # log.msg("I seem to be already attached.") # return # try: # self.attach() # except error.Unauthorized: # if self.perspective.client: # log.msg("%s is attached to my perspective: " # "kicking it off." % (self.perspective.client,)) # self.perspective.detached(self.perspective.client, None) # self.attach() # else: # raise ### WordsClient methods ## Words.Group --> IRC 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): # Test for Special case: # got CTCP, probably through words.ircservice # (you SUCK!) # ACTION is the only case we'll support here. 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: # Not an action. Repackage the chunk, msg = "%(X)s%(tag)s %(data)s%(X)s" % { 'X': irc.X_DELIM, 'tag': tag, 'data': data } # ctcpQuote it to render it harmless, msg = irc.ctcpQuote(msg) # and let it continue on. 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: # A message in our errorGroup. 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) ### My methods as a Participant ### (Shortcuts for self.perspective.foo()) 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) ### My methods as a bogus perspective broker ### (Since I grab my perspective directly from the service, it hasn't ### been issued by a Perspective Broker.) 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')): # Not attached. 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() ### Participant event methods ## Words.Participant --> IRC 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'): # Damn. What am I supposed to do with this? message = "[%s] %s" % (metadata['style'], message) self.irc.msg(toNick, '<%s> %s' % (sender, message)) else: # If there is a CTCP delimeter at the beginning of the # message, let's leave it there to accomidate not-so- # tolerant clients. 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,)) # IRC Participant Management 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. """ # XXX: This marks another place where we get a little # overly cozy with the words service. 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] # TODO: let IRC users authorize themselves and then give them a # *real* perspective (one attached to their identity) instead # of one of my @networkSuffix-Nobody perspectives. 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) # p.attached(c, 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 # XXX: This must change if we ever start giving people 'real' # perspectives! 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. """ # Normalize case and trim leading '#' groupName = string.lower(channelName[1:]) return groupName def groupToChannelName(groupName): # Don't add a "#" here, because we do so in the outgoing IRC methods. channelName = groupName return channelName