refactor the irc bot, in preparation for other IM status backends
[buildbot.git] / buildbot / status / words.py
blob0b776d30689a05496f100c99576039631af0adf4
1 #! /usr/bin/python
3 # code to deliver build status through twisted.words (instant messaging
4 # protocols: irc, etc)
6 import re, shlex
8 from zope.interface import Interface, implements
9 from twisted.internet import protocol, reactor
10 from twisted.words.protocols import irc
11 from twisted.python import log, failure
12 from twisted.application import internet
14 from buildbot import interfaces, util
15 from buildbot import version
16 from buildbot.sourcestamp import SourceStamp
17 from buildbot.process.base import BuildRequest
18 from buildbot.status import base
19 from buildbot.status.builder import SUCCESS, WARNINGS, FAILURE, EXCEPTION
20 from buildbot.scripts.runner import ForceOptions
22 class UsageError(ValueError):
23 def __init__(self, string = "Invalid usage", *more):
24 ValueError.__init__(self, string, *more)
26 class IrcBuildRequest:
27 hasStarted = False
28 timer = None
30 def __init__(self, parent):
31 self.parent = parent
32 self.timer = reactor.callLater(5, self.soon)
34 def soon(self):
35 del self.timer
36 if not self.hasStarted:
37 self.parent.send("The build has been queued, I'll give a shout"
38 " when it starts")
40 def started(self, c):
41 self.hasStarted = True
42 if self.timer:
43 self.timer.cancel()
44 del self.timer
45 s = c.getStatus()
46 eta = s.getETA()
47 response = "build #%d forced" % s.getNumber()
48 if eta is not None:
49 response = "build forced [ETA %s]" % self.parent.convertTime(eta)
50 self.parent.send(response)
51 self.parent.send("I'll give a shout when the build finishes")
52 d = s.waitUntilFinished()
53 d.addCallback(self.parent.buildFinished)
56 class Contact:
57 """I hold the state for a single user's interaction with the buildbot.
59 This base class provides all the basic behavior (the queries and
60 responses). Subclasses for each channel type (IRC, different IM
61 protocols) are expected to provide the lower-level send/receive methods.
63 There will be one instance of me for each user who interacts personally
64 with the buildbot. There will be an additional instance for each
65 'broadcast contact' (chat rooms, IRC channels as a whole).
66 """
68 def __init__(self, channel, username):
69 self.channel = channel
70 self.username = username
72 silly = {
73 "What happen ?": "Somebody set up us the bomb.",
74 "It's You !!": ["How are you gentlemen !!",
75 "All your base are belong to us.",
76 "You are on the way to destruction."],
77 "What you say !!": ["You have no chance to survive make your time.",
78 "HA HA HA HA ...."],
81 def getCommandMethod(self, command):
82 meth = getattr(self, 'command_' + command.upper(), None)
83 return meth
85 def getBuilder(self, which):
86 try:
87 b = self.channel.status.getBuilder(which)
88 except KeyError:
89 raise UsageError, "no such builder '%s'" % which
90 return b
92 def getControl(self, which):
93 if not self.channel.control:
94 raise UsageError("builder control is not enabled")
95 try:
96 bc = self.channel.control.getBuilder(which)
97 except KeyError:
98 raise UsageError("no such builder '%s'" % which)
99 return bc
101 def getAllBuilders(self):
103 @rtype: list of L{buildbot.process.builder.Builder}
105 names = self.channel.status.getBuilderNames(categories=self.categories)
106 names.sort()
107 builders = [self.channel.status.getBuilder(n) for n in names]
108 return builders
110 def convertTime(self, seconds):
111 if seconds < 60:
112 return "%d seconds" % seconds
113 minutes = int(seconds / 60)
114 seconds = seconds - 60*minutes
115 if minutes < 60:
116 return "%dm%02ds" % (minutes, seconds)
117 hours = int(minutes / 60)
118 minutes = minutes - 60*hours
119 return "%dh%02dm%02ds" % (hours, minutes, seconds)
121 def doSilly(self, message):
122 response = self.silly[message]
123 if type(response) != type([]):
124 response = [response]
125 when = 0.5
126 for r in response:
127 reactor.callLater(when, self.send, r)
128 when += 2.5
130 def command_HELLO(self, args):
131 self.send("yes?")
133 def command_VERSION(self, args):
134 self.send("buildbot-%s at your service" % version)
136 def command_LIST(self, args):
137 args = args.split()
138 if len(args) == 0:
139 raise UsageError, "try 'list builders'"
140 if args[0] == 'builders':
141 builders = self.getAllBuilders()
142 str = "Configured builders: "
143 for b in builders:
144 str += b.name
145 state = b.getState()[0]
146 if state == 'offline':
147 str += "[offline]"
148 str += " "
149 str.rstrip()
150 self.send(str)
151 return
152 command_LIST.usage = "list builders - List configured builders"
154 def command_STATUS(self, args):
155 args = args.split()
156 if len(args) == 0:
157 which = "all"
158 elif len(args) == 1:
159 which = args[0]
160 else:
161 raise UsageError, "try 'status <builder>'"
162 if which == "all":
163 builders = self.getAllBuilders()
164 for b in builders:
165 self.emit_status(b.name)
166 return
167 self.emit_status(which)
168 command_STATUS.usage = "status [<which>] - List status of a builder (or all builders)"
170 def command_WATCH(self, args):
171 args = args.split()
172 if len(args) != 1:
173 raise UsageError("try 'watch <builder>'")
174 which = args[0]
175 b = self.getBuilder(which)
176 builds = b.getCurrentBuilds()
177 if not builds:
178 self.send("there are no builds currently running")
179 return
180 for build in builds:
181 assert not build.isFinished()
182 d = build.waitUntilFinished()
183 d.addCallback(self.buildFinished)
184 r = "watching build %s #%d until it finishes" \
185 % (which, build.getNumber())
186 eta = build.getETA()
187 if eta is not None:
188 r += " [%s]" % self.convertTime(eta)
189 r += ".."
190 self.send(r)
191 command_WATCH.usage = "watch <which> - announce the completion of an active build"
193 def buildFinished(self, b):
194 results = {SUCCESS: "Success",
195 WARNINGS: "Warnings",
196 FAILURE: "Failure",
197 EXCEPTION: "Exception",
200 # only notify about builders we are interested in
201 builder = b.getBuilder()
202 log.msg('builder %r in category %s finished' % (builder,
203 builder.category))
204 if (self.categories != None and
205 builder.category not in self.categories):
206 return
208 r = "Hey! build %s #%d is complete: %s" % \
209 (b.getBuilder().getName(),
210 b.getNumber(),
211 results.get(b.getResults(), "??"))
212 r += " [%s]" % " ".join(b.getText())
213 self.send(r)
214 buildurl = self.channel.status.getURLForThing(b)
215 if buildurl:
216 self.send("Build details are at %s" % buildurl)
218 def command_FORCE(self, args):
219 args = shlex.split(args) # TODO: this requires python2.3 or newer
220 if args.pop(0) != "build":
221 raise UsageError("try 'force build WHICH <REASON>'")
222 opts = ForceOptions()
223 opts.parseOptions(args)
225 which = opts['builder']
226 branch = opts['branch']
227 revision = opts['revision']
228 reason = opts['reason']
230 # keep weird stuff out of the branch and revision strings. TODO:
231 # centralize this somewhere.
232 if branch and not re.match(r'^[\w\.\-\/]*$', branch):
233 log.msg("bad branch '%s'" % branch)
234 self.send("sorry, bad branch '%s'" % branch)
235 return
236 if revision and not re.match(r'^[\w\.\-\/]*$', revision):
237 log.msg("bad revision '%s'" % revision)
238 self.send("sorry, bad revision '%s'" % revision)
239 return
241 bc = self.getControl(which)
243 who = None # TODO: if we can authenticate that a particular User
244 # asked for this, use User Name instead of None so they'll
245 # be informed of the results.
246 # TODO: or, monitor this build and announce the results
247 r = "forced: by IRC user <%s>: %s" % (self.username, reason)
248 # TODO: maybe give certain users the ability to request builds of
249 # certain branches
250 s = SourceStamp(branch=branch, revision=revision)
251 req = BuildRequest(r, s, which)
252 try:
253 bc.requestBuildSoon(req)
254 except interfaces.NoSlaveError:
255 self.send("sorry, I can't force a build: all slaves are offline")
256 return
257 ireq = IrcBuildRequest(self)
258 req.subscribe(ireq.started)
261 command_FORCE.usage = "force build <which> <reason> - Force a build"
263 def command_STOP(self, args):
264 args = args.split(None, 2)
265 if len(args) < 3 or args[0] != 'build':
266 raise UsageError, "try 'stop build WHICH <REASON>'"
267 which = args[1]
268 reason = args[2]
270 buildercontrol = self.getControl(which)
272 who = None
273 r = "stopped: by IRC user <%s>: %s" % (self.username, reason)
275 # find an in-progress build
276 builderstatus = self.getBuilder(which)
277 builds = builderstatus.getCurrentBuilds()
278 if not builds:
279 self.send("sorry, no build is currently running")
280 return
281 for build in builds:
282 num = build.getNumber()
284 # obtain the BuildControl object
285 buildcontrol = buildercontrol.getBuild(num)
287 # make it stop
288 buildcontrol.stopBuild(r)
290 self.send("build %d interrupted" % num)
292 command_STOP.usage = "stop build <which> <reason> - Stop a running build"
294 def emit_status(self, which):
295 b = self.getBuilder(which)
296 str = "%s: " % which
297 state, builds = b.getState()
298 str += state
299 if state == "idle":
300 last = b.getLastFinishedBuild()
301 if last:
302 start,finished = last.getTimes()
303 str += ", last build %s secs ago: %s" % \
304 (int(util.now() - finished), " ".join(last.getText()))
305 if state == "building":
306 t = []
307 for build in builds:
308 step = build.getCurrentStep()
309 s = "(%s)" % " ".join(step.getText())
310 ETA = build.getETA()
311 if ETA is not None:
312 s += " [ETA %s]" % self.convertTime(ETA)
313 t.append(s)
314 str += ", ".join(t)
315 self.send(str)
317 def emit_last(self, which):
318 last = self.getBuilder(which).getLastFinishedBuild()
319 if not last:
320 str = "(no builds run since last restart)"
321 else:
322 start,finish = last.getTimes()
323 str = "%s secs ago: " % (int(util.now() - finish))
324 str += " ".join(last.getText())
325 self.send("last build [%s]: %s" % (which, str))
327 def command_LAST(self, args):
328 args = args.split()
329 if len(args) == 0:
330 which = "all"
331 elif len(args) == 1:
332 which = args[0]
333 else:
334 raise UsageError, "try 'last <builder>'"
335 if which == "all":
336 builders = self.getAllBuilders()
337 for b in builders:
338 self.emit_last(b.name)
339 return
340 self.emit_last(which)
341 command_LAST.usage = "last <which> - list last build status for builder <which>"
343 def build_commands(self):
344 commands = []
345 for k in self.__class__.__dict__.keys():
346 if k.startswith('command_'):
347 commands.append(k[8:].lower())
348 commands.sort()
349 return commands
351 def command_HELP(self, args):
352 args = args.split()
353 if len(args) == 0:
354 self.send("Get help on what? (try 'help <foo>', or 'commands' for a command list)")
355 return
356 command = args[0]
357 meth = self.getCommandMethod(command)
358 if not meth:
359 raise UsageError, "no such command '%s'" % command
360 usage = getattr(meth, 'usage', None)
361 if usage:
362 self.send("Usage: %s" % usage)
363 else:
364 self.send("No usage info for '%s'" % command)
365 command_HELP.usage = "help <command> - Give help for <command>"
367 def command_SOURCE(self, args):
368 banner = "My source can be found at http://buildbot.sourceforge.net/"
369 self.send(banner)
371 def command_COMMANDS(self, args):
372 commands = self.build_commands()
373 str = "buildbot commands: " + ", ".join(commands)
374 self.send(str)
375 command_COMMANDS.usage = "commands - List available commands"
377 def command_DESTROY(self, args):
378 self.act("readies phasers")
380 def command_DANCE(self, args):
381 reactor.callLater(1.0, self.send, "0-<")
382 reactor.callLater(3.0, self.send, "0-/")
383 reactor.callLater(3.5, self.send, "0-\\")
385 def command_EXCITED(self, args):
386 # like 'buildbot: destroy the sun!'
387 self.send("What you say!")
389 def handleAction(self, data, user):
390 # this is sent when somebody performs an action that mentions the
391 # buildbot (like '/me kicks buildbot'). 'user' is the name/nick/id of
392 # the person who performed the action, so if their action provokes a
393 # response, they can be named.
394 if not data.endswith("s buildbot"):
395 return
396 words = data.split()
397 verb = words[-2]
398 timeout = 4
399 if verb == "kicks":
400 response = "%s back" % verb
401 timeout = 1
402 else:
403 response = "%s %s too" % (verb, user)
404 reactor.callLater(timeout, self.act, response)
406 class IRCContact(Contact):
407 # this is the IRC-specific subclass of Contact
409 def __init__(self, channel, dest):
410 Contact.__init__(self, channel)
411 self.dest = dest
413 # userJoined(self, user, channel)
415 def send(self, message):
416 self.channel.msg(self.dest, message)
417 def act(self, action):
418 self.channel.me(self.dest, action)
421 def handleMessage(self, message):
422 message = message.lstrip()
423 if self.silly.has_key(message):
424 return self.doSilly(message)
426 parts = message.split(' ', 1)
427 if len(parts) == 1:
428 parts = parts + ['']
429 cmd, args = parts
430 log.msg("irc command", cmd)
432 meth = self.getCommandMethod(cmd)
433 if not meth and message[-1] == '!':
434 meth = self.command_EXCITED
436 error = None
437 try:
438 if meth:
439 meth(args.strip())
440 except UsageError, e:
441 self.send(str(e))
442 except:
443 f = failure.Failure()
444 log.err(f)
445 error = "Something bad happened (see logs): %s" % f.type
447 if error:
448 try:
449 self.send(error)
450 except:
451 log.err()
453 #self.say(channel, "count %d" % self.counter)
454 self.channel.counter += 1
456 class IChannel(Interface):
457 """I represent the buildbot's presence in a particular IM scheme.
459 This provides the connection to the IRC server, or represents the
460 buildbot's account with an IM service. Each Channel will have zero or
461 more Contacts associated with it.
464 class IrcStatusBot(irc.IRCClient):
465 """I represent the buildbot to an IRC server.
467 implements(IChannel)
469 def __init__(self, nickname, password, channels, status, categories):
471 @type nickname: string
472 @param nickname: the nickname by which this bot should be known
473 @type password: string
474 @param password: the password to use for identifying with Nickserv
475 @type channels: list of strings
476 @param channels: the bot will maintain a presence in these channels
477 @type status: L{buildbot.status.builder.Status}
478 @param status: the build master's Status object, through which the
479 bot retrieves all status information
481 self.nickname = nickname
482 self.channels = channels
483 self.password = password
484 self.status = status
485 self.categories = categories
486 self.counter = 0
487 self.hasQuit = 0
488 self.contacts = {}
490 def addContact(self, name, contact):
491 self.contacts[name] = contact
493 def getContact(self, name):
494 if name in self.contacts:
495 return self.contacts[name]
496 new_contact = IRCContact(self, name)
497 self.contacts[name] = new_contact
498 return new_contact
500 def deleteContact(self, contact):
501 name = contact.getName()
502 if name in self.contacts:
503 assert self.contacts[name] == contact
504 del self.contacts[name]
506 def log(self, msg):
507 log.msg("%s: %s" % (self, msg))
510 # the following irc.IRCClient methods are called when we have input
512 def privmsg(self, user, channel, message):
513 user = user.split('!', 1)[0] # rest is ~user@hostname
514 # channel is '#twisted' or 'buildbot' (for private messages)
515 channel = channel.lower()
516 #print "privmsg:", user, channel, message
517 if channel == self.nickname:
518 # private message
519 contact = self.getContact(user)
520 contact.handleMessage(message)
521 return
522 # else it's a broadcast message, maybe for us, maybe not. 'channel'
523 # is '#twisted' or the like.
524 contact = self.getContact(channel)
525 if message.startswith("%s:" % self.nickname):
526 message = message[len("%s:" % self.nickname):]
527 contact.handleMessage(message)
528 # to track users comings and goings, add code here
530 def action(self, user, channel, data):
531 #log.msg("action: %s,%s,%s" % (user, channel, data))
532 user = user.split('!', 1)[0] # rest is ~user@hostname
533 # somebody did an action (/me actions) in the broadcast channel
534 contact = self.getContact(channel)
535 if "buildbot" in data:
536 contact.handleAction(data, user)
540 def signedOn(self):
541 if self.password:
542 self.msg("Nickserv", "IDENTIFY " + self.password)
543 for c in self.channels:
544 self.join(c)
546 def joined(self, channel):
547 self.log("I have joined %s" % (channel,))
548 def left(self, channel):
549 self.log("I have left %s" % (channel,))
550 def kickedFrom(self, channel, kicker, message):
551 self.log("I have been kicked from %s by %s: %s" % (channel,
552 kicker,
553 message))
555 # we can using the following irc.IRCClient methods to send output. Most
556 # of these are used by the IRCContact class.
558 # self.say(channel, message) # broadcast
559 # self.msg(user, message) # unicast
560 # self.me(channel, action) # send action
561 # self.away(message='')
562 # self.quit(message='')
564 class ThrottledClientFactory(protocol.ClientFactory):
565 lostDelay = 2
566 failedDelay = 60
567 def clientConnectionLost(self, connector, reason):
568 reactor.callLater(self.lostDelay, connector.connect)
569 def clientConnectionFailed(self, connector, reason):
570 reactor.callLater(self.failedDelay, connector.connect)
572 class IrcStatusFactory(ThrottledClientFactory):
573 protocol = IrcStatusBot
575 status = None
576 control = None
577 shuttingDown = False
578 p = None
580 def __init__(self, nickname, password, channels, categories):
581 #ThrottledClientFactory.__init__(self) # doesn't exist
582 self.status = None
583 self.nickname = nickname
584 self.password = password
585 self.channels = channels
586 self.categories = categories
588 def __getstate__(self):
589 d = self.__dict__.copy()
590 del d['p']
591 return d
593 def shutdown(self):
594 self.shuttingDown = True
595 if self.p:
596 self.p.quit("buildmaster reconfigured: bot disconnecting")
598 def buildProtocol(self, address):
599 p = self.protocol(self.nickname, self.password,
600 self.channels, self.status,
601 self.categories)
602 p.factory = self
603 p.status = self.status
604 p.control = self.control
605 self.p = p
606 return p
608 # TODO: I think a shutdown that occurs while the connection is being
609 # established will make this explode
611 def clientConnectionLost(self, connector, reason):
612 if self.shuttingDown:
613 log.msg("not scheduling reconnection attempt")
614 return
615 ThrottledClientFactory.clientConnectionLost(self, connector, reason)
617 def clientConnectionFailed(self, connector, reason):
618 if self.shuttingDown:
619 log.msg("not scheduling reconnection attempt")
620 return
621 ThrottledClientFactory.clientConnectionFailed(self, connector, reason)
624 class IRC(base.StatusReceiverMultiService):
625 """I am an IRC bot which can be queried for status information. I
626 connect to a single IRC server and am known by a single nickname on that
627 server, however I can join multiple channels."""
629 compare_attrs = ["host", "port", "nick", "password",
630 "channels", "allowForce",
631 "categories"]
633 def __init__(self, host, nick, channels, port=6667, allowForce=True,
634 categories=None, password=None):
635 base.StatusReceiverMultiService.__init__(self)
637 assert allowForce in (True, False) # TODO: implement others
639 # need to stash these so we can detect changes later
640 self.host = host
641 self.port = port
642 self.nick = nick
643 self.channels = channels
644 self.password = password
645 self.allowForce = allowForce
646 self.categories = categories
648 # need to stash the factory so we can give it the status object
649 self.f = IrcStatusFactory(self.nick, self.password,
650 self.channels, self.categories)
652 c = internet.TCPClient(host, port, self.f)
653 c.setServiceParent(self)
655 def setServiceParent(self, parent):
656 base.StatusReceiverMultiService.setServiceParent(self, parent)
657 self.f.status = parent.getStatus()
658 if self.allowForce:
659 self.f.control = interfaces.IControl(parent)
661 def stopService(self):
662 # make sure the factory will stop reconnecting
663 self.f.shutdown()
664 return base.StatusReceiverMultiService.stopService(self)
667 ## buildbot: list builders
668 # buildbot: watch quick
669 # print notification when current build in 'quick' finishes
670 ## buildbot: status
671 ## buildbot: status full-2.3
672 ## building, not, % complete, ETA
673 ## buildbot: force build full-2.3 "reason"