# -*- test-case-name: twisted.test.test_words -*-
# 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
"""
Twisted Words Service objects. Chat and messaging for Twisted.
Twisted words is a general-purpose chat and instant messaging system designed
to be a suitable replacement both for Instant Messenger systems and
conferencing systems like IRC.
Currently it provides presence notification, web-based account creation, and a
simple group-chat abstraction.
Stability: incendiary
Maintainer: Maintainer: U{Glyph Lefkowitz<mailto:glyph@twistedmatrix.com>}
Future Plans: Woah boy. This module is incredibly unstable. It has an
incredible deficiency of features. There are also several features which are
pretty controvertial. As far as stability goes, it is lucky that the current
interfaces are really simple: at least the uppermost external ones will almost
certainly be preserved, but there is a lot of plumbing work.
First of all the fact that users must have accounts generated through a web
interface to sign in is a serious annoyance, especially to people who are
familiar with IRC's semantics. The following features are proposed to
mitigate this annoyance:
- account creation through the various client interfaces available to Words
users.
- guest accounts, so that users who join for an hour once don't pollute the
authentication database with huge amounts of cruft.
- 'mood' metadata for users. Since you can't change nicks, you need a way to
do the equivalent thing on IRC where people will sign in multiple times and
have foo_work and foo_home
There is no plan to make it possible to log-in without an account. This is
simply a broken behavior of IRC; all possible convenience features that mimic
this should be integrated, but authentication is an important part of chat.
There are also certain things that are just missing.
- restricted group operations. Typical IRC-style stuff, except you don't
ever see the @. Permimssions should be grantable in a capability style,
rather than with a single bit.
- server-to-server communication. As much as possible this should be
decentralized and not have the notion of 'hub' servers; rooms have
'physical' locality. This is really hard to integrate with IRC client
protocol stuff, so it may end up that this feature requires a rewrite of
Twisted Words so that servers that present an IRC gateway are treated as
leaf nodes, and the recommended mode of operation is for the user to run a
lightweight proxy locally.
- a serious logging, monitoring, and routing framework
Then there's a whole bunch of things that would be nice to have.
- public key authentication
- robust wire-level security
- integrated consensus web authoring tools
- management tools and guidelines for community leaders
- interface to operator functionality through 'bot' interface with
per-channel personality configuration
- graphical extensions to clients to allow formatted text (but detect
obviously annoying or abusive formatting)
- rate limiting, simple DoS protection, firewall integration
- basically everything OPN wants to be able to do, but better
"""
# System Imports
import types, time
# Twisted Imports
from twisted.spread import pb
from twisted.python import log, roots, components
from twisted.persisted import styles
from twisted import copyright
from twisted.cred import authorizer
# Status "enumeration"
OFFLINE = 0
ONLINE = 1
AWAY = 2
statuses = ["Offline","Online","Away"]
class WordsError(pb.Error, KeyError):
pass
class NotInCollectionError(WordsError):
pass
class NotInGroupError(NotInCollectionError):
def __init__(self, groupName, pName=None):
WordsError.__init__(self, groupName, pName)
self.group = groupName
self.pName = pName
def __str__(self):
if self.pName:
pName = "'%s' is" % (self.pName,)
else:
pName = "You are"
s = ("%s not in group '%s'." % (pName, self.group))
return s
class UserNonexistantError(NotInCollectionError):
def __init__(self, pName):
WordsError.__init__(self, pName)
self.pName = pName
def __str__(self):
return "'%s' does not exist." % (self.pName,)
class WrongStatusError(WordsError):
def __init__(self, status, pName=None):
WordsError.__init__(self, status, pName)
self.status = status
self.pName = pName
def __str__(self):
if self.pName:
pName = "'%s'" % (self.pName,)
else:
pName = "User"
if self.status in statuses:
status = self.status
else:
status = 'unknown? (%s)' % self.status
s = ("%s status is '%s'." % (pName, status))
return s
class IWordsClient(components.Interface):
"""A client to a perspective on the twisted.words service.
I attach to that participant with Participant.attached(),
and detatch with Participant.detached().
"""
def receiveContactList(self, contactList):
"""Receive a list of contacts and their status.
The list is composed of 2-tuples, of the form
(contactName, contactStatus)
"""
def notifyStatusChanged(self, name, status):
"""Notify me of a change in status of one of my contacts.
"""
def receiveGroupMembers(self, names, group):
"""Receive a list of members in a group.
'names' is a list of participant names in the group named 'group'.
"""
def setGroupMetadata(self, metadata, name):
"""Some metadata on a group has been set.
XXX: Should this be receiveGroupMetadata(name, metedata)?
"""
def receiveDirectMessage(self, sender, message, metadata=None):
"""Receive a message from someone named 'sender'.
'metadata' is a dict of special flags. So far 'style': 'emote'
is defined. Note that 'metadata' *must* be optional.
"""
def receiveGroupMessage(self, sender, group, message, metadata=None):
"""Receive a message from 'sender' directed to a group.
'metadata' is a dict of special flags. So far 'style': 'emote'
is defined. Note that 'metadata' *must* be optional.
"""
def memberJoined(self, member, group):
"""Tells me a member has joined a group.
"""
def memberLeft(self, member, group):
"""Tells me a member has left a group.
"""
class WordsClient:
__implements__ = IWordsClient
"""A stubbed version of L{IWordsClient}.
Useful for partial implementations.
"""
def receiveContactList(self, contactList): pass
def notifyStatusChanged(self, name, status): pass
def receiveGroupMembers(self, names, group): pass
def setGroupMetadata(self, metadata, name): pass
def receiveDirectMessage(self, sender, message, metadata=None): pass
def receiveGroupMessage(self, sender, group, message, metadata=None): pass
def memberJoined(self, member, group): pass
def memberLeft(self, member, group): pass
class Transcript:
"""I am a transcript of a conversation between multiple parties.
"""
def __init__(self, voice, name):
self.chat = []
self.voice = voice
self.name = name
def logMessage(self, voiceName, message, metadata):
self.chat.append((time.time(), voiceName, message, metadata))
def endTranscript(self):
self.voice.stopTranscribing(self.name)
class IWordsPolicy(components.Interface):
def getNameFor(self, participant):
"""Give a name for a participant, based on the current policy."""
def lookUpParticipant(self, nick):
""" Get a Participant, given a name."""
class NormalPolicy:
__implements__ = IWordsPolicy
def __init__(self, participant):
self.participant = participant
def getNameFor(self, participant):
return participant.name
def lookUpParticipant(self, nick):
return self.participant.service.getPerspectiveNamed(nick)
class Participant(pb.Perspective, styles.Versioned):
def __init__(self, name):
pb.Perspective.__init__(self, name)
self.name = name
self.status = OFFLINE
self.contacts = []
self.reverseContacts = []
self.groups = []
self.client = None
self.loggedNames = {}
self.policy = NormalPolicy(self)
persistenceVersion = 2
def upgradeToVersion2(self):
self.loggedNames = {}
def __getstate__(self):
state = styles.Versioned.__getstate__(self)
# Assumptions:
# * self.client is a RemoteReference, or otherwise represents
# a transient presence.
if isinstance(state["client"], styles.Ephemeral):
state["client"] = None
# * Because we have no client, we are not online.
state["status"] = OFFLINE
# * Because we are not online, we are in no groups.
state["groups"] = []
return state
def attached(self, client, identity):
"""Attach a client which implements L{IWordsClient} to me.
"""
if ((self.client is not None)
and self.client.__class__ != styles.Ephemeral):
self.detached(client, identity)
log.msg("attached: %s" % self.name)
self.client = client
client.callRemote('receiveContactList', map(lambda contact: (contact.name,
contact.status),
self.contacts))
self.changeStatus(ONLINE)
return self
def transcribeConversationWith(self, voiceName):
t = Transcript(self, voiceName)
self.loggedNames[voiceName] = t
return t
def stopTranscribing(self, voiceName):
del self.loggedNames[voiceName]
def changeStatus(self, newStatus):
self.status = newStatus
for contact in self.reverseContacts:
contact.notifyStatusChanged(self)
def notifyStatusChanged(self, contact):
if self.client:
self.client.callRemote('notifyStatusChanged', contact.name, contact.status)
def detached(self, client, identity):
log.msg("detached: %s" % self.name)
self.client = None
for group in self.groups[:]:
try:
self.leaveGroup(group.name)
except NotInGroupError:
pass
self.changeStatus(OFFLINE)
def addContact(self, contactName):
# XXX This should use a database or something. Doing it synchronously
# like this won't work.
contact = self.service.getPerspectiveNamed(contactName)
self.contacts.append(contact)
contact.reverseContacts.append(self)
self.notifyStatusChanged(contact)
def removeContact(self, contactName):
for contact in self.contacts:
if contact.name == contactName:
self.contacts.remove(contact)
contact.reverseContacts.remove(self)
return
raise NotInCollectionError("No such contact '%s'."
% (contactName,))
def joinGroup(self, name):
group = self.service.getGroup(name)
if group in self.groups:
# We're in that group. Don't make a fuss.
return
group.addMember(self)
self.groups.append(group)
def leaveGroup(self, name):
for group in self.groups:
if group.name == name:
self.groups.remove(group)
group.removeMember(self)
return
raise NotInGroupError(name)
def getGroupMembers(self, groupName):
if self.client:
for group in self.groups:
if group.name == groupName:
self.client.callRemote('receiveGroupMembers',
map(lambda m: m.name,
group.members),
group.name)
return
raise NotInGroupError(groupName)
def getGroupMetadata(self, groupName):
if self.client:
for group in self.groups:
if group.name == groupName:
self.client.callRemote('setGroupMetadata', group.metadata, group.name)
def receiveDirectMessage(self, sender, message, metadata):
if self.client:
# is this wrong?
# nick = self.policy.getNameFor(sender)
nick = sender.name
if self.loggedNames.has_key(nick):
self.loggedNames[nick].logMessage(sender.name, message,
metadata)
self.client.callRemote('receiveDirectMessage', nick,
message, metadata)
else:
raise WrongStatusError(self.status, self.name)
def receiveGroupMessage(self, sender, group, message, metadata):
if sender is not self and self.client:
self.client.callRemote('receiveGroupMessage',sender.name, group.name,
message, metadata)
def memberJoined(self, member, group):
if self.client:
self.client.callRemote('memberJoined', member.name, group.name)
def memberLeft(self, member, group):
if self.client:
self.client.callRemote('memberLeft', member.name, group.name)
def directMessage(self, recipientName, message, metadata=None):
recipient = self.policy.lookUpParticipant(recipientName)
recipient.receiveDirectMessage(self, message, metadata or {})
if self.loggedNames.has_key(recipientName):
self.loggedNames[recipientName].logMessage(self.name, message, metadata)
def groupMessage(self, groupName, message, metadata=None):
for group in self.groups:
if group.name == groupName:
group.sendMessage(self, message, metadata or {})
return
raise NotInGroupError(groupName)
def setGroupMetadata(self, dict_, groupName):
if self.client:
self.client.callRemote('setGroupMetadata', dict_, groupName)
def perspective_setGroupMetadata(self, dict_, groupName):
#pre-processing
if dict_.has_key('topic'):
#don't want topic-spoofing, now
dict_["topic_author"] = self.name
for group in self.groups:
if group.name == groupName:
group.setMetadata(dict_)
# Establish client protocol for PB.
perspective_changeStatus = changeStatus
perspective_joinGroup = joinGroup
perspective_directMessage = directMessage
perspective_addContact = addContact
perspective_removeContact = removeContact
perspective_groupMessage = groupMessage
perspective_leaveGroup = leaveGroup
perspective_getGroupMembers = getGroupMembers
def __repr__(self):
if self.identityName != "Nobody":
id_s = '(id:%s)' % (self.identityName, )
else:
id_s = ''
s = ("<%s '%s'%s on %s at %x>"
% (self.__class__, self.name, id_s,
self.service.serviceName, id(self)))
return s
class Group(styles.Versioned):
"""
This class represents a group of people engaged in a chat session
with one another.
@type name: C{string}
@ivar name: The name of the group
@type members: C{list}
@ivar members: The members of the group
@type metadata: C{dictionary}
@ivar metadata: Metadata that describes the group. Common
keys are:
- C{'topic'}: The topic string for the group.
- C{'topic_author'}: The name of the user who
last set the topic.
"""
def __init__(self, name):
self.name = name
self.members = []
self.metadata = {'topic': 'Welcome to %s!' % self.name,
'topic_author': 'admin'}
def __getstate__(self):
state = styles.Versioned.__getstate__(self)
state['members'] = []
return state
def addMember(self, participant):
if participant in self.members:
return
for member in self.members:
member.memberJoined(participant, self)
participant.setGroupMetadata(self.metadata, self.name)
self.members.append(participant)
def removeMember(self, participant):
try:
self.members.remove(participant)
except ValueError:
raise NotInGroupError(self.name, participant.name)
else:
for member in self.members:
member.memberLeft(participant, self)
def sendMessage(self, sender, message, metadata):
for member in self.members:
member.receiveGroupMessage(sender, self, message, metadata)
def setMetadata(self, dict_):
self.metadata.update(dict_)
for member in self.members:
member.setGroupMetadata(dict_, self.name)
def __repr__(self):
s = "<%s '%s' at %x>" % (self.__class__, self.name, id(self))
return s
##Persistence Versioning
persistenceVersion = 1
def upgradeToVersion1(self):
self.metadata = {'topic': self.topic}
del self.topic
self.metadata['topic_author'] = 'admin'
class Service(pb.Service, styles.Versioned):
"""I am a chat service.
"""
perspectiveClass = Participant
def __init__(self, name, parent=None, auth=None):
pb.Service.__init__(self, name, parent, auth)
self.groups = {}
self.bots = []
## Persistence versioning.
persistenceVersion = 4
def upgradeToVersion1(self):
from twisted.internet.app import theApplication
styles.requireUpgrade(theApplication)
pb.Service.__init__(self, 'twisted.words', theApplication)
def upgradeToVersion3(self):
self.perspectives = self.participants
del self.participants
def upgradeToVersion4(self):
self.bots = []
## Service functionality.
def getGroup(self, name):
group = self.groups.get(name)
if not group:
group = Group(name)
self.groups[name] = group
return group
def createPerspective(self, name):
if self.perspectives.has_key(name):
raise KeyError("Participant already exists: %s." % name)
log.msg("Creating New Participant: %s" % name)
return pb.Service.createPerspective(self, name)
def getPerspectiveNamed(self, name):
try:
return pb.Service.getPerspectiveNamed(self, name)
except KeyError:
raise UserNonexistantError(name)
def addBot(self, name, bot):
try:
p = self.getPerspectiveNamed(name)
except UserNonexistantError:
p = self.createPerspective(name)
bot.setupBot(p) # XXX this method needs a better name
from twisted.spread.util import LocalAsyncForwarder
p.attached(LocalAsyncForwarder(bot, IWordsClient, 1), None)
self.bots.append(bot)
def deleteBot(self, bot):
bot.voice.detached(bot, None)
self.bots.remove(bot)
del self.perspectives[bot.voice.perspectiveName]
createParticipant = createPerspective
def __str__(self):
s = "<%s in app '%s' at %x>" % (self.serviceName,
self.application.name,
id(self))
return s
syntax highlighted by Code2HTML, v. 0.9.1