💎

Ruby + AWS LambdaでDiscordの/コマンドを受け取る関数を作る話2

2024/04/21に公開

Ruby + AWS LambdaでDiscordの/コマンドを受け取る関数を作る話2

前回の続きと事情が変わった話

前の記事: Ruby + AWS LambdaでDiscordの/コマンドを受け取る関数を作る話1の続きです、と言いたいところですが、ぽしゃりました。

  1. tweetのURLのリストを受け取って、その内容のembedメッセージをリンクとともに送信してくれるbotを作ろうとした。既存のソリューションとしてComebackTwitterEmbedがあるが、不満な部分があったので自弁しようとした。
  2. ところが、twitterからのデータの取得ができない
  3. 調べたところ、次の事実が判明した
    3.1 Twitter社(X社)の利用規約では、合意のないスクレイピング・クロールを禁止している。合意の当事者は明示されていないものの、Twitter社(X社)とスクレイピング・クロールを実行する・させようとする人(自然人・法人を含む)であると推測される
    3.2 合意のあるデータの取得、つまりTwitter APIの使用は可能であるが、費用のかからないプランでは任意のtweetに対してAPIによる情報の取得ができず、低廉なプランでも非現実的な取得制限があり、実装しようとすると多額の費用がかかる。
    3.3 実際問題として、Twitter社(X社)はtweetへのアクセスに対し、多段で複雑な対策を施しており、APIを用いる情報取得以外は非常に困難である
  4. ComebackTwitterEmbedなどがどのようにやっているか不明である。オープンソースらしい

よってですね、botを作る動機が消滅してしまいました。

ただ何をやろうとしていたかは書いておきます。

共通の要素

下のLambda関数も含めてライセンスはNYSL、つまり、活用・応用について、自由になさっていただいて構いません。

共通のDockerfile

FROM public.ecr.aws/sam/build-ruby3.3:latest-x86_64
RUN gem update bundler 
CMD "/bin/bash"

また、以下の二つのlambda_function.rbは、LLMのClaud3/GTP4をふんだんに活用しました。

そのほか、エラーが出たときにどこでつっかえになっているか判断するためにこまめにputs "テキスト"をしていますが、これは全て削除して問題ありません。

Lambda関数ExpandTwitter

Gemfile

source "https://rubygems.org"

gem 'ed25519'

lambda_function.rb

require 'net/http'
require "json"
require "ed25519"
require 'aws-sdk-lambda'

puts "定数を読み込みます"
DC_APPLICATION_ID = ENV['DC_APPLICATION_ID']
DC_PUBLIC_KEY = ENV['DC_PUBLIC_KEY']
DC_TOKEN = ENV['DC_TOKEN']

# Slash コマンドを定義します。
COMMANDS = [
  {
    name: "e", description: "URLを受け取って、embedメッセージを返します", type: 1,
    options: [
      {
        name: "urls",
        description: "Twitter URL",
        type: 3,
        required: true
      }
    ]
  }
]

def do_command(command_name, urls, guild_id, channel_id)
  puts "コマンド実行関数です。"
  puts "AWSに接続します。"
  client = Aws::Lambda::Client.new(region: 'ap-northeast-1')
  puts "do_command"
  puts "command #{command_name}を受け取りました"
  case command_name
  when 'e' then
    puts "コマンドを実行します"
    payload = JSON.generate({'command_name': command_name, 'urls': urls, 'guild_id': guild_id, 'channel_id': channel_id})
    resp = client.invoke({
        function_name: 'get_tweet_post_discord',
        invocation_type: 'RequestResponse',
        log_type: 'None',
        payload: payload
    })
    puts resp.to_s
    response_content = "コマンドが実行されました"
  else
    puts "コマンドは実行されませんでした"
    raise
  end
rescue
  # 不明なコマンドの処理
  puts "不明なコマンドです"
  response_content = "不明なコマンドです"
ensure
  # レスポンスを作成
  puts "レスポンスを生成します"
  response = {
    'statusCode' => 200,
    'headers' => { 'Content-Type' => 'application/json' },
    'body' => {
      'type' => 4,
      'data' => {
        'content' => response_content
      }
    }.to_json
  }
  return response
end



def register_slash_commands(commands)
  # リクエストを作成します
  uri = URI.parse("https://discord.com/api/v8/applications/#{DC_APPLICATION_ID}/commands")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  request = Net::HTTP::Put.new(uri.request_uri, {'Authorization': "Bot #{DC_TOKEN}", 'Content-Type': 'application/json'})
  request.body = COMMANDS.to_json

  # リクエストを送信します
  response = http.request(request)

  # レスポンスを出力します
  puts response.body
end

class Responder
  # コンストラクタ。DC_PUBLIC_KEYを16進文字列に変換して、VerifyKeyを得る
  def initialize
    puts 'コンストラクタが呼び出されました'
    @verification_key = Ed25519::VerifyKey.new([DC_PUBLIC_KEY].pack("H*"))
  end

  def respond(event)
    
    puts 'リクエストのbodyをJSONでパースします'
    interaction = JSON.parse(event['body'])
     
    begin
      unless verify_request(event)
        raise
      end
    rescue
        # 不適なリクエストに対して401を返す
        puts 'error: 認証エラー'
        puts '不適なリクエストなので401をセットします'
        return [401, {"Content-Type": "text/plain"}, ["invalid request signature"]]
    end
    
    begin
      if interaction["type"] == 1
        # type: 1を返す
        puts '1を返します'
        return {type: 1}
      else
        raise
      end
    rescue
      # 400を返す
      puts 'error: 認証エラー'
      puts '400を返します'
      return [400, {"Content-Type": "text/plain"}, ["Unrecognized interaction type"]]
    end
  end

  private

  # タイムスタンプと署名を用いてリクエストを認証する
  def verify_request(event)
    puts 'ヘッダに書かれているx-signature-timestampからタイムスタンプを取得'
    timestamp = event['headers']['x-signature-timestamp']
#    current_time = Process.clock_gettime(Process::CLOCK_REALTIME)
#    clock_skew = (current_time - timestamp.to_i).abs
#    return false if clock_skew > ALLOWED_CLOCK_SKEW

    puts 'リクエストのbodyとタイムスタンプに対して署名を得る'
    signature = event['headers']['x-signature-ed25519'] #.pack("H*")
    puts 'signature: ' + signature.to_s.force_encoding("UTF-8")
    puts 'message: ' + timestamp + event['body']
    begin
      puts '署名を確認'
      @verification_key.verify([signature].pack("H*"), timestamp + event['body'])
      
      puts '署名確認に成功したのでtrueを返す'
      true
    rescue Ed25519::VerifyError
      puts '署名確認に失敗したのでerrorを返す'
      false
    end
  end
end

def lambda_handler(event:, context:)
### コマンドを登録するときに、コメントを外して実行します
#  return register_slash_commands(COMMANDS)
### Interaction Endpoint URLを認証するときに、コメントを外して実行します
#  return Responder.new.respond(event)


  puts "こんにちは!discordさん!イベントハンドラが呼ばれたよ"
  puts event.to_s
  puts event["headers"]['body']
  puts context.to_s


  # スラッシュコマンドを解析
  body = JSON.parse(event['body'])
  if body['data']['name']
    puts 'コマンドがあります。'
    puts '実行を試みます'
    
    puts 'name: ' << body['data']['name']
    puts 'options: ' << body['data']['options'].to_s
    puts 'guild: ' << body['guild'].to_s
    puts 'guild_id: ' << body['guild_id']
    puts 'channel_id: ' << body['channel_id']
    
    urls = body['data']['options'][0]['value'].split(/\s*https\:\/\//)
    urls.delete("")
    urls.map! do |url|
      # URLチェック
      unless url =~ /(twitter\.com)/ || url =~ /(x\.com)/
        "ERRER://" << url
      else
        "https://" << url
      end
    end
    
    puts urls
    
    
    res = do_command(body['data']['name'], urls, body['guild_id'], body['channel_id'])
  else
    puts エラーが発生しました
  end
rescue
  res = {
    'statusCode' => 200,
    'headers' => { 'Content-Type' => 'application/json' },
    'body' => {
      'type' => 4,
      'data' => {
        'content' => "エラーが発生しました"
      }
    }.to_json
  }
ensure
  return res
end

Lambda関数get_tweet_post_discord

こちらではnokogiriのgemを使っているので、コンテナ内で作ったzipファイルが9MBを超えます。

Gemfile

source "https://rubygems.org"

gem 'open-uri'
gem 'nokogiri'

lambda_function.rb

require 'net/http'
require 'json'
require 'nokogiri'

puts "定数を読み込みます"
DC_APPLICATION_ID = ENV['DC_APPLICATION_ID']
DC_PUBLIC_KEY = ENV['DC_PUBLIC_KEY']
DC_TOKEN = ENV['DC_TOKEN']



### Twitterからtweetの情報をとってくる関数。返り値はHash。
def fetch_tweet_info(url)
  puts "NokogiriでURLから情報をとってきます。"
  puts url
  doc = Nokogiri::HTML(open(url))

  puts "Nokogiriで、author、time、body、mediaを取り出します。mediasはmedia URLの配列です"
  author = doc.at_css('.tweet .username').text.strip
  time = doc.at_css('.tweet .time').text.strip
  body = doc.at_css('.tweet .tweet-text').text.strip
  medias = doc.css('.tweet .AdaptiveMedia-photoContainer img').map do |img|
    img['src']
  end

  puts "リプライを取得します。"
  replies = doc.css('.ThreadedConversation--loneTweet .tweet-text a').map do |a|
    a['href'] if a['href'].start_with?('/' + author + '/')
  end.compact

  puts "Hashを返します。"
  req = {
    "author": author,
    "url": url,
    "time": time,
    "body": body,
    "medias": medias,
    "replies": replies
  }
  
  push req
  
  return req
end


def create_embed(message)
  puts "message(hash)からembedメッセージを生成します"
  embed = {
    "title" => message.title,
    "description" => message.body,
    "color" => message.color,
    "fields" => [],
    "footer" => {
      "text" => "#{message.media} at #{message.time}",
      "icon_url" => message.media_icon
    }
  }
  

  
  if message.medias[0]
    embed["fields"] << {
      "name" => "Media",
      "value" => message.medias[0],
      "inline" => true
    }
  end
  if message.medias[1]
    embed["fields"] << {
      "name" => "Media",
      "value" => message.medias[1],
      "inline" => true
    }
  end
  if message.medias[2]
    embed["fields"] << {
      "name" => "Media",
      "value" => message.medias[2],
      "inline" => true
      }
  end
  if message.medias[3]
    embed["fields"] << {
      "name" => "Media",
      "value" => message.medias[3],
      "inline" => true
    }
  end
  
  return embed
end

# embedの配列とchannel_idを受け取って投稿する関数
def post_message_to_discord(embeds, channel_id)
  
  puts "リクエストを送るURIを生成"
  uri = URI("https://discord.com/api/channels/#{channel_id}/messages")
  
  puts "Net::HTTP::Post生成"
  request = Net::HTTP::Post.new(uri,
    'Content-Type' => 'application/json',
    'Authorization' => "Bot #{'DC_TOKEN'}"
  )

  
  request.body = {embeds: embeds}.to_json
  puts request.body
  puts 'discordチャンネルにメッセージを送出'
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(request)
  end

  case response
  when Net::HTTPSuccess then
    # メッセージが正常に投稿された場合の処理
    puts "送信が正常に終了しました。"
    return true
  else
    # エラーが発生した場合の処理
    puts "送信に失敗しました。"
    return false
  end
end


def lambda_handler(event:, context:)

  puts event
  
  puts "command" << event['command_name'].to_s
  puts "urls" << event['urls'].to_s
  puts "channel_id" << event['channel_id'].to_s
  
  unless "e" == event['command_name']
    puts "不明なイベントが呼び出されました"
  end
  
  tweets = event['urls'].map {|url|
    puts "url: #{url}を処理します" 
    fetch_tweet_info(url)
  }
  
  color = "118248"
  color16 = color.to_i(16)
  media_icon = "https://expandtwitter.s3.ap-northeast-1.amazonaws.com/icon/twittterlogo.svg"
  embeds = tweets.map{|tweet|
    create_embed({'title': "[#{tweet["author"]}](#{tweet["url"]})", "description": tweet["body"], "color": color16, "medeias": tweet["medeias"], media_icon: tweet["media_icon"], "time": tweet["time"]})
  }
  
  post_message_to_discord(embeds, event['channel_id'])
  
end

Discussion