Add support for command prefixes
[jabberbot/examples.git] / jabberbot.py
blob558174b0419a898663bff9b522eb636a07f18de8
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
4 # JabberBot: A simple jabber/xmpp bot framework
5 # Copyright (c) 2007-2011 Thomas Perl <thp.io/about>
6 # $Id$
8 # This program is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
22 """
23 A framework for writing Jabber/XMPP bots and services
25 The JabberBot framework allows you to easily write bots
26 that use the XMPP protocol. You can create commands by
27 decorating functions in your subclass or customize the
28 bot's operation completely. MUCs are also supported.
29 """
31 import os
32 import re
33 import sys
34 import thread
36 try:
37 import xmpp
38 except ImportError:
39 print >> sys.stderr, """
40 You need to install xmpppy from http://xmpppy.sf.net/.
41 On Debian-based systems, install the python-xmpp package.
42 """
43 sys.exit(-1)
45 import time
46 import inspect
47 import logging
48 import traceback
50 # Will be parsed by setup.py to determine package metadata
51 __author__ = 'Thomas Perl <m@thp.io>'
52 __version__ = '0.14'
53 __website__ = 'http://thp.io/2007/python-jabberbot/'
54 __license__ = 'GNU General Public License version 3 or later'
56 def botcmd(*args, **kwargs):
57 """Decorator for bot command functions"""
59 def decorate(func, hidden=False, name=None, thread=False):
60 setattr(func, '_jabberbot_command', True)
61 setattr(func, '_jabberbot_command_hidden', hidden)
62 setattr(func, '_jabberbot_command_name', name or func.__name__)
63 setattr(func, '_jabberbot_command_thread', thread) # Experimental!
64 return func
66 if len(args):
67 return decorate(args[0], **kwargs)
68 else:
69 return lambda func: decorate(func, **kwargs)
72 class JabberBot(object):
73 # Show types for presence
74 AVAILABLE, AWAY, CHAT = None, 'away', 'chat'
75 DND, XA, OFFLINE = 'dnd', 'xa', 'unavailable'
77 # UI-messages (overwrite to change content)
78 MSG_AUTHORIZE_ME = 'Hey there. You are not yet on my roster. ' \
79 'Authorize my request and I will do the same.'
80 MSG_NOT_AUTHORIZED = 'You did not authorize my subscription request. '\
81 'Access denied.'
82 MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". '\
83 'Type "%(helpcommand)s" for available commands.'
84 MSG_HELP_TAIL = 'Type %(helpcommand)s <command name> to get more info '\
85 'about that specific command.'
86 MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.'
87 MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. '\
88 'An unexpected error occurred.'
90 PING_FREQUENCY = 0 # Set to the number of seconds, e.g. 60.
91 PING_TIMEOUT = 2 # Seconds to wait for a response.
93 def __init__(self, username, password, res=None, debug=False,
94 privatedomain=False, acceptownmsgs=False, handlers=None,
95 command_prefix=''):
96 """Initializes the jabber bot and sets up commands.
98 username and password should be clear ;)
100 If res provided, res will be ressourcename,
101 otherwise it defaults to classname of childclass
103 If debug is True log messages of xmpppy will be printed to console.
104 Logging of Jabberbot itself is NOT affected.
106 If privatedomain is provided, it should be either
107 True to only allow subscriptions from the same domain
108 as the bot or a string that describes the domain for
109 which subscriptions are accepted (e.g. 'jabber.org').
111 If acceptownmsgs it set to True, this bot will accept
112 messages from the same JID that the bot itself has. This
113 is useful when using JabberBot with a single Jabber account
114 and multiple instances that want to talk to each other.
116 If handlers are provided, default handlers won't be enabled.
117 Usage like: [('stanzatype1', function1), ('stanzatype2', function2)]
118 Signature of function should be callback_xx(self, conn, stanza),
119 where conn is the connection and stanza the current stanza in process.
120 First handler in list will be served first.
121 Don't forget to raise exception xmpp.NodeProcessed to stop
122 processing in other handlers (see callback_presence)
124 If command_prefix is set to a string different from '' (the empty
125 string), it will require the commands to be prefixed with this text,
126 e.g. command_prefix = '!' means: Type "!info" for the "info" command.
128 # TODO sort this initialisation thematically
129 self.__debug = debug
130 self.log = logging.getLogger(__name__)
131 self.__username = username
132 self.__password = password
133 self.jid = xmpp.JID(self.__username)
134 self.res = (res or self.__class__.__name__)
135 self.conn = None
136 self.__finished = False
137 self.__show = None
138 self.__status = None
139 self.__seen = {}
140 self.__threads = {}
141 self.__lastping = time.time()
142 self.__privatedomain = privatedomain
143 self.__acceptownmsgs = acceptownmsgs
144 self.__command_prefix = command_prefix
146 self.handlers = (handlers or [('message', self.callback_message),
147 ('presence', self.callback_presence)])
149 # Collect commands from source
150 self.commands = {}
151 for name, value in inspect.getmembers(self, inspect.ismethod):
152 if getattr(value, '_jabberbot_command', False):
153 name = getattr(value, '_jabberbot_command_name')
154 self.log.info('Registered command: %s' % name)
155 self.commands[self.__command_prefix + name] = value
157 self.roster = None
159 ################################
161 def _send_status(self):
162 """Send status to everyone"""
163 self.conn.send(xmpp.dispatcher.Presence(show=self.__show,
164 status=self.__status))
166 def __set_status(self, value):
167 """Set status message.
168 If value remains constant, no presence stanza will be send"""
169 if self.__status != value:
170 self.__status = value
171 self._send_status()
173 def __get_status(self):
174 """Get current status message"""
175 return self.__status
177 status_message = property(fget=__get_status, fset=__set_status)
179 def __set_show(self, value):
180 """Set show (status type like AWAY, DND etc.).
181 If value remains constant, no presence stanza will be send"""
182 if self.__show != value:
183 self.__show = value
184 self._send_status()
186 def __get_show(self):
187 """Get current show (status type like AWAY, DND etc.)."""
188 return self.__show
190 status_type = property(fget=__get_show, fset=__set_show)
192 ################################
194 def connect(self):
195 """Connects the bot to server or returns current connection,
196 send inital presence stanza
197 and registers handlers
199 if not self.conn:
200 #TODO improve debug
201 if self.__debug:
202 conn = xmpp.Client(self.jid.getDomain())
203 else:
204 conn = xmpp.Client(self.jid.getDomain(), debug=[])
206 #connection attempt
207 conres = conn.connect()
208 if not conres:
209 self.log.error('unable to connect to server %s.' %
210 self.jid.getDomain())
211 return None
212 if conres != 'tls':
213 self.log.warning('unable to establish secure connection '\
214 '- TLS failed!')
216 authres = conn.auth(self.jid.getNode(), self.__password, self.res)
217 if not authres:
218 self.log.error('unable to authorize with server.')
219 return None
220 if authres != 'sasl':
221 self.log.warning("unable to perform SASL auth on %s. "\
222 "Old authentication method used!" % self.jid.getDomain())
224 # Connection established - save connection
225 self.conn = conn
227 # Send initial presence stanza (say hello to everyone)
228 self.conn.sendInitPresence()
229 # Save roster and log Items
230 self.roster = self.conn.Roster.getRoster()
231 self.log.info('*** roster ***')
232 for contact in self.roster.getItems():
233 self.log.info(' %s' % contact)
234 self.log.info('*** roster ***')
236 # Register given handlers (TODO move to own function)
237 for (handler, callback) in self.handlers:
238 self.conn.RegisterHandler(handler, callback)
239 self.log.debug('Registered handler: %s' % handler)
241 return self.conn
243 def join_room(self, room, username=None, password=None):
244 """Join the specified multi-user chat room
246 If username is NOT provided fallback to node part of JID"""
247 # TODO fix namespacestrings and history settings
248 NS_MUC = 'http://jabber.org/protocol/muc'
249 if username is None:
250 # TODO use xmpppy function getNode
251 username = self.__username.split('@')[0]
252 my_room_JID = '/'.join((room, username))
253 pres = xmpp.Presence(to=my_room_JID)
254 if password is not None:
255 pres.setTag('x',namespace=NS_MUC).setTagData('password',password)
256 self.connect().send(pres)
258 def kick(self, room, nick, reason=None):
259 """Kicks user from muc
260 Works only with sufficient rights."""
261 NS_MUCADMIN = 'http://jabber.org/protocol/muc#admin'
262 item = xmpp.simplexml.Node('item')
263 item.setAttr('nick', nick)
264 item.setAttr('role', 'none')
265 iq = xmpp.Iq(typ='set',queryNS=NS_MUCADMIN,xmlns=None,to=room,payload=set([item]))
266 if reason is not None:
267 item.setTagData('reason',reason)
268 self.connect().send(iq)
270 def invite(self, room, jid, reason=None):
271 """Invites user to muc.
272 Works only if user has permission to invite to muc"""
273 NS_MUCUSER = 'http://jabber.org/protocol/muc#user'
274 invite = xmpp.simplexml.Node('invite')
275 invite.setAttr('to',jid)
276 if reason is not None:
277 invite.setTagData('reason',reason)
278 mess = xmpp.Message(to=room)
279 mess.setTag('x',namespace=NS_MUCUSER).addChild(node=invite)
280 self.log.error(mess)
281 self.connect().send(mess)
283 def quit(self):
284 """Stop serving messages and exit.
286 I find it is handy for development to run the
287 jabberbot in a 'while true' loop in the shell, so
288 whenever I make a code change to the bot, I send
289 the 'reload' command, which I have mapped to call
290 self.quit(), and my shell script relaunches the
291 new version.
293 self.__finished = True
295 def send_message(self, mess):
296 """Send an XMPP message"""
297 self.connect().send(mess)
299 def send_tune(self, song, debug=False):
300 """Set information about the currently played tune
302 Song is a dictionary with keys: file, title, artist, album, pos, track,
303 length, uri. For details see <http://xmpp.org/protocols/tune/>.
305 NS_TUNE = 'http://jabber.org/protocol/tune'
306 iq = xmpp.Iq(typ='set')
307 iq.setFrom(self.jid)
308 iq.pubsub = iq.addChild('pubsub', namespace=xmpp.NS_PUBSUB)
309 iq.pubsub.publish = iq.pubsub.addChild('publish',
310 attrs={ 'node' : NS_TUNE })
311 iq.pubsub.publish.item = iq.pubsub.publish.addChild('item',
312 attrs={ 'id' : 'current' })
313 tune = iq.pubsub.publish.item.addChild('tune')
314 tune.setNamespace(NS_TUNE)
316 title = None
317 if song.has_key('title'):
318 title = song['title']
319 elif song.has_key('file'):
320 title = os.path.splitext(os.path.basename(song['file']))[0]
321 if title is not None:
322 tune.addChild('title').addData(title)
323 if song.has_key('artist'):
324 tune.addChild('artist').addData(song['artist'])
325 if song.has_key('album'):
326 tune.addChild('source').addData(song['album'])
327 if song.has_key('pos') and song['pos'] > 0:
328 tune.addChild('track').addData(str(song['pos']))
329 if song.has_key('time'):
330 tune.addChild('length').addData(str(song['time']))
331 if song.has_key('uri'):
332 tune.addChild('uri').addData(song['uri'])
334 if debug:
335 self.log.info('Sending tune: %s' % iq.__str__().encode('utf8'))
336 self.conn.send(iq)
338 def send(self, user, text, in_reply_to=None, message_type='chat'):
339 """Sends a simple message to the specified user."""
340 mess = self.build_message(text)
341 mess.setTo(user)
343 if in_reply_to:
344 mess.setThread(in_reply_to.getThread())
345 mess.setType(in_reply_to.getType())
346 else:
347 mess.setThread(self.__threads.get(user, None))
348 mess.setType(message_type)
350 self.send_message(mess)
352 def send_simple_reply(self, mess, text, private=False):
353 """Send a simple response to a message"""
354 self.send_message(self.build_reply(mess, text, private))
356 def build_reply(self, mess, text=None, private=False):
357 """Build a message for responding to another message.
358 Message is NOT sent"""
359 response = self.build_message(text)
360 if private:
361 response.setTo(mess.getFrom())
362 response.setType('chat')
363 else:
364 response.setTo(mess.getFrom().getStripped())
365 response.setType(mess.getType())
366 response.setThread(mess.getThread())
367 return response
369 def build_message(self, text):
370 """Builds an xhtml message without attributes.
371 If input is not valid xhtml-im fallback to normal."""
372 message = None # init message variable
373 # Try to determine if text has xhtml-tags - TODO needs improvement
374 text_plain = re.sub(r'<[^>]+>', '', text)
375 if text_plain != text:
376 # Create body w stripped tags for reciptiens w/o xhtml-abilities
377 # FIXME unescape &quot; etc.
378 message = xmpp.protocol.Message(body=text_plain)
379 # Start creating a xhtml body
380 html = xmpp.Node('html', \
381 {'xmlns': 'http://jabber.org/protocol/xhtml-im'})
382 try:
383 html.addChild(node=xmpp.simplexml.XML2Node( \
384 "<body xmlns='http://www.w3.org/1999/xhtml'>" + \
385 text.encode('utf-8') + "</body>"))
386 message.addChild(node=html)
387 except Exception, e:
388 # Didn't work, incorrect markup or something.
389 self.log.debug('An error while building a xhtml message. '\
390 'Fallback to normal messagebody')
391 # Fallback - don't sanitize invalid input. User is responsible!
392 message = None
393 if message is None:
394 # Normal body
395 message = xmpp.protocol.Message(body=text)
396 return message
398 def get_sender_username(self, mess):
399 """Extract the sender's user name from a message"""
400 type = mess.getType()
401 jid = mess.getFrom()
402 if type == "groupchat":
403 username = jid.getResource()
404 elif type == "chat":
405 username = jid.getNode()
406 else:
407 username = ""
408 return username
410 def get_full_jids(self, jid):
411 """Returns all full jids, which belong to a bare jid
413 Example: A bare jid is bob@jabber.org, with two clients connected,
414 which
415 have the full jids bob@jabber.org/home and bob@jabber.org/work."""
416 for res in self.roster.getResources(jid):
417 full_jid = "%s/%s" % (jid,res)
418 yield full_jid
420 def status_type_changed(self, jid, new_status_type):
421 """Callback for tracking status types (dnd, away, offline, ...)"""
422 self.log.debug('user %s changed status to %s' % (jid, new_status_type))
424 def status_message_changed(self, jid, new_status_message):
425 """Callback for tracking status messages (the free-form status text)"""
426 self.log.debug('user %s updated text to %s' %
427 (jid, new_status_message))
429 def broadcast(self, message, only_available=False):
430 """Broadcast a message to all users 'seen' by this bot.
432 If the parameter 'only_available' is True, the broadcast
433 will not go to users whose status is not 'Available'."""
434 for jid, (show, status) in self.__seen.items():
435 if not only_available or show is self.AVAILABLE:
436 self.send(jid, message)
438 def callback_presence(self, conn, presence):
439 jid, type_, show, status = presence.getFrom(), \
440 presence.getType(), presence.getShow(), \
441 presence.getStatus()
443 if self.jid.bareMatch(jid):
444 # update internal status
445 if type_ != self.OFFLINE:
446 self.__status = status
447 self.__show = show
448 else:
449 self.__status = ""
450 self.__show = self.OFFLINE
451 if not self.__acceptownmsgs:
452 # Ignore our own presence messages
453 return
455 if type_ is None:
456 # Keep track of status message and type changes
457 old_show, old_status = self.__seen.get(jid, (self.OFFLINE, None))
458 if old_show != show:
459 self.status_type_changed(jid, show)
461 if old_status != status:
462 self.status_message_changed(jid, status)
464 self.__seen[jid] = (show, status)
465 elif type_ == self.OFFLINE and jid in self.__seen:
466 # Notify of user offline status change
467 del self.__seen[jid]
468 self.status_type_changed(jid, self.OFFLINE)
470 try:
471 subscription = self.roster.getSubscription(unicode(jid.__str__()))
472 except KeyError, e:
473 # User not on our roster
474 subscription = None
475 except AttributeError, e:
476 # Recieved presence update before roster built
477 return
479 if type_ == 'error':
480 self.log.error(presence.getError())
482 self.log.debug('Got presence: %s (type: %s, show: %s, status: %s, '\
483 'subscription: %s)' % (jid, type_, show, status, subscription))
485 # If subscription is private,
486 # disregard anything not from the private domain
487 if self.__privatedomain and type_ in ('subscribe', 'subscribed', \
488 'unsubscribe'):
489 if self.__privatedomain == True:
490 # Use the bot's domain
491 domain = self.jid.getDomain()
492 else:
493 # Use the specified domain
494 domain = self.__privatedomain
496 # Check if the sender is in the private domain
497 user_domain = jid.getDomain()
498 if domain != user_domain:
499 self.log.info('Ignoring subscribe request: %s does not '\
500 'match private domain (%s)' % (user_domain, domain))
501 return
503 if type_ == 'subscribe':
504 # Incoming presence subscription request
505 if subscription in ('to', 'both', 'from'):
506 self.roster.Authorize(jid)
507 self._send_status()
509 if subscription not in ('to', 'both'):
510 self.roster.Subscribe(jid)
512 if subscription in (None, 'none'):
513 self.send(jid, self.MSG_AUTHORIZE_ME)
514 elif type_ == 'subscribed':
515 # Authorize any pending requests for that JID
516 self.roster.Authorize(jid)
517 elif type_ == 'unsubscribed':
518 # Authorization was not granted
519 self.send(jid, self.MSG_NOT_AUTHORIZED)
520 self.roster.Unauthorize(jid)
522 def callback_message(self, conn, mess):
523 """Messages sent to the bot will arrive here.
524 Command handling + routing is done in this function."""
526 # Prepare to handle either private chats or group chats
527 type = mess.getType()
528 jid = mess.getFrom()
529 props = mess.getProperties()
530 text = mess.getBody()
531 username = self.get_sender_username(mess)
533 if type not in ("groupchat", "chat"):
534 self.log.debug("unhandled message type: %s" % type)
535 return
537 # Ignore messages from before we joined
538 if xmpp.NS_DELAY in props: return
540 # Ignore messages from myself
541 if self.jid.bareMatch(jid): return
543 self.log.debug("*** props = %s" % props)
544 self.log.debug("*** jid = %s" % jid)
545 self.log.debug("*** username = %s" % username)
546 self.log.debug("*** type = %s" % type)
547 self.log.debug("*** text = %s" % text)
549 # If a message format is not supported (eg. encrypted),
550 # txt will be None
551 if not text: return
553 # Ignore messages from users not seen by this bot
554 if jid not in self.__seen:
555 self.log.info('Ignoring message from unseen guest: %s' % jid)
556 self.log.debug("I've seen: %s" %
557 ["%s" % x for x in self.__seen.keys()])
558 return
560 # Remember the last-talked-in message thread for replies
561 # FIXME i am not threadsafe
562 self.__threads[jid] = mess.getThread()
564 if ' ' in text:
565 command, args = text.split(' ', 1)
566 else:
567 command, args = text, ''
568 cmd = command.lower()
569 self.log.debug("*** cmd = %s" % cmd)
571 if self.commands.has_key(cmd):
572 def execute_and_send():
573 try:
574 reply = self.commands[cmd](mess, args)
575 except Exception, e:
576 self.log.exception('An error happened while processing '\
577 'a message ("%s") from %s: %s"' %
578 (text, jid, traceback.format_exc(e)))
579 reply = self.MSG_ERROR_OCCURRED
580 if reply:
581 self.send_simple_reply(mess, reply)
582 # Experimental!
583 # if command should be executed in a seperate thread do it
584 if self.commands[cmd]._jabberbot_command_thread:
585 thread.start_new_thread(execute_and_send, ())
586 else:
587 execute_and_send()
588 else:
589 # In private chat, it's okay for the bot to always respond.
590 # In group chat, the bot should silently ignore commands it
591 # doesn't understand or aren't handled by unknown_command().
592 if type == 'groupchat':
593 default_reply = None
594 else:
595 default_reply = self.MSG_UNKNOWN_COMMAND % {
596 'command': cmd,
597 'helpcommand': self.__command_prefix + 'help',
599 reply = self.unknown_command(mess, cmd, args)
600 if reply is None:
601 reply = default_reply
602 if reply:
603 self.send_simple_reply(mess, reply)
605 def unknown_command(self, mess, cmd, args):
606 """Default handler for unknown commands
608 Override this method in derived class if you
609 want to trap some unrecognized commands. If
610 'cmd' is handled, you must return some non-false
611 value, else some helpful text will be sent back
612 to the sender.
614 return None
616 def top_of_help_message(self):
617 """Returns a string that forms the top of the help message
619 Override this method in derived class if you
620 want to add additional help text at the
621 beginning of the help message.
623 return ""
625 def bottom_of_help_message(self):
626 """Returns a string that forms the bottom of the help message
628 Override this method in derived class if you
629 want to add additional help text at the end
630 of the help message.
632 return ""
634 @botcmd
635 def help(self, mess, args):
636 """ Returns a help string listing available options.
638 Automatically assigned to the "help" command."""
639 if not args:
640 if self.__doc__:
641 description = self.__doc__.strip()
642 else:
643 description = 'Available commands:'
645 usage = '\n'.join(sorted([
646 '%s: %s' % (name, (command.__doc__ or \
647 '(undocumented)').strip().split('\n', 1)[0])
648 for (name, command) in self.commands.iteritems() \
649 if name != (self.__command_prefix + 'help') \
650 and not command._jabberbot_command_hidden
652 usage = '\n\n' + '\n\n'.join(filter(None, [usage, self.MSG_HELP_TAIL % {'helpcommand': self.__command_prefix + 'help'}]))
653 else:
654 description = ''
655 if (args not in self.commands and
656 (self.__command_prefix + args) in self.commands):
657 # Automatically add prefix if it's missing
658 args = self.__command_prefix + args
659 if args in self.commands:
660 usage = (self.commands[args].__doc__ or \
661 'undocumented').strip()
662 else:
663 usage = self.MSG_HELP_UNDEFINED_COMMAND
665 top = self.top_of_help_message()
666 bottom = self.bottom_of_help_message()
667 return ''.join(filter(None, [top, description, usage, bottom]))
669 def idle_proc(self):
670 """This function will be called in the main loop."""
671 self._idle_ping()
673 def _idle_ping(self):
674 """Pings the server, calls on_ping_timeout() on no response.
676 To enable set self.PING_FREQUENCY to a value higher than zero.
678 if self.PING_FREQUENCY \
679 and time.time() - self.__lastping > self.PING_FREQUENCY:
680 self.__lastping = time.time()
681 #logging.debug('Pinging the server.')
682 ping = xmpp.Protocol('iq', typ='get', \
683 payload=[xmpp.Node('ping', attrs={'xmlns':'urn:xmpp:ping'})])
684 try:
685 res = self.conn.SendAndWaitForResponse(ping, self.PING_TIMEOUT)
686 #logging.debug('Got response: ' + str(res))
687 if res is None:
688 self.on_ping_timeout()
689 except IOError, e:
690 logging.error('Error pinging the server: %s, '\
691 'treating as ping timeout.' % e)
692 self.on_ping_timeout()
694 def on_ping_timeout(self):
695 logging.info('Terminating due to PING timeout.')
696 self.quit()
698 def shutdown(self):
699 """This function will be called when we're done serving
701 Override this method in derived class if you
702 want to do anything special at shutdown.
704 pass
706 def serve_forever(self, connect_callback=None, disconnect_callback=None):
707 """Connects to the server and handles messages."""
708 conn = self.connect()
709 if conn:
710 self.log.info('bot connected. serving forever.')
711 else:
712 self.log.warn('could not connect to server - aborting.')
713 return
715 if connect_callback:
716 connect_callback()
717 self.__lastping = time.time()
719 while not self.__finished:
720 try:
721 conn.Process(1)
722 self.idle_proc()
723 except KeyboardInterrupt:
724 self.log.info('bot stopped by user request. '\
725 'shutting down.')
726 break
728 self.shutdown()
730 if disconnect_callback:
731 disconnect_callback()
733 # vim: expandtab tabstop=4 shiftwidth=4 softtabstop=4