Merge pull request #11629 from JMAConsulting/CRM-21675
[civicrm-core.git] / tools / bin / scripts / ircbot-civi.py
CommitLineData
6a488035
TO
1#!/usr/bin/env python
2'''
3Push feed and vcs activities to an IRC channel. Configured with the ".slander" rc file, or another yaml file specified on the cmd line.
4
5CREDITS
6Miki Tebeka, http://pythonwise.blogspot.com/2009/05/subversion-irc-bot.html
7Eloff, http://stackoverflow.com/a/925630
8rewritten by Adam Wight,
9project homepage is https://github.com/adamwight/slander
10
11EXAMPLE
12This is the configuration file used for the CiviCRM project:
13 jobs:
14 svn:
15 changeset_url_format:
16 https://fisheye2.atlassian.com/changelog/CiviCRM?cs=%s
17 root: http://svn.civicrm.org/civicrm
18 args: --username SVN_USER --password SVN_PASSS
19 jira:
20 base_url:
21 http://issues.civicrm.org/jira
22 source:
23 http://issues.civicrm.org/jira/activity?maxResults=20&streams=key+IS+CRM&title=undefined
24
25 irc:
26 host: irc.freenode.net
27 port: 6667
28 nick: civi-activity
29 realname: CiviCRM svn and jira notification bot
30 channel: "#civicrm" #note that quotes are necessary here
31 maxlen: 200
32
33 poll_interval: 60
34
35 sourceURL: https://svn.civicrm.org/tools/trunk/bin/scripts/ircbot-civi.py
36'''
37
38import sys
39import os
40import re
41
42import yaml
43
44from twisted.words.protocols import irc
45from twisted.internet.protocol import ReconnectingClientFactory
46from twisted.internet import reactor
47from twisted.internet.task import LoopingCall
48
49from xml.etree.cElementTree import parse as xmlparse
50from cStringIO import StringIO
51from subprocess import Popen, PIPE
52
53import feedparser
54from HTMLParser import HTMLParser
55
56
57class 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"]
67
68 irc.IRCClient.connectionMade(self)
69
70 def signedOn(self):
71 self.join(self.channel)
72
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"], )
78
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, ))
84 else:
85 print "Failed to handle incoming command: %s said %s" % (user, message)
86
87 def check(self):
88 for job in self.jobs:
89 for line in job.check():
90 self.say(self.channel, str(line))
91 print(line)
92
93 @staticmethod
94 def run(config):
95 factory = ReconnectingClientFactory()
96 factory.protocol = RelayToIRC
97 factory.config = config
98 reactor.connectTCP(config["irc"]["host"], config["irc"]["port"], factory)
99 reactor.run()
100
101class SvnPoller(object):
102 def __init__(self, root=None, args=None, changeset_url_format=None):
103 self.pre = ["svn", "--xml"] + args.split()
104 self.root = root
105 self.changeset_url_format = changeset_url_format
106 print "Initializing SVN poller: %s" % (" ".join(self.pre)+" "+root, )
107
108 def svn(self, *cmd):
109 pipe = Popen(self.pre + list(cmd) + [self.root], stdout=PIPE)
110 try:
111 data = pipe.communicate()[0]
112 except IOError:
113 data = ""
114 return xmlparse(StringIO(data))
115
116 def revision(self):
117 tree = self.svn("info")
118 revision = tree.find(".//commit").get("revision")
119 return int(revision)
120
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)
127
128 return (revision, author, comment, url)
129
130 def changeset_url(self, revision):
131 return self.changeset_url_format % (revision, )
132
133 previous_revision = None
134 def check(self):
135 try:
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
141 except Exception, e:
142 print "ERROR: %s" % e
143
144
145class FeedPoller(object):
146 last_seen_id = None
147
148 def __init__(self, **config):
149 print "Initializing feed poller: %s" % (config["source"], )
150 self.config = config
151
152 def check(self):
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):
156 break
157 yield self.parse(entry)
158
159 if result.entries:
160 self.last_seen_id = result.entries[0].id
161
162
163class 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"]):
167 return
168 issue = m.group(1)
169 summary = truncate(strip(entry.summary), self.config["irc"]["maxlen"])
170 url = self.config["base_url"]+"/browse/%s" % (issue, )
171
172 return "%s: %s %s [%s]" % (entry.author_detail.name, issue, summary, url)
173
174class MinglePoller(FeedPoller):
175 def parse(self, entry):
176 m = re.search(r'^(.*/([0-9]+))', entry.id)
177 url = m.group(1)
178 issue = int(m.group(2))
179 summary = truncate(strip(entry.summary), self.config["irc"]["maxlen"])
180 author = abbrevs(entry.author_detail.name)
181
182 return "#%d: (%s) %s [%s]" % (issue, author, summary, url)
183
184def strip(text, html=True, space=True):
185 class MLStripper(HTMLParser):
186 def __init__(self):
187 self.reset()
188 self.fed = []
189 def handle_data(self, d):
190 self.fed.append(d)
191 def get_data(self):
192 return ''.join(self.fed)
193
194 if html:
195 stripper = MLStripper()
196 stripper.feed(text)
197 text = stripper.get_data()
198 if space:
199 text = text.strip().replace("\n", " ")
200 return text
201
202def abbrevs(name):
203 return "".join([w[:1] for w in name.split()])
204
205def truncate(message, length):
206 if len(message) > length:
207 return (message[:(length-3)] + "...")
208 else:
209 return message
210
211
212def create_jobs(d):
213 for type, options in d.items():
214 classname = type.capitalize() + "Poller"
215 klass = globals()[classname]
216 yield klass(**options)
217
218if __name__ == "__main__":
219 if len(sys.argv) == 2:
220 dotfile = sys.argv[1]
221 else:
222 dotfile = os.path.expanduser("~/.slander")
223 print "Reading config from %s" % (dotfile, )
224 config = yaml.load(file(dotfile))
225 RelayToIRC.run(config)