]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/webhook.rb
87c8c400973257888d1409ed6265ecb93a2ff571
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / webhook.rb
1 # vi:et:sw=2
2 # webhook plugin -- webservice plugin to support webhooks from common repository services
3 # (e.g. GitHub, GitLab, Gitea) and announce changes on IRC
4 # Most of the processing is done through two (sets of) filters:
5 # * webhook host filters take the JSON sent from the hosting server,
6 #   and extract pertinent information (repository name, commit author, etc)
7 # * webhook output filters take the DataStream produced by the webhook host filter,
8 #   and turn it into an IRC message to be sent by PRIVMSG or NOTICE based on the
9 #   webhook.announce_method configuration
10 # The reason for this two-tier filtering is to allow the same output filters
11 # to be fed data from different (potentially unknown) hosting services.
12
13 require 'json'
14
15 class WebHookPlugin < Plugin
16   include WebPlugin
17
18   Config.register Config::EnumValue.new('webhook.announce_method',
19     :values => ['say', 'notice'],
20     :default => 'say',
21     :desc => "Whether to send a message or notice when announcing new GitHub actions.")
22
23   # Auxiliary method used to collect two lines for  output filters,
24   # running substitutions against DataStream _s_ optionally joined
25   # with hash _h_.
26   #
27   # TODO this was ripped from rss.rb considering moving it to the DataStream
28   # interface or something like that
29   #
30   # For substitutions, *_wrap keys can be used to alter the content of
31   # other nonempty keys. If the value of *_wrap is a String, it will be
32   # put before and after the corresponding key; if it's an Array, the first
33   # and second elements will be used for wrapping; if it's nil, no wrapping
34   # will be done (useful to override a default wrapping).
35   #
36   # For example:
37   # :handle_wrap => '::'::
38   #   will wrap s[:handle] by prefixing and postfixing it with '::'
39   # :date_wrap => [nil, ' :: ']::
40   #   will put ' :: ' after s[:date]
41   def make_stream(line1, line2, s, h={})
42     ss = s.merge(h)
43     subs = {}
44     wraps = {}
45     ss.each do |k, v|
46       kk = k.to_s.chomp!('_wrap')
47       if kk
48         nk = kk.intern
49         case v
50         when String
51           wraps[nk] = ss[nk].wrap_nonempty(v, v)
52         when Array
53           wraps[nk] = ss[nk].wrap_nonempty(*v)
54         when nil
55           # do nothing
56         else
57           warning "ignoring #{v.inspect} wrapping of unknown class"
58         end unless ss[nk].nil?
59       else
60         subs[k] = v
61       end
62     end
63     subs.merge! wraps
64     DataStream.new([line1, line2].compact.join("\n") % subs, ss)
65   end
66
67
68   # Auxiliary method used to define rss output filters
69   def webhook_host_filter(key, &block)
70     @bot.register_filter(key, @hostkey, &block)
71   end
72
73   def webhook_out_filter(key, &block)
74     @bot.register_filter(key, @outkey, &block)
75   end
76
77   # Define the default webhook host and output filters, and load custom ones.
78   # Custom filters are looked for in the plugin's default filter locations,
79   # and in webhook/filters.rb 
80   #
81   # Preferably, the webhook_host_filter and webhook_out_filter methods should be used in these files, e.g.:
82   #   webhook_filter :my_output do |s|
83   #     line1 = "%{repo} and some %{author} info"
84   #     make_stream(line1, nil, s)
85   #   end
86   # to define the new filter 'my_output'.
87   #
88   # The datastream passed as input to the host filters has two keys:
89   # payload::
90   #   the hash representing the JSON payload
91   # request::
92   #   the HTTPRequest that carried the JSON payload
93   # repo::
94   #   the expected name of the repository.
95   #
96   # Host filters should check that the request+payload is compatible with the format they expect,
97   # and that the detected repo name matches the provided one. If either condition is not satisfied,
98   # they should return nil. Otherwise, they should agument the input hash with
99   # approrpiate keys extracting the relevant information (as indicated below).
100   #
101   # The default host and out filters produce and expect the following keys in the DataStream:
102   # event::
103   #   the event type, as described by e.g. the X-GitHub-Event request header
104   # event_key::
105   #   the main event-specific object key (e.g. issue in the case of issue_comment)
106   # payload::
107   #   the hash representing the JSON payload
108   # repo::
109   #   the full name of the repository (e.g. "ruby-rbot/rbot")
110   # author::
111   #   the sender login (e.g. "Oblomov")
112   # action::
113   #   the hook action
114   # ref::
115   #   the ref referenced by the event
116   # number::
117   #   the number of the issue or PR modified, or the number of commits
118   # title::
119   #   title of the object
120   # link::
121   #   the HTML link
122   def define_filters
123     @hostkey ||= :"webhook.host"
124     @outkey ||= :"webhook.out"
125
126     # the default output filter
127     webhook_out_filter :default do |s|
128       line1 = "%{repo}: %{author} %{action}"
129       [:number, :title, :ref, :link].each do |k|
130         line1 += "%{#{k}}" if s[k]
131       end
132       make_stream(line1, nil, s,
133                   :repo_wrap => [Irc.color(:yellow), NormalText],
134                   :author_wrap => Bold,
135                   :number_wrap => [' ', ''],
136                   :title_wrap => [" #{Irc.color(:green)}", NormalText],
137                   :ref_wrap =>  [" (#{Irc.color(:yellow)}", "#{NormalText})"],
138                   :link_wrap => [" <#{Irc.color(:aqualight)}", "#{NormalText}>"])
139     end
140
141     # the github host filter is actually implemented below
142     webhook_host_filter :github do |s|
143       github_host_filter(s)
144     end
145
146     # gitea is essentially compatible with github
147     webhook_host_filter :gitea do |s|
148       github_host_filter(s)
149     end
150
151     @user_types ||= datafile 'filters.rb'
152     load_filters
153     load_filters :path => @user_types
154   end
155
156   # Map the event name to the payload key storing the essential information
157   GITHUB_EVENT_KEY = {
158     :issues => :issue,
159     :ping => :hook,
160   }
161
162   # Host filters should return nil if they cannot process the given payload+request pair
163   def github_host_filter(input_stream)
164     request = input_stream[:request]
165     json = input_stream[:payload]
166     req_repo = input_stream[:repo]
167
168     return nil unless request['x-github-event']
169
170     repo = json[:repository]
171     return nil unless repo
172     repo = repo[:full_name]
173     return nil unless repo
174
175     return nil unless repo == req_repo
176
177     event = request.header['x-github-event'].first.to_sym
178
179     obj = nil
180     link = nil
181     title = nil
182
183     event_key = GITHUB_EVENT_KEY[event] || event
184
185     # :issue_comment needs special handling because it has two primary objects
186     # (the issue and the comment), and we take stuff from both
187     obj = json[event_key] || json[:issue]
188     if obj
189       link = json[:comment][:html_url] rescue nil if event == :issue_comment
190       link ||= obj[:html_url] || obj[:url]
191       title = obj[:title]
192     else
193       link = json[:html_url] || json[:url] || json[:compare]
194     end
195     title ||= json[:zen] || json[:commits].last[:message].lines.first.chomp rescue nil
196
197     stream_hash = { :event => event,
198                     :event_key => event_key,
199                     :ref => json[:ref],
200                     :author => (json[:sender][:login] rescue nil),
201                     :action => json[:action] || event,
202                     :title => title,
203                     :link => link
204     }
205
206     num = json[:number] || obj[:number] rescue nil
207     stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => event_key.to_s.gsub('_', ' ') } if num
208     num = json[:size] || json[:commits].size rescue nil
209     stream_hash[:number] = _("%{num} commits") % { :num => num } if num
210
211     debug stream_hash
212
213     return input_stream.merge stream_hash
214   end
215
216
217   def initialize
218     super
219     define_filters
220
221     # @repos is hash the maps each reapo to a hash of watchers
222     # channel => filter
223     @repos = {}
224     if @registry.has_key?(:repos)
225       @repos = @registry[:repos]
226     end
227   end
228
229   def name
230     "webhook"
231   end
232
233   def save
234     @registry[:repos] = Hash.new.merge @repos
235   end
236
237   def help(plugin, topic="")
238     case topic
239     when "watch"
240       ["webhook watch #{Bold}repository#{Bold} #{Bold}filter#{Bold} [in #{Bold}\#channel#{Bold}]: announce webhook triggers matching the given repository, using the given output filter.",
241        "the repository should be defined as service:name where service is known service, and name the actual repository name.",
242        "example: webhook watch github:ruby-rbot/rbot github"].join("\n")
243     when "unwatch"
244       " unwatch #{Bold}repository#{Bold} [in #{Bold}\#channel#{Bold}]: stop announcing webhhoks from the given repository"
245     else
246       " [un]watch <repository> [in #channel]: manage webhhok announcements for the given repository in the given channel"
247     end
248   end
249
250   def watch_repo(m, params)
251     repo = params[:repo]
252     chan = (params[:chan] || m.replyto).downcase
253     filter = params[:filter] || :default
254
255     @repos[repo] ||= {}
256     @repos[repo][chan] = filter
257     m.okay
258   end
259
260   def unwatch_repo(m, params)
261     repo = params[:repo]
262     chan = (params[:chan] || m.replyto).downcase
263
264     if @repo.has_key?(repo)
265       @repos[repo].delete(chan)
266       m.okay
267       if @repos[repo].empty?
268         @repos.delete(repo)
269         m.reply _("No more watchers, I'll forget about %{repo} altogether") % params
270       end
271     else
272       m.reply _("repo %{repo} not found") % params
273     end
274   end
275
276   # Display the host filters
277   def list_host_filters(m, params)
278     ar = @bot.filter_names(@hostkey)
279     if ar.empty?
280       m.reply _("No custom service filters registered")
281     else
282       m.reply ar.map { |k| k.to_s }.sort!.join(", ")
283     end
284   end
285
286   # Display the known output filters
287   def list_output_filters(m, params)
288     ar = @bot.filter_names(@outkey)
289     ar.delete(:default)
290     if ar.empty?
291       m.reply _("No custom output filters registered")
292     else
293       m.reply ar.map { |k| k.to_s }.sort!.join(", ")
294     end
295   end
296
297   # Display the known repos and watchers
298   def list_repos(m, params)
299     if @repos.empty?
300       m.reply "No repos defined"
301       return
302     end
303     msg = @repos.map do |repo, watchers|
304       [Bold + repo + Bold, watchers.map do |channel, filter|
305         "#{channel} (#{filter})"
306       end.join(", ")].join(": ")
307     end.join(", ")
308     m.reply msg
309   end
310
311   def filter_hook(json, request)
312     announce_method = @bot.config['webhook.announce_method']
313
314     debug request
315     debug json
316
317     @repos.each do |s_repo, watchers|
318       host, repo = s_repo.split(':', 2)
319       key = @bot.global_filter_name(host, @hostkey)
320       error "No host filter for #{host} (from #{s_repo})" unless @bot.has_filter?(key)
321
322       debug key
323       processed = @bot.filter(key, { :payload => json, :request => request, :repo => repo })
324       debug processed
325       next unless processed
326
327       # TODO if we see that the same output filter is applied to multiple channels,
328       # we should group the channels by filters and only do the output processing once
329       watchers.each do |channel, filter|
330         begin
331           key = @bot.global_filter_name(filter, @outkey)
332           key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
333
334           debug key
335           output = @bot.filter(key, processed)
336           debug output
337
338           @bot.__send__(announce_method, channel, output)
339         rescue => e
340           error "Failed to announce #{json} for #{repo} in #{channel} with filter #{filter}"
341           debug e.inspect
342           debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
343         end
344       end
345       # match found, stop checking
346       break
347     end
348   end
349
350   def process_hook(m, params)
351     json = nil
352     begin
353       json = JSON.parse(m.req.body, :symbolize_names => true)
354     rescue => e
355       error "Failed to parse request #{m.req}"
356       debug m.req
357       debug e.inspect
358       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
359     end
360
361     # Send the response early
362     if not json
363       m.send_plaintext("Failed\n", 400)
364       return
365     end
366
367     m.send_plaintext("OK\n", 200)
368
369     begin
370       filter_hook(json, m.req)
371     rescue => e
372       error e
373       debug e.inspect
374       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
375     end
376   end
377
378 end
379
380 plugin = WebHookPlugin.new
381 plugin.web_map "/webhook", :action => :process_hook
382
383 plugin.map 'webhook watch :repo :filter [in :chan]',
384   :action => :watch_repo,
385   :defaults => { :filter => nil }
386
387 plugin.map 'webhook unwatch :repo [in :chan]',
388   :action => :unwatch_repo
389
390 plugin.map 'webhook list [repos]',
391   :action => 'list_repos'
392
393 plugin.map 'webhook [list] filters',
394   :action => 'list_output_filters'
395
396 plugin.map 'webhook [list] hosts',
397   :action => 'list_host_filters'
398
399 plugin.map 'webhook [list] services',
400   :action => 'list_host_filters'