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.
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
16 # TODO specialized output filter by event/event_key, with some kind of automatic selection
17 # e.g. if :default_pull_request exists, then it's automatically used if :event => "pull_request"
18 # and :default is the current output filter.
19 # The big question is what we should fallback to if the specific filter doesn't exist..
21 # If :custom exists, :default_pull_request exists and :custom_pull_request does not,
22 # should we fall back to :custom or to :default_pull_request?
27 class WebHookPlugin < Plugin
30 Config.register Config::EnumValue.new('webhook.announce_method',
31 :values => ['say', 'notice'],
33 :desc => "Whether to send a message or notice when announcing new GitHub actions.")
35 # Auxiliary method used to collect two lines for output filters,
36 # running substitutions against DataStream _s_ optionally joined
39 # TODO this was ripped from rss.rb considering moving it to the DataStream
40 # interface or something like that
42 # For substitutions, *_wrap keys can be used to alter the content of
43 # other nonempty keys. If the value of *_wrap is a String, it will be
44 # put before and after the corresponding key; if it's an Array, the first
45 # and second elements will be used for wrapping; if it's nil, no wrapping
46 # will be done (useful to override a default wrapping).
49 # :handle_wrap => '::'::
50 # will wrap s[:handle] by prefixing and postfixing it with '::'
51 # :date_wrap => [nil, ' :: ']::
52 # will put ' :: ' after s[:date]
53 def make_stream(line1, line2, s, h={})
58 kk = k.to_s.chomp!('_wrap')
63 wraps[nk] = ss[nk].wrap_nonempty(v, v)
65 wraps[nk] = ss[nk].wrap_nonempty(*v)
69 warning "ignoring #{v.inspect} wrapping of unknown class"
70 end unless ss[nk].nil?
76 DataStream.new([line1, line2].compact.join("\n") % subs, ss)
80 # Auxiliary method used to define rss output filters
81 def webhook_host_filter(key, &block)
82 @bot.register_filter(key, @hostkey, &block)
85 def webhook_out_filter(key, &block)
86 @bot.register_filter(key, @outkey, &block)
89 # Define the default webhook host and output filters, and load custom ones.
90 # Custom filters are looked for in the plugin's default filter locations,
91 # and in webhook/filters.rb
93 # Preferably, the webhook_host_filter and webhook_out_filter methods should be used in these files, e.g.:
94 # webhook_filter :my_output do |s|
95 # line1 = "%{repo} and some %{author} info"
96 # make_stream(line1, nil, s)
98 # to define the new filter 'my_output'.
100 # The datastream passed as input to the host filters has two keys:
102 # the hash representing the JSON payload
104 # the HTTPRequest that carried the JSON payload
106 # the expected name of the repository.
108 # Host filters should check that the request+payload is compatible with the format they expect,
109 # and that the detected repo name matches the provided one. If either condition is not satisfied,
110 # they should return nil. Otherwise, they should agument the input hash with
111 # approrpiate keys extracting the relevant information (as indicated below).
113 # The default host and out filters produce and expect the following keys in the DataStream:
115 # the event type, as described by e.g. the X-GitHub-Event request header
117 # the main event-specific object key (e.g. issue in the case of issue_comment)
119 # the hash representing the JSON payload
121 # the full name of the repository (e.g. "ruby-rbot/rbot")
123 # the sender login (e.g. "Oblomov")
127 # the ref referenced by the event
129 # 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'
131 # title of the object
135 @hostkey ||= :"webhook.host"
136 @outkey ||= :"webhook.out"
138 # the default output filter
139 webhook_out_filter :default do |s|
140 line1 = "%{repo}: %{author} %{action}"
141 [:number, :title, :ref, :link].each do |k|
142 line1 += "%{#{k}}" if s[k]
144 make_stream(line1, nil, s,
145 :repo_wrap => [Irc.color(:yellow), NormalText],
146 :author_wrap => Bold,
147 :number_wrap => [' ', ''],
148 :title_wrap => [" #{Irc.color(:green)}", NormalText],
149 :ref_wrap => [" (#{Irc.color(:yellow)}", "#{NormalText})"],
150 :link_wrap => [" <#{Irc.color(:aqualight)}", "#{NormalText}>"])
153 # the github host filter is actually implemented below
154 webhook_host_filter :github do |s|
155 github_host_filter(s)
158 # gitea is essentially compatible with github
159 webhook_host_filter :gitea do |s|
160 github_host_filter(s)
163 # gitlab has a different one
164 webhook_host_filter :gitlab do |s|
165 gitlab_host_filter(s)
168 @user_types ||= datafile 'filters.rb'
170 load_filters :path => @user_types
173 # Map the event name to the payload key storing the essential information
179 # Host filters should return nil if they cannot process the given payload+request pair
180 def github_host_filter(input_stream)
181 request = input_stream[:request]
182 json = input_stream[:payload]
183 req_repo = input_stream[:repo]
185 return nil unless request['x-github-event']
187 repo = json[:repository]
188 return nil unless repo
189 repo = repo[:full_name]
190 return nil unless repo
192 return nil unless repo == req_repo
194 event = request.header['x-github-event'].first.to_sym
200 event_key = GITHUB_EVENT_KEY[event] || event
202 # :issue_comment needs special handling because it has two primary objects
203 # (the issue and the comment), and we take stuff from both
204 obj = json[event_key] || json[:issue]
206 link = json[:comment][:html_url] rescue nil if event == :issue_comment
207 link ||= obj[:html_url] || obj[:url]
210 link = json[:html_url] || json[:url] || json[:compare]
212 title ||= json[:zen] || json[:commits].last[:message].lines.first.chomp rescue nil
214 stream_hash = { :event => event,
215 :event_key => event_key,
217 :author => (json[:sender][:login] rescue nil),
218 :action => json[:action] || event,
223 stream_hash[:ref] ||= json[:base][:ref] if json[:base]
225 num = json[:number] || obj[:number] rescue nil
226 stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => event_key.to_s.gsub('_', ' ') } if num
227 num = json[:size] || json[:commits].size rescue nil
228 stream_hash[:number] = _("%{num} commits") % { :num => num } if num
232 stream_hash[:number] ||= 'watching 👀%{watchers_count}' % json[:repository]
234 stream_hash[:number] ||= 'star ☆ %{watchers_count}' % json[:repository]
239 return input_stream.merge stream_hash
242 GITLAB_EVENT_ACTION = {
244 'tag_push' => 'pushed tag',
245 'note' => 'commented on'
248 def gitlab_host_filter(input_stream)
249 request = input_stream[:request]
250 json = input_stream[:payload]
251 req_repo = input_stream[:repo]
253 return nil unless request['x-gitlab-event']
255 repo = json[:project]
256 return nil unless repo
257 repo = repo[:path_with_namespace]
258 return nil unless repo
260 return nil unless repo == req_repo
262 event = json[:object_kind]
264 debug "No object_kind found in JSON"
268 event_key = :object_attributes
269 obj = json[event_key]
271 user = json[:user] # may be nil: some events use keys such as user_username
272 # TODO we might want to unify this at the rbot level
274 # comments have a noteable_type, but this is not the key of the object used
275 # so instead we just look for the known keys
277 [:commit, :merge_request, :issue, :snippet].each do |k|
284 link = obj ? obj[:url] : nil
285 title = notable ? notable[:title] : obj ? obj[:title] : nil
286 title ||= json[:commits].last[:title] rescue nil
288 # TODO https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
290 stream_hash = { :event => event,
291 :event_key => event_key,
293 :author => user ? user[:username] : json[:user_username],
294 :action => GITLAB_EVENT_ACTION[event] || (obj ? (obj[:action] || 'created') : event),
297 :text => obj ? (obj[:note] || obj[:description]) : nil
300 stream_hash[:ref] ||= obj[:target_branch] if obj
302 num = notable ? (notable[:iid] || notable[:id]) : obj ? obj[:iid] || obj[:id] : nil
303 stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => (obj[:noteable_type] || event).to_s.gsub('_', ' ') } if num
304 num = json[:total_commits_count]
305 stream_hash[:number] = _("%{num} commits") % { :num => num } if num
308 return input_stream.merge stream_hash
315 # @repos is hash the maps each reapo to a hash of watchers
318 if @registry.has_key?(:repos)
319 @repos = @registry[:repos]
328 @registry[:repos] = Hash.new.merge @repos
331 def help(plugin, topic="")
334 ["webhook watch #{Bold}repository#{Bold} #{Bold}filter#{Bold} [in #{Bold}\#channel#{Bold}]: announce webhook triggers matching the given repository, using the given output filter.",
335 "the repository should be defined as service:name where service is known service, and name the actual repository name.",
336 "example: webhook watch github:ruby-rbot/rbot github"].join("\n")
338 " unwatch #{Bold}repository#{Bold} [in #{Bold}\#channel#{Bold}]: stop announcing webhhoks from the given repository"
340 " [un]watch <repository> [in #channel]: manage webhhok announcements for the given repository in the given channel"
344 def watch_repo(m, params)
346 chan = (params[:chan] || m.replyto).downcase
347 filter = params[:filter] || :default
350 @repos[repo][chan] = filter
354 def unwatch_repo(m, params)
356 chan = (params[:chan] || m.replyto).downcase
358 if @repos.has_key?(repo)
359 @repos[repo].delete(chan)
361 if @repos[repo].empty?
363 m.reply _("No more watchers, I'll forget about %{repo} altogether") % params
366 m.reply _("repo %{repo} not found") % params
370 # Display the host filters
371 def list_host_filters(m, params)
372 ar = @bot.filter_names(@hostkey)
374 m.reply _("No custom service filters registered")
376 m.reply ar.map { |k| k.to_s }.sort!.join(", ")
380 # Display the known output filters
381 def list_output_filters(m, params)
382 ar = @bot.filter_names(@outkey)
385 m.reply _("No custom output filters registered")
387 m.reply ar.map { |k| k.to_s }.sort!.join(", ")
391 # Display the known repos and watchers
392 def list_repos(m, params)
394 m.reply "No repos defined"
397 msg = @repos.map do |repo, watchers|
398 [Bold + repo + Bold, watchers.map do |channel, filter|
399 "#{channel} (#{filter})"
400 end.join(", ")].join(": ")
405 def filter_hook(json, request)
406 announce_method = @bot.config['webhook.announce_method']
411 @repos.each do |s_repo, watchers|
412 host, repo = s_repo.split(':', 2)
413 key = @bot.global_filter_name(host, @hostkey)
414 error "No host filter for #{host} (from #{s_repo})" unless @bot.has_filter?(key)
417 processed = @bot.filter(key, { :payload => json, :request => request, :repo => repo })
419 next unless processed
421 # TODO if we see that the same output filter is applied to multiple channels,
422 # we should group the channels by filters and only do the output processing once
423 watchers.each do |channel, filter|
425 key = @bot.global_filter_name(filter, @outkey)
426 key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
429 output = @bot.filter(key, processed)
432 @bot.__send__(announce_method, channel, output)
434 error "Failed to announce #{json} for #{repo} in #{channel} with filter #{filter}"
436 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
439 # match found, stop checking
444 def process_hook(m, params)
447 json = JSON.parse(m.req.body, :symbolize_names => true)
449 error "Failed to parse request #{m.req}"
452 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
455 # Send the response early
457 m.send_plaintext("Failed\n", 400)
461 m.send_plaintext("OK\n", 200)
464 filter_hook(json, m.req)
468 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
474 plugin = WebHookPlugin.new
475 plugin.web_map "/webhook", :action => :process_hook
477 plugin.map 'webhook watch :repo :filter [in :chan]',
478 :action => :watch_repo,
479 :defaults => { :filter => nil }
481 plugin.map 'webhook unwatch :repo [in :chan]',
482 :action => :unwatch_repo
484 plugin.map 'webhook list [repos]',
485 :action => 'list_repos'
487 plugin.map 'webhook [list] filters',
488 :action => 'list_output_filters'
490 plugin.map 'webhook [list] hosts',
491 :action => 'list_host_filters'
493 plugin.map 'webhook [list] services',
494 :action => 'list_host_filters'