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