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