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.
15 class WebHookPlugin < Plugin
18 Config.register Config::EnumValue.new('webhook.announce_method',
19 :values => ['say', 'notice'],
21 :desc => "Whether to send a message or notice when announcing new GitHub actions.")
23 # Auxiliary method used to collect two lines for output filters,
24 # running substitutions against DataStream _s_ optionally joined
27 # TODO this was ripped from rss.rb considering moving it to the DataStream
28 # interface or something like that
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).
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={})
46 kk = k.to_s.chomp!('_wrap')
51 wraps[nk] = ss[nk].wrap_nonempty(v, v)
53 wraps[nk] = ss[nk].wrap_nonempty(*v)
57 warning "ignoring #{v.inspect} wrapping of unknown class"
58 end unless ss[nk].nil?
64 DataStream.new([line1, line2].compact.join("\n") % subs, ss)
68 # Auxiliary method used to define rss output filters
69 def webhook_host_filter(key, &block)
70 @bot.register_filter(key, @hostkey, &block)
73 def webhook_out_filter(key, &block)
74 @bot.register_filter(key, @outkey, &block)
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
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)
86 # to define the new filter 'my_output'.
88 # The datastream passed as input to the host filters has two keys:
90 # the hash representing the JSON payload
92 # the HTTPRequest that carried the JSON payload
94 # the expected name of the repository.
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).
101 # The default host and out filters produce and expect the following keys in the DataStream:
103 # the event type, as described by e.g. the X-GitHub-Event request header
105 # the main event-specific object key (e.g. issue in the case of issue_comment)
107 # the hash representing the JSON payload
109 # the full name of the repository (e.g. "ruby-rbot/rbot")
111 # the sender login (e.g. "Oblomov")
115 # the ref referenced by the event
117 # the number of the issue or PR modified, or the number of commits
119 # title of the object
123 @hostkey ||= :"webhook.host"
124 @outkey ||= :"webhook.out"
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]
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}>"])
141 # the github host filter is actually implemented below
142 webhook_host_filter :github do |s|
143 github_host_filter(s)
146 # gitea is essentially compatible with github
147 webhook_host_filter :gitea do |s|
148 github_host_filter(s)
151 @user_types ||= datafile 'filters.rb'
153 load_filters :path => @user_types
156 # Map the event name to the payload key storing the essential information
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]
168 return nil unless request['x-github-event']
170 repo = json[:repository]
171 return nil unless repo
172 repo = repo[:full_name]
173 return nil unless repo
175 return nil unless repo == req_repo
177 event = request.header['x-github-event'].first.to_sym
183 event_key = GITHUB_EVENT_KEY[event] || event
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]
189 link = json[:comment][:html_url] rescue nil if event == :issue_comment
190 link ||= obj[:html_url] || obj[:url]
193 link = json[:html_url] || json[:url] || json[:compare]
195 title ||= json[:zen] || json[:commits].last[:message].lines.first.chomp rescue nil
197 stream_hash = { :event => event,
198 :event_key => event_key,
200 :author => (json[:sender][:login] rescue nil),
201 :action => json[:action] || event,
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
213 return input_stream.merge stream_hash
221 # @repos is hash the maps each reapo to a hash of watchers
224 if @registry.has_key?(:repos)
225 @repos = @registry[:repos]
234 @registry[:repos] = Hash.new.merge @repos
237 def help(plugin, topic="")
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")
244 " unwatch #{Bold}repository#{Bold} [in #{Bold}\#channel#{Bold}]: stop announcing webhhoks from the given repository"
246 " [un]watch <repository> [in #channel]: manage webhhok announcements for the given repository in the given channel"
250 def watch_repo(m, params)
252 chan = (params[:chan] || m.replyto).downcase
253 filter = params[:filter] || :default
256 @repos[repo][chan] = filter
260 def unwatch_repo(m, params)
262 chan = (params[:chan] || m.replyto).downcase
264 if @repo.has_key?(repo)
265 @repos[repo].delete(chan)
267 if @repos[repo].empty?
269 m.reply _("No more watchers, I'll forget about %{repo} altogether") % params
272 m.reply _("repo %{repo} not found") % params
276 # Display the host filters
277 def list_host_filters(m, params)
278 ar = @bot.filter_names(@hostkey)
280 m.reply _("No custom service filters registered")
282 m.reply ar.map { |k| k.to_s }.sort!.join(", ")
286 # Display the known output filters
287 def list_output_filters(m, params)
288 ar = @bot.filter_names(@outkey)
291 m.reply _("No custom output filters registered")
293 m.reply ar.map { |k| k.to_s }.sort!.join(", ")
297 # Display the known repos and watchers
298 def list_repos(m, params)
300 m.reply "No repos defined"
303 msg = @repos.map do |repo, watchers|
304 [Bold + repo + Bold, watchers.map do |channel, filter|
305 "#{channel} (#{filter})"
306 end.join(", ")].join(": ")
311 def filter_hook(json, request)
312 announce_method = @bot.config['webhook.announce_method']
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)
323 processed = @bot.filter(key, { :payload => json, :request => request, :repo => repo })
325 next unless processed
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|
331 key = @bot.global_filter_name(filter, @outkey)
332 key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
335 output = @bot.filter(key, processed)
338 @bot.__send__(announce_method, channel, output)
340 error "Failed to announce #{json} for #{repo} in #{channel} with filter #{filter}"
342 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
345 # match found, stop checking
350 def process_hook(m, params)
353 json = JSON.parse(m.req.body, :symbolize_names => true)
355 error "Failed to parse request #{m.req}"
358 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
361 # Send the response early
363 m.send_plaintext("Failed\n", 400)
367 m.send_plaintext("OK\n", 200)
370 filter_hook(json, m.req)
374 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
380 plugin = WebHookPlugin.new
381 plugin.web_map "/webhook", :action => :process_hook
383 plugin.map 'webhook watch :repo :filter [in :chan]',
384 :action => :watch_repo,
385 :defaults => { :filter => nil }
387 plugin.map 'webhook unwatch :repo [in :chan]',
388 :action => :unwatch_repo
390 plugin.map 'webhook list [repos]',
391 :action => 'list_repos'
393 plugin.map 'webhook [list] filters',
394 :action => 'list_output_filters'
396 plugin.map 'webhook [list] hosts',
397 :action => 'list_host_filters'
399 plugin.map 'webhook [list] services',
400 :action => 'list_host_filters'