├── .env.example ├── .github └── FUNDING.yml ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── config.ru ├── lib ├── cache.rb ├── esa_client.rb └── slack_api_client.rb ├── logo.png └── main.rb /.env.example: -------------------------------------------------------------------------------- 1 | SLACK_OAUTH_ACCESS_TOKEN="" 2 | ESA_ACCESS_TOKEN="" 3 | ESA_TEAM_NAME="" 4 | REDIS_URL="" # キャッシュとしてRedisを利用する場合のみ 5 | GOOGLE_CLOUD_PROJECT="" # キャッシュとしてFirestoreを利用する場合のみ 6 | FIRESTORE_COLLECTION="" # キャッシュとしてFirestoreを利用する場合のみ 7 | # (Optional) ESA_MAX_ARTICLE_LINES="" 8 | # (Optional) ESA_MAX_COMMENT_LINES="" 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [FromAtom] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/cfd32d9174496f7d6d54a43929973e6a8d4deb6e/Ruby.gitignore 2 | 3 | *.gem 4 | *.rbc 5 | /.config 6 | /coverage/ 7 | /InstalledFiles 8 | /pkg/ 9 | /spec/reports/ 10 | /spec/examples.txt 11 | /test/tmp/ 12 | /test/version_tmp/ 13 | /tmp/ 14 | 15 | # Used by dotenv library to load environment variables. 16 | # .env 17 | 18 | ## Specific to RubyMotion: 19 | .dat* 20 | .repl_history 21 | build/ 22 | *.bridgesupport 23 | build-iPhoneOS/ 24 | build-iPhoneSimulator/ 25 | 26 | ## Specific to RubyMotion (use of CocoaPods): 27 | # 28 | # We recommend against adding the Pods directory to your .gitignore. However 29 | # you should judge for yourself, the pros and cons are mentioned at: 30 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 31 | # 32 | # vendor/Pods/ 33 | 34 | ## Documentation cache and generated files: 35 | /.yardoc/ 36 | /_yardoc/ 37 | /doc/ 38 | /rdoc/ 39 | 40 | ## Environment normalization: 41 | /.bundle/ 42 | /vendor/bundle 43 | /lib/bundler/man/ 44 | 45 | # for a library or gem, you might want to ignore these files since the code is 46 | # intended to run in multiple environments; otherwise, check them in: 47 | # Gemfile.lock 48 | # .ruby-version 49 | # .ruby-gemset 50 | 51 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 52 | .rvmrc 53 | 54 | 55 | ### https://raw.github.com/github/gitignore/cfd32d9174496f7d6d54a43929973e6a8d4deb6e/Global/macOS.gitignore 56 | 57 | # General 58 | .DS_Store 59 | .AppleDouble 60 | .LSOverride 61 | 62 | # Icon must end with two \r 63 | Icon 64 | 65 | 66 | # Thumbnails 67 | ._* 68 | 69 | # Files that might appear in the root of a volume 70 | .DocumentRevisions-V100 71 | .fseventsd 72 | .Spotlight-V100 73 | .TemporaryItems 74 | .Trashes 75 | .VolumeIcon.icns 76 | .com.apple.timemachine.donotpresent 77 | 78 | # Directories potentially created on remote AFP share 79 | .AppleDB 80 | .AppleDesktop 81 | Network Trash Folder 82 | Temporary Items 83 | .apdisk 84 | 85 | 86 | .env -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'puma' 4 | gem 'sinatra' 5 | gem 'sinatra-contrib' 6 | gem 'esa' 7 | gem 'redis' 8 | gem 'google-cloud-firestore' 9 | 10 | group :development do 11 | gem 'dotenv', require: 'dotenv/load' 12 | end -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.1) 5 | public_suffix (>= 2.0.2, < 6.0) 6 | concurrent-ruby (1.1.10) 7 | connection_pool (2.3.0) 8 | dotenv (2.8.1) 9 | esa (1.18.0) 10 | faraday (>= 0.9, < 2.0) 11 | faraday_middleware (>= 0.12, < 2.0) 12 | mime-types (>= 2.6, < 4.0) 13 | multi_xml (>= 0.5.5, < 1.0) 14 | faraday (1.10.2) 15 | faraday-em_http (~> 1.0) 16 | faraday-em_synchrony (~> 1.0) 17 | faraday-excon (~> 1.1) 18 | faraday-httpclient (~> 1.0) 19 | faraday-multipart (~> 1.0) 20 | faraday-net_http (~> 1.0) 21 | faraday-net_http_persistent (~> 1.0) 22 | faraday-patron (~> 1.0) 23 | faraday-rack (~> 1.0) 24 | faraday-retry (~> 1.0) 25 | ruby2_keywords (>= 0.0.4) 26 | faraday-em_http (1.0.0) 27 | faraday-em_synchrony (1.0.0) 28 | faraday-excon (1.1.0) 29 | faraday-httpclient (1.0.1) 30 | faraday-multipart (1.0.4) 31 | multipart-post (~> 2) 32 | faraday-net_http (1.0.1) 33 | faraday-net_http_persistent (1.2.0) 34 | faraday-patron (1.0.0) 35 | faraday-rack (1.0.0) 36 | faraday-retry (1.0.3) 37 | faraday_middleware (1.2.0) 38 | faraday (~> 1.0) 39 | gapic-common (0.15.1) 40 | faraday (>= 1.9, < 3.a) 41 | faraday-retry (>= 1.0, < 3.a) 42 | google-protobuf (~> 3.14) 43 | googleapis-common-protos (>= 1.3.12, < 2.a) 44 | googleapis-common-protos-types (>= 1.3.1, < 2.a) 45 | googleauth (~> 1.0) 46 | grpc (~> 1.36) 47 | google-cloud-core (1.6.0) 48 | google-cloud-env (~> 1.0) 49 | google-cloud-errors (~> 1.0) 50 | google-cloud-env (1.6.0) 51 | faraday (>= 0.17.3, < 3.0) 52 | google-cloud-errors (1.3.0) 53 | google-cloud-firestore (2.7.2) 54 | concurrent-ruby (~> 1.0) 55 | google-cloud-core (~> 1.5) 56 | google-cloud-firestore-v1 (~> 0.0) 57 | rbtree (~> 0.4.2) 58 | google-cloud-firestore-v1 (0.8.0) 59 | gapic-common (>= 0.10, < 2.a) 60 | google-cloud-errors (~> 1.0) 61 | google-cloud-location (>= 0.0, < 2.a) 62 | google-cloud-location (0.2.0) 63 | gapic-common (>= 0.10, < 2.a) 64 | google-cloud-errors (~> 1.0) 65 | google-protobuf (3.21.10) 66 | googleapis-common-protos (1.3.12) 67 | google-protobuf (~> 3.14) 68 | googleapis-common-protos-types (~> 1.2) 69 | grpc (~> 1.27) 70 | googleapis-common-protos-types (1.4.0) 71 | google-protobuf (~> 3.14) 72 | googleauth (1.3.0) 73 | faraday (>= 0.17.3, < 3.a) 74 | jwt (>= 1.4, < 3.0) 75 | memoist (~> 0.16) 76 | multi_json (~> 1.11) 77 | os (>= 0.9, < 2.0) 78 | signet (>= 0.16, < 2.a) 79 | grpc (1.50.0) 80 | google-protobuf (~> 3.21) 81 | googleapis-common-protos-types (~> 1.0) 82 | jwt (2.5.0) 83 | memoist (0.16.2) 84 | mime-types (3.4.1) 85 | mime-types-data (~> 3.2015) 86 | mime-types-data (3.2022.0105) 87 | multi_json (1.15.0) 88 | multi_xml (0.6.0) 89 | multipart-post (2.2.3) 90 | mustermann (3.0.0) 91 | ruby2_keywords (~> 0.0.1) 92 | nio4r (2.5.8) 93 | os (1.1.4) 94 | public_suffix (5.0.1) 95 | puma (6.0.0) 96 | nio4r (~> 2.0) 97 | rack (2.2.4) 98 | rack-protection (3.0.4) 99 | rack 100 | rbtree (0.4.5) 101 | redis (5.0.5) 102 | redis-client (>= 0.9.0) 103 | redis-client (0.11.2) 104 | connection_pool 105 | ruby2_keywords (0.0.5) 106 | signet (0.17.0) 107 | addressable (~> 2.8) 108 | faraday (>= 0.17.5, < 3.a) 109 | jwt (>= 1.5, < 3.0) 110 | multi_json (~> 1.10) 111 | sinatra (3.0.4) 112 | mustermann (~> 3.0) 113 | rack (~> 2.2, >= 2.2.4) 114 | rack-protection (= 3.0.4) 115 | tilt (~> 2.0) 116 | sinatra-contrib (3.0.4) 117 | multi_json 118 | mustermann (~> 3.0) 119 | rack-protection (= 3.0.4) 120 | sinatra (= 3.0.4) 121 | tilt (~> 2.0) 122 | tilt (2.0.11) 123 | 124 | PLATFORMS 125 | ruby 126 | 127 | DEPENDENCIES 128 | dotenv 129 | esa 130 | google-cloud-firestore 131 | puma 132 | redis 133 | sinatra 134 | sinatra-contrib 135 | 136 | BUNDLED WITH 137 | 2.3.24 138 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 FromAtom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec rackup config.ru -p $PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](logo.png) 2 | 3 | Slackに貼られたesaのURLを展開するSlack Appです。 4 | 5 | ## Cache 6 | Kujakuには簡単なキャッシュ機構があります。これは、短時間に同一のesa URLがUnfurlされた場合に、esaのAPIを叩きすぎてRateLimitedにならない為に存在します。 7 | 8 | キャッシュにはRedisもしくはFirestoreが利用できます。`.env.example`を参考に環境変数を指定することで、キャッシュに用いるサービスを選ぶことができます。なお、RedisとFirestoreの両方が利用可能な場合はRedisが優先されます。 9 | 10 | ## :warning:Herokuの無料枠が終了します:warning: 11 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/FromAtom/Kujaku) 12 | 13 | HerokuおよびHeroku Redisの無料枠は終了されます。KujakuをHerokuでホスティングする(している)場合はご注意ください。 14 | 15 | - https://blog.heroku.com/next-chapter 16 | 17 | ## LICENSE 18 | [MIT](LICENSE) 19 | 20 | ## Note 21 | Icon made by Freepik from [www.flaticon.com](https://www.flaticon.com) 22 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kujaku", 3 | "description": "Slack App that to unfurl url of esa.io", 4 | "keywords": [ 5 | "productivity", 6 | "team", 7 | "esa", 8 | "slack" 9 | ], 10 | "repository": "https://github.com/FromAtom/Kujaku", 11 | "env": { 12 | "SLACK_OAUTH_ACCESS_TOKEN": { 13 | "description": "ex) xoxp-XXXXXXXX-XXXXXXXX-XXXXX" 14 | }, 15 | "ESA_ACCESS_TOKEN": { 16 | "description": "Personal Access Token (Require read permission)" 17 | }, 18 | "ESA_TEAM_NAME": { 19 | "description": "Esa team name of targeted for URL unfurling" 20 | }, 21 | "ESA_MAX_ARTICLE_LINES": { 22 | "description": "Maximum number of lines of article text outputting by Kujaku. (default: 10)", 23 | "required": false 24 | }, 25 | "ESA_MAX_COMMENT_LINES": { 26 | "description": "Maximum number of lines of comment text outputting by Kujaku. (default: 10)", 27 | "required": false 28 | } 29 | }, 30 | "image": "heroku/ruby", 31 | "addons": [ 32 | { 33 | "plan": "heroku-redis:hobby-dev" 34 | }, 35 | { 36 | "plan": "papertrail" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | Bundler.require 4 | require './main.rb' 5 | run Sinatra::Application 6 | -------------------------------------------------------------------------------- /lib/cache.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | require 'json' 3 | require 'google/cloud/firestore' 4 | 5 | class Cache 6 | REDIS_TTL = 60 * 60 # 1時間 7 | FIRE_STORE_TTL = 60 * 60 # 1時間 8 | 9 | def initialize 10 | if redis_enable? 11 | @redis = Redis.new(:url => ENV['REDIS_URL']) 12 | end 13 | 14 | if firestore_enable? 15 | @collection_id = ENV['FIRESTORE_COLLECTION'] 16 | @firestore = Google::Cloud::Firestore.new 17 | end 18 | end 19 | 20 | def get(key) 21 | return get_redis(key) if redis_enable? 22 | return get_firestore(key) if firestore_enable? 23 | end 24 | 25 | def set(key, info) 26 | if redis_enable? 27 | set_redis(key, info) 28 | end 29 | 30 | if firestore_enable? 31 | set_firestore(key, info) 32 | end 33 | end 34 | 35 | private 36 | def redis_enable? 37 | ENV['REDIS_URL'] && !ENV['REDIS_URL'].empty? 38 | end 39 | 40 | def firestore_enable? 41 | ENV['FIRESTORE_COLLECTION'] && !ENV['FIRESTORE_COLLECTION'].empty? 42 | end 43 | 44 | def get_redis(key) 45 | # ttl導入前のkeyが残り続けることを避ける 46 | @redis.keys.each do |key| 47 | if @redis.ttl(key) == -1 48 | @redis.expire(key, REDIS_TTL) 49 | end 50 | end 51 | 52 | @redis.get(key) 53 | end 54 | 55 | def set_redis(key, info) 56 | json = { 57 | info: info 58 | }.to_json 59 | 60 | @redis.set(key, json, ex: REDIS_TTL) 61 | end 62 | 63 | def set_firestore(key, info) 64 | json = { 65 | info: info, 66 | expires_at: Time.now + FIRE_STORE_TTL 67 | } 68 | doc = @firestore.doc(firestore_key(key)) 69 | doc.set(json) 70 | end 71 | 72 | def get_firestore(key) 73 | ref = @firestore.doc(firestore_key(key)) 74 | return nil if ref.get.fields.nil? 75 | return ref.get.fields.to_json 76 | end 77 | 78 | def firestore_key(key) 79 | @collection_id + "/" + key 80 | end 81 | end -------------------------------------------------------------------------------- /lib/esa_client.rb: -------------------------------------------------------------------------------- 1 | require 'esa' 2 | require 'json' 3 | require 'time' 4 | 5 | require_relative 'cache' 6 | 7 | class EsaClient 8 | ESA_ACCESS_TOKEN = ENV['ESA_ACCESS_TOKEN'] 9 | ESA_TEAM_NAME = ENV['ESA_TEAM_NAME'] 10 | ESA_MAX_ARTICLE_LINES = ENV.fetch('ESA_MAX_ARTICLE_LINES', '10').to_i 11 | ESA_MAX_COMMENT_LINES = ENV.fetch('ESA_MAX_COMMENT_LINES', '10').to_i 12 | 13 | def initialize 14 | @esa_client = Esa::Client.new( 15 | access_token: ESA_ACCESS_TOKEN, 16 | current_team: ESA_TEAM_NAME 17 | ) 18 | @cache = Cache.new 19 | end 20 | 21 | def get_post(post_number) 22 | cache_json = @cache.get(post_number) 23 | unless cache_json.nil? 24 | puts '[LOG] cache hit' 25 | cache = JSON.parse(cache_json) 26 | return cache['info'] 27 | end 28 | 29 | post = @esa_client.post(post_number).body 30 | return {} if post.nil? 31 | 32 | title = post['full_name'] 33 | title.insert(0, '[WIP] ') if post['wip'] 34 | footer = generate_footer(post) 35 | 36 | info = { 37 | title: unescape(title), 38 | title_link: post['url'], 39 | author_name: post['created_by']['screen_name'], 40 | author_icon: post['created_by']['icon'], 41 | text: article_text(post), 42 | color: '#3E8E89', 43 | footer: footer, 44 | ts: Time.parse(post['updated_at']).to_i 45 | } 46 | 47 | @cache.set(post_number, info) 48 | return info 49 | end 50 | 51 | def get_comment(post_number, comment_number) 52 | cache_json = @cache.get("comment-#{comment_number}") 53 | unless cache_json.nil? 54 | puts '[LOG] cache hit' 55 | cache = JSON.parse(cache_json) 56 | return cache['info'] 57 | end 58 | 59 | post = @esa_client.post(post_number, {include: "comments"}).body 60 | return {} if post.nil? 61 | 62 | comment = nil 63 | post['comments'].each do |c| 64 | if c['id'] == comment_number.to_i 65 | comment = c 66 | break 67 | end 68 | end 69 | 70 | if comment.nil? 71 | comment = @esa_client.comment(comment_number).body 72 | end 73 | return {} if comment.nil? 74 | 75 | post_name = post['full_name'] 76 | post_name.insert(0, '[WIP] ') if post['wip'] 77 | title = "#{post_name} へのコメント" 78 | footer = generate_footer(comment) 79 | info = { 80 | title: title, 81 | title_link: comment['url'], 82 | author_name: comment['created_by']['screen_name'], 83 | author_icon: comment['created_by']['icon'], 84 | text: comment_text(comment), 85 | color: '#3E8E89', 86 | footer: footer, 87 | ts: Time.parse(comment['updated_at']).to_i 88 | } 89 | 90 | @cache.set("comment-#{comment_number}", info) 91 | return info 92 | end 93 | 94 | private 95 | def article_text(post) 96 | unescape(post['body_md'].lines[0, ESA_MAX_ARTICLE_LINES].map{ |item| item.chomp }.join("\n")) 97 | end 98 | 99 | def comment_text(comment) 100 | unescape(comment['body_md'].lines[0, ESA_MAX_COMMENT_LINES].map{ |item| item.chomp }.join("\n")) 101 | end 102 | 103 | def unescape(str) 104 | str.gsub('#', '#').gsub('/', '/') 105 | end 106 | 107 | def generate_footer(post) 108 | updated_user_name = post.dig('updated_by', 'screen_name') 109 | unless updated_user_name.nil? 110 | return "Updated by #{updated_user_name}" 111 | end 112 | 113 | created_user_name = post.dig('created_by', 'screen_name') || 'unknown' 114 | return "Created by #{created_user_name}" 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/slack_api_client.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'json' 3 | 4 | class SlackApiClient 5 | SLACK_API_ENDPOINT = 'https://slack.com/api/chat.unfurl'.freeze 6 | SLACK_AUTH_KEY = ENV['SLACK_OAUTH_ACCESS_TOKEN'] 7 | 8 | def initialize 9 | uri = URI.parse(SLACK_API_ENDPOINT) 10 | @req = Net::HTTP::Post.new(uri.request_uri) 11 | @req['Content-Type'] = 'application/json; charset=utf-8' 12 | @req['Authorization'] = "Bearer #{SLACK_AUTH_KEY}" 13 | 14 | @https = Net::HTTP.new(uri.host, uri.port) 15 | @https.use_ssl = true 16 | end 17 | 18 | def request(json) 19 | @req.body = json 20 | 21 | res = @https.request(@req) 22 | puts "[LOG] slack response: #{res.body}" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FromAtom/Kujaku/ac0e9cd753a49c2c07b0b9d7de7cca98155e9655/logo.png -------------------------------------------------------------------------------- /main.rb: -------------------------------------------------------------------------------- 1 | ENV["RACK_ENV"] ||= "development" 2 | Bundler.require(:default, ENV["RACK_ENV"]) 3 | 4 | require 'sinatra' 5 | require 'sinatra/reloader' if development? 6 | require 'json' 7 | 8 | require_relative 'lib/esa_client' 9 | require_relative 'lib/slack_api_client' 10 | require_relative 'lib/cache' 11 | 12 | ESA_TEAM_NAME = ENV['ESA_TEAM_NAME'] 13 | 14 | post '/' do 15 | puts '[START]' 16 | params = JSON.parse(request.body.read) 17 | 18 | case params['type'] 19 | when 'url_verification' 20 | # サーバの正当性検証 21 | challenge = params['challenge'] 22 | return { 23 | challenge: challenge 24 | }.to_json 25 | when 'event_callback' 26 | channel = params.dig('event', 'channel') 27 | 28 | ts = params.dig('event', 'message_ts') 29 | links = params.dig('event', 'links') 30 | 31 | unfurls = {} 32 | links.each do |link| 33 | url = link['url'] 34 | 35 | if url =~ /\Ahttps:\/\/#{ESA_TEAM_NAME}.esa.io\/posts\/(\d+)#comment-(\d+).*\z/ 36 | post_number = $1 37 | comment_number = $2 38 | esa = EsaClient.new 39 | attachment = esa.get_comment(post_number, comment_number) 40 | unfurls[url] = attachment 41 | elsif url =~ /\Ahttps:\/\/#{ESA_TEAM_NAME}.esa.io\/posts\/(\d+).*\z/ 42 | post_number = $1 43 | esa = EsaClient.new 44 | attachment = esa.get_post(post_number) 45 | unfurls[url] = attachment 46 | end 47 | end 48 | 49 | payload = { 50 | channel: channel, 51 | ts: ts, 52 | unfurls: unfurls 53 | }.to_json 54 | 55 | slackApiClient = SlackApiClient.new 56 | slackApiClient.request(payload) 57 | end 58 | 59 | return {}.to_json 60 | end 61 | --------------------------------------------------------------------------------