3 Push feed and vcs activities to an IRC channel. Configured with the ".slander" rc file, or another yaml file specified on the cmd line.
6 Miki Tebeka, http://pythonwise.blogspot.com/2009/05/subversion-irc-bot.html
7 Eloff, http://stackoverflow.com/a/925630
8 rewritten by Adam Wight,
9 project homepage is https://github.com/adamwight/slander
12 This is the configuration file used for the CiviCRM project:
16 https://fisheye2.atlassian.com/changelog/CiviCRM?cs=%s
17 root: http://svn.civicrm.org/civicrm
18 args: --username SVN_USER --password SVN_PASSS
21 http://issues.civicrm.org/jira
23 http://issues.civicrm.org/jira/activity?maxResults=20&streams=key+IS+CRM&title=undefined
26 host: irc.freenode.net
29 realname: CiviCRM svn and jira notification bot
30 channel: "#civicrm" #note that quotes are necessary here
35 sourceURL: https://svn.civicrm.org/tools/trunk/bin/scripts/ircbot-civi.py
44 from twisted
.words
.protocols
import irc
45 from twisted
.internet
.protocol
import ReconnectingClientFactory
46 from twisted
.internet
import reactor
47 from twisted
.internet
.task
import LoopingCall
49 from xml
.etree
.cElementTree
import parse
as xmlparse
50 from cStringIO
import StringIO
51 from subprocess
import Popen
, PIPE
54 from HTMLParser
import HTMLParser
57 class RelayToIRC(irc
.IRCClient
):
58 def connectionMade(self
):
59 self
.config
= self
.factory
.config
60 self
.jobs
= create_jobs(self
.config
["jobs"])
61 self
.nickname
= self
.config
["irc"]["nick"]
62 self
.realname
= self
.config
["irc"]["realname"]
63 self
.channel
= self
.config
["irc"]["channel"]
64 self
.sourceURL
= "https://github.com/adamwight/slander"
65 if "sourceURL" in self
.config
:
66 self
.sourceURL
= self
.config
["sourceURL"]
68 irc
.IRCClient
.connectionMade(self
)
71 self
.join(self
.channel
)
73 def joined(self
, channel
):
74 print "Joined channel %s as %s" % (channel
, self
.nickname
)
75 task
= LoopingCall(self
.check
)
76 task
.start(self
.config
["poll_interval"])
77 print "Started polling jobs, every %d seconds." % (self
.config
["poll_interval"], )
79 def privmsg(self
, user
, channel
, message
):
80 if message
.find(self
.nickname
) >= 0:
81 # TODO surely there are useful ways to interact?
82 if re
.search(r
'\bhelp\b', message
):
83 self
.say(self
.channel
, "If I only had a brain: %s" % (self
.sourceURL
, ))
85 print "Failed to handle incoming command: %s said %s" % (user
, message
)
89 for line
in job
.check():
90 self
.say(self
.channel
, str(line
))
95 factory
= ReconnectingClientFactory()
96 factory
.protocol
= RelayToIRC
97 factory
.config
= config
98 reactor
.connectTCP(config
["irc"]["host"], config
["irc"]["port"], factory
)
101 class SvnPoller(object):
102 def __init__(self
, root
=None, args
=None, changeset_url_format
=None):
103 self
.pre
= ["svn", "--xml"] + args
.split()
105 self
.changeset_url_format
= changeset_url_format
106 print "Initializing SVN poller: %s" % (" ".join(self
.pre
)+" "+root
, )
109 pipe
= Popen(self
.pre
+ list(cmd
) + [self
.root
], stdout
=PIPE
)
111 data
= pipe
.communicate()[0]
114 return xmlparse(StringIO(data
))
117 tree
= self
.svn("info")
118 revision
= tree
.find(".//commit").get("revision")
121 def revision_info(self
, revision
):
122 revision
= str(revision
)
123 tree
= self
.svn("log", "-r", revision
)
124 author
= tree
.find(".//author").text
125 comment
= truncate(strip(tree
.find(".//msg").text
), self
.config
["irc"]["maxlen"])
126 url
= self
.changeset_url(revision
)
128 return (revision
, author
, comment
, url
)
130 def changeset_url(self
, revision
):
131 return self
.changeset_url_format
% (revision
, )
133 previous_revision
= None
136 latest
= self
.revision()
137 if self
.previous_revision
and latest
!= self
.previous_revision
:
138 for rev
in range(self
.previous_revision
+ 1, latest
+ 1):
139 yield "r%s by %s: %s [%s]" % self
.revision_info(rev
)
140 self
.previous_revision
= latest
142 print "ERROR: %s" % e
145 class FeedPoller(object):
148 def __init__(self
, **config
):
149 print "Initializing feed poller: %s" % (config
["source"], )
153 result
= feedparser
.parse(self
.config
["source"])
154 for entry
in result
.entries
:
155 if (not self
.last_seen_id
) or (self
.last_seen_id
== entry
.id):
157 yield self
.parse(entry
)
160 self
.last_seen_id
= result
.entries
[0].id
163 class JiraPoller(FeedPoller
):
164 def parse(self
, entry
):
165 m
= re
.search(r
'(CRM-[0-9]+)$', entry
.link
)
166 if (not m
) or (entry
.generator_detail
.href
!= self
.config
["base_url"]):
169 summary
= truncate(strip(entry
.summary
), self
.config
["irc"]["maxlen"])
170 url
= self
.config
["base_url"]+"/browse/%s" % (issue
, )
172 return "%s: %s %s [%s]" % (entry
.author_detail
.name
, issue
, summary
, url
)
174 class MinglePoller(FeedPoller
):
175 def parse(self
, entry
):
176 m
= re
.search(r
'^(.*/([0-9]+))', entry
.id)
178 issue
= int(m
.group(2))
179 summary
= truncate(strip(entry
.summary
), self
.config
["irc"]["maxlen"])
180 author
= abbrevs(entry
.author_detail
.name
)
182 return "#%d: (%s) %s [%s]" % (issue
, author
, summary
, url
)
184 def strip(text
, html
=True, space
=True):
185 class MLStripper(HTMLParser
):
189 def handle_data(self
, d
):
192 return ''.join(self
.fed
)
195 stripper
= MLStripper()
197 text
= stripper
.get_data()
199 text
= text
.strip().replace("\n", " ")
203 return "".join([w
[:1] for w
in name
.split()])
205 def truncate(message
, length
):
206 if len(message
) > length
:
207 return (message
[:(length
-3)] + "...")
213 for type, options
in d
.items():
214 classname
= type.capitalize() + "Poller"
215 klass
= globals()[classname
]
216 yield klass(**options
)
218 if __name__
== "__main__":
219 if len(sys
.argv
) == 2:
220 dotfile
= sys
.argv
[1]
222 dotfile
= os
.path
.expanduser("~/.slander")
223 print "Reading config from %s" % (dotfile
, )
224 config
= yaml
.load(file(dotfile
))
225 RelayToIRC
.run(config
)