summaryrefslogtreecommitdiff
path: root/data/rbot/plugins/webhook.rb
blob: 502b5e9372692cefa302929ed4b28c2d249abbe2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
# vi:et:sw=2
# webhook plugin -- webservice plugin to support webhooks from common repository services
# (e.g. GitHub, GitLab, Gitea) and announce changes on IRC
# Most of the processing is done through two (sets of) filters:
# * webhook host filters take the JSON sent from the hosting server,
#   and extract pertinent information (repository name, commit author, etc)
# * webhook output filters take the DataStream produced by the webhook host filter,
#   and turn it into an IRC message to be sent by PRIVMSG or NOTICE based on the
#   webhook.announce_method configuration
# The reason for this two-tier filtering is to allow the same output filters
# to be fed data from different (potentially unknown) hosting services.

# TODO for the repo matchers in the built-in filters we might want to support
# both the whole user/repo or just the repo name

# TODO specialized output filter by event/event_key, with some kind of automatic selection
# e.g. if :default_pull_request exists, then it's automatically used if :event => "pull_request"
# and :default is the current output filter.
# The big question is what we should fallback to if the specific filter doesn't exist..
#
# If :custom exists, :default_pull_request exists and :custom_pull_request does not,
# should we fall back to :custom or to :default_pull_request?


require 'json'

class WebHookPlugin < Plugin
  include WebPlugin

  Config.register Config::EnumValue.new('webhook.announce_method',
    :values => ['say', 'notice'],
    :default => 'say',
    :desc => "Whether to send a message or notice when announcing new GitHub actions.")

  # Auxiliary method used to collect two lines for  output filters,
  # running substitutions against DataStream _s_ optionally joined
  # with hash _h_.
  #
  # TODO this was ripped from rss.rb considering moving it to the DataStream
  # interface or something like that
  #
  # For substitutions, *_wrap keys can be used to alter the content of
  # other nonempty keys. If the value of *_wrap is a String, it will be
  # put before and after the corresponding key; if it's an Array, the first
  # and second elements will be used for wrapping; if it's nil, no wrapping
  # will be done (useful to override a default wrapping).
  #
  # For example:
  # :handle_wrap => '::'::
  #   will wrap s[:handle] by prefixing and postfixing it with '::'
  # :date_wrap => [nil, ' :: ']::
  #   will put ' :: ' after s[:date]
  def make_stream(line1, line2, s, h={})
    ss = s.merge(h)
    subs = {}
    wraps = {}
    ss.each do |k, v|
      kk = k.to_s.chomp!('_wrap')
      if kk
        nk = kk.intern
        case v
        when String
          wraps[nk] = ss[nk].wrap_nonempty(v, v)
        when Array
          wraps[nk] = ss[nk].wrap_nonempty(*v)
        when nil
          # do nothing
        else
          warning "ignoring #{v.inspect} wrapping of unknown class"
        end unless ss[nk].nil?
      else
        subs[k] = v
      end
    end
    subs.merge! wraps
    DataStream.new([line1, line2].compact.join("\n") % subs, ss)
  end


  # Auxiliary method used to define rss output filters
  def webhook_host_filter(key, &block)
    @bot.register_filter(key, @hostkey, &block)
  end

  def webhook_out_filter(key, &block)
    @bot.register_filter(key, @outkey, &block)
  end

  # Define the default webhook host and output filters, and load custom ones.
  # Custom filters are looked for in the plugin's default filter locations,
  # and in webhook/filters.rb 
  #
  # Preferably, the webhook_host_filter and webhook_out_filter methods should be used in these files, e.g.:
  #   webhook_filter :my_output do |s|
  #     line1 = "%{repo} and some %{author} info"
  #     make_stream(line1, nil, s)
  #   end
  # to define the new filter 'my_output'.
  #
  # The datastream passed as input to the host filters has two keys:
  # payload::
  #   the hash representing the JSON payload
  # request::
  #   the HTTPRequest that carried the JSON payload
  # repo::
  #   the expected name of the repository.
  #
  # Host filters should check that the request+payload is compatible with the format they expect,
  # and that the detected repo name matches the provided one. If either condition is not satisfied,
  # they should return nil. Otherwise, they should agument the input hash with
  # approrpiate keys extracting the relevant information (as indicated below).
  #
  # The default host and out filters produce and expect the following keys in the DataStream:
  # event::
  #   the event type, as described by e.g. the X-GitHub-Event request header
  # event_key::
  #   the main event-specific object key (e.g. issue in the case of issue_comment)
  # payload::
  #   the hash representing the JSON payload
  # repo::
  #   the full name of the repository (e.g. "ruby-rbot/rbot")
  # author::
  #   the sender login (e.g. "Oblomov")
  # action::
  #   the hook action
  # ref::
  #   the ref referenced by the event
  # number::
  #   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'
  # title::
  #   title of the object
  # link::
  #   the HTML link
  def define_filters
    @hostkey ||= :"webhook.host"
    @outkey ||= :"webhook.out"

    # the default output filter
    webhook_out_filter :default do |s|
      line1 = "%{repo}: %{author} %{action}"
      [:number, :title, :ref, :link].each do |k|
        line1 += "%{#{k}}" if s[k]
      end
      make_stream(line1, nil, s,
                  :repo_wrap => [Irc.color(:yellow), NormalText],
                  :author_wrap => Bold,
                  :number_wrap => [' ', ''],
                  :title_wrap => [" #{Irc.color(:green)}", NormalText],
                  :ref_wrap =>  [" (#{Irc.color(:yellow)}", "#{NormalText})"],
                  :link_wrap => [" <#{Irc.color(:aqualight)}", "#{NormalText}>"])
    end

    # the github host filter is actually implemented below
    webhook_host_filter :github do |s|
      github_host_filter(s)
    end

    # gitea is essentially compatible with github
    webhook_host_filter :gitea do |s|
      github_host_filter(s)
    end

    # gitlab has a different one
    webhook_host_filter :gitlab do |s|
      gitlab_host_filter(s)
    end

    @user_types ||= datafile 'filters.rb'
    load_filters
    load_filters :path => @user_types
  end

  # Map the event name to the payload key storing the essential information
  GITHUB_EVENT_KEY = {
    :issues => :issue,
    :ping => :hook,
  }

  # Host filters should return nil if they cannot process the given payload+request pair
  def github_host_filter(input_stream)
    request = input_stream[:request]
    json = input_stream[:payload]
    req_repo = input_stream[:repo]

    return nil unless request['x-github-event']

    repo = json[:repository]
    return nil unless repo
    repo = repo[:full_name]
    return nil unless repo

    return nil unless repo == req_repo

    event = request.header['x-github-event'].first.to_sym

    obj = nil
    link = nil
    title = nil

    event_key = GITHUB_EVENT_KEY[event] || event

    # :issue_comment needs special handling because it has two primary objects
    # (the issue and the comment), and we take stuff from both
    obj = json[event_key] || json[:issue]
    if obj
      link = json[:comment][:html_url] rescue nil if event == :issue_comment
      link ||= obj[:html_url] || obj[:url]
      title = obj[:title]
    else
      link = json[:html_url] || json[:url] || json[:compare]
    end
    title ||= json[:zen] || json[:commits].last[:message].lines.first.chomp rescue nil

    stream_hash = { :event => event,
                    :event_key => event_key,
                    :ref => json[:ref],
                    :author => (json[:sender][:login] rescue nil),
                    :action => json[:action] || event,
                    :title => title,
                    :link => link
    }

    stream_hash[:ref] ||= json[:base][:ref] if json[:base]

    num = json[:number] || obj[:number] rescue nil
    stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => event_key.to_s.gsub('_', ' ') } if num
    num = json[:size] || json[:commits].size rescue nil
    stream_hash[:number] = _("%{num} commits") % { :num => num } if num

    case event
    when :watch
      stream_hash[:number] ||= 'watching 👀%{watchers_count}' % json[:repository]
    when :star
      stream_hash[:number] ||= 'star ☆ %{watchers_count}' % json[:repository]
    end

    debug stream_hash

    return input_stream.merge stream_hash
  end

  GITLAB_EVENT_ACTION = {
    'push' => 'pushed',
    'tag_push' => 'pushed tag',
    'note' => 'commented on'
  }

  def gitlab_host_filter(input_stream)
    request = input_stream[:request]
    json = input_stream[:payload]
    req_repo = input_stream[:repo]

    return nil unless request['x-gitlab-event']

    repo = json[:project]
    return nil unless repo
    repo = repo[:path_with_namespace]
    return nil unless repo

    return nil unless repo == req_repo

    event = json[:object_kind]
    if not event
      debug "No object_kind found in JSON"
      return nil
    end

    event_key = :object_attributes
    obj = json[event_key]

    user = json[:user] # may be nil: some events use keys such as user_username
    # TODO we might want to unify this at the rbot level

    # comments have a noteable_type, but this is not the key of the object used
    # so instead we just look for the known keys
    notable = nil
    [:commit, :merge_request, :issue, :snippet].each do |k|
      if json.has_key?(k)
        notable = json[k]
        break
      end
    end

    link = obj ? obj[:url] : nil
    title = notable ? notable[:title] : obj ? obj[:title] : nil
    title ||= json[:commits].last[:title] rescue nil

    # TODO https://docs.gitlab.com/ee/user/project/integrations/webhooks.html

    stream_hash = { :event => event,
                    :event_key => event_key,
                    :ref => json[:ref],
                    :author => user ? user[:username] : json[:user_username],
                    :action => GITLAB_EVENT_ACTION[event] || (obj ? (obj[:action] || 'created') :  event),
                    :title => title,
                    :link => link,
                    :text => obj ? (obj[:note] || obj[:description]) : nil
    }

    stream_hash[:ref] ||= obj[:target_branch] if obj

    num = notable ? (notable[:iid] || notable[:id]) : obj ? obj[:iid] || obj[:id] : nil
    stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => (obj[:noteable_type] || event).to_s.gsub('_', ' ') } if num
    num = json[:total_commits_count]
    stream_hash[:number] = _("%{num} commits") % { :num => num } if num

    debug stream_hash
    return input_stream.merge stream_hash
  end

  def initialize
    super
    define_filters

    # @repos is hash the maps each reapo to a hash of watchers
    # channel => filter
    @repos = {}
    if @registry.has_key?(:repos)
      @repos = @registry[:repos]
    end
  end

  def name
    "webhook"
  end

  def save
    @registry[:repos] = Hash.new.merge @repos
  end

  def help(plugin, topic="")
    case topic
    when "watch"
      ["webhook watch #{Bold}repository#{Bold} #{Bold}filter#{Bold} [in #{Bold}\#channel#{Bold}]: announce webhook triggers matching the given repository, using the given output filter.",
       "the repository should be defined as service:name where service is known service, and name the actual repository name.",
       "example: webhook watch github:ruby-rbot/rbot github"].join("\n")
    when "unwatch"
      " unwatch #{Bold}repository#{Bold} [in #{Bold}\#channel#{Bold}]: stop announcing webhhoks from the given repository"
    else
      " [un]watch <repository> [in #channel]: manage webhhok announcements for the given repository in the given channel"
    end
  end

  def watch_repo(m, params)
    repo = params[:repo]
    chan = (params[:chan] || m.replyto).downcase
    filter = params[:filter] || :default

    @repos[repo] ||= {}
    @repos[repo][chan] = filter
    m.okay
  end

  def unwatch_repo(m, params)
    repo = params[:repo]
    chan = (params[:chan] || m.replyto).downcase

    if @repos.has_key?(repo)
      @repos[repo].delete(chan)
      m.okay
      if @repos[repo].empty?
        @repos.delete(repo)
        m.reply _("No more watchers, I'll forget about %{repo} altogether") % params
      end
    else
      m.reply _("repo %{repo} not found") % params
    end
  end

  # Display the host filters
  def list_host_filters(m, params)
    ar = @bot.filter_names(@hostkey)
    if ar.empty?
      m.reply _("No custom service filters registered")
    else
      m.reply ar.map { |k| k.to_s }.sort!.join(", ")
    end
  end

  # Display the known output filters
  def list_output_filters(m, params)
    ar = @bot.filter_names(@outkey)
    ar.delete(:default)
    if ar.empty?
      m.reply _("No custom output filters registered")
    else
      m.reply ar.map { |k| k.to_s }.sort!.join(", ")
    end
  end

  # Display the known repos and watchers
  def list_repos(m, params)
    if @repos.empty?
      m.reply "No repos defined"
      return
    end
    msg = @repos.map do |repo, watchers|
      [Bold + repo + Bold, watchers.map do |channel, filter|
        "#{channel} (#{filter})"
      end.join(", ")].join(": ")
    end.join(", ")
    m.reply msg
  end

  def filter_hook(json, request)
    announce_method = @bot.config['webhook.announce_method']

    debug request
    debug json

    @repos.each do |s_repo, watchers|
      host, repo = s_repo.split(':', 2)
      key = @bot.global_filter_name(host, @hostkey)
      error "No host filter for #{host} (from #{s_repo})" unless @bot.has_filter?(key)

      debug key
      processed = @bot.filter(key, { :payload => json, :request => request, :repo => repo })
      debug processed
      next unless processed

      # TODO if we see that the same output filter is applied to multiple channels,
      # we should group the channels by filters and only do the output processing once
      watchers.each do |channel, filter|
        begin
          key = @bot.global_filter_name(filter, @outkey)
          key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)

          debug key
          output = @bot.filter(key, processed)
          debug output

          @bot.__send__(announce_method, channel, output)
        rescue => e
          error "Failed to announce #{json} for #{repo} in #{channel} with filter #{filter}"
          debug e.inspect
          debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
        end
      end
      # match found, stop checking
      break
    end
  end

  def process_hook(m, params)
    json = nil
    begin
      json = JSON.parse(m.req.body, :symbolize_names => true)
    rescue => e
      error "Failed to parse request #{m.req}"
      debug m.req
      debug e.inspect
      debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
    end

    # Send the response early
    if not json
      m.send_plaintext("Failed\n", 400)
      return
    end

    m.send_plaintext("OK\n", 200)

    begin
      filter_hook(json, m.req)
    rescue => e
      error e
      debug e.inspect
      debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
    end
  end

end

plugin = WebHookPlugin.new
plugin.web_map "/webhook", :action => :process_hook

plugin.map 'webhook watch :repo :filter [in :chan]',
  :action => :watch_repo,
  :defaults => { :filter => nil }

plugin.map 'webhook unwatch :repo [in :chan]',
  :action => :unwatch_repo

plugin.map 'webhook list [repos]',
  :action => 'list_repos'

plugin.map 'webhook [list] filters',
  :action => 'list_output_filters'

plugin.map 'webhook [list] hosts',
  :action => 'list_host_filters'

plugin.map 'webhook [list] services',
  :action => 'list_host_filters'