LoginSignup

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

定時にDiscordで何か投稿してくれるBotをGASで作る

Last updated at Posted at 2023-12-06

この記事は、SLP KBIT AdventCalendar 2023 7日目の記事となるはずのものです。

はじめに

こんにちは、na_11です。
高専から編入してきて、単位だとか課題だとかに追われたと思ったらもう11月のようです。時の流れは早いですね。

今回は、昔から抱えていた「定時に何か処理をしたい、だけど持っているPCは起動しているか分からないのでそれに頼りたくない」な状況を解決するかもしれないGASを使ってみる記事です。

概要

Google Apps Scriptを使用して、20:00とかの定時にDiscordへメッセージを投げるBotを作成します。なんとサーバ不要です。
凡庸なメッセージを投稿してもつまらないので、「ボ」「ー」「・」をランダムな順番で並べたものを投稿します。

Google Apps Script?

略してGAS。Googleのサーバーを使って任意のタイミングでJavaScriptなプログラムを走らせることのできるサービスです。
毎週日曜日の0~1時の間に1+1の演算を無意味に行っても良いですし、毎日20~21時の間に「ボーボボボボー・ボボ」とDiscordに投稿してもOK、Googleカレンダーが更新されたら通知を...も可能です。

常にプログラムを走らせるといったことはできません。何なら動作時間等で上限が設けられているため、うっかり長ったらしい処理を書かないようにしましょう。さもなければ(一見)何もしていないのに動かなくなります。

作る

GASしていきます。

まずはシンプルなものを

まずは何かシンプルなものを作ります。GAS公式サイトを開き、左上の「新しいプロジェクト」を押してコーディングを初めます。

ひとまず、左上の「無題のプロジェクト」は分かりやすい名前に変えておきましょう(画像ではtestとしました)。
myfunctionは別に必須ではありませんが、どこかにmain関数的な存在は必要です。Pythonのようにいきなり処理を書き始めることはできません。

image.png
(「test」は命名としてよくないとおもう)

GASはJavaScriptの感覚で色々な処理を書くことができます(と言うよりも確かJavaScript)。ということで、次のように書いてみます。

function main() {
  console.log(1+1)
}

Ctrl+Sで保存し、(ここではfunction mainとしたため)main関数を選んで実行します。
image.png

実行ログが開かれて...

image.png

良さそうです。
GASでは好きな関数をエントリーポイントとして実行させることができます。
逆にコード1行目から実行させることはできません。だからmain関数的関数が必要なんですね。

保存はこまめに行いましょう。
と言うより、保存しないと変更内容が実行結果などに反映されない気がします。「直ってない!? なんで!?」「保存してなかった」がないように心がけたいものです。

Discordに投稿する

Webhookを使って投稿します。そのためにはWebhook URLが必要です。
Discordを開き、投稿したいチャンネルの設定画面を開き、連携中サービス→ウェブフックと進みます。後は道なりに進んでWebhook URLを手に入れます。
image.png

次に、投稿するための関数を作り、これを使って投稿してみましょう。

投稿するための関数.js
function post_discord(name, content, url){
  const payload = {
    username: name,
    content: content,
  };

  UrlFetchApp.fetch(url,{
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload),
  })
}

nameには表示させたいユーザー名を、contentには本文を渡します。urlはWebhook URLです。
さっきのmain関数で使うだけなので答えは書かなくてもいいと思いました。

実行すると権限を渡すか脅されますが、証明書の切れたhttpsサイトを開く時のような手順で承認します。

image.png

できました。
ちなみに、Discordではいつの間にかMarkdown記法が使えるようになったみたいです。

本題

では本題、ボボボーボ・ーボボボットを作成していきます。
ボボボーボ・ボーボボボットの仕様の確認をしましょう。ボボボーボ・ボーボボボットは「ボ」と「ー」と「・」をランダムな順番で並べ、20:00に投稿します。

配列内の要素をランダムに並び替えて文字列にして返すreturn_all_element_random関数を定義しておきました。

bobobo-bo_bo-bobobot.js
const WEBHOOK_URL = "[WebhookのURLをここに入れる]"

function main() {
  result = return_all_element_random([..."ボボボーボ・ボーボボ"])

  console.log(result)

  post_discord(
    "ボボボーボ・ボーボボボット",
    result,
    WEBHOOK_URL,
  )
}

function return_all_element_random(array){
  result = ""
  do{
    result += array.splice(Math.floor(Math.random()*array.length),1)[0]
  }while(array.length!=0)
  return result
}

function post_discord(name, content, url){
  const payload = {
    username: name,
    content: content,
  };

  UrlFetchApp.fetch(url,{
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload),
  })
}

image.png

ボボボーボ・ボーボボボットは正常に作動し、ボボボーボ・ボーボボからボーー・ボボボボボボが生成されたことが分かります。

実行トリガーを設定する

「毎日20:00実行」とかができない

後はボボボーボ・ボーボボボットを自動で20:00に動かすのみです。
自動でコードを動かすには、トリガーを設定します。

左のメニューバー(?)にカーソルをホバーし、目覚まし時計アイコンの「トリガー」をクリックします。
image.png

トリガーを追加、時間主導型、日付ベースのタイマー、時刻を選択...

image.png

あれっ?

かなりファジーです。これでは20:00にボボーボボーボボボットを動かしてボボボーボ・ボーボボできません。これでは日によって20:32にボボボーボ・ボーボボされてしまったり、20:59にボボボーボ・ボーボボされても文句は言えない状況です。

特定の日時ベースであれば分単位まで指定できますが、代わりに日付まで手動入力しなくてはならなくなります。

コードで日時ベーストリガーを自動追加する

しかし、抜け穴があります。
コードでトリガーが追加・削除できるのです。

古いトリガーを削除&20:00に指定した新しいトリガーをセットする関数を作り、これを先程のファジーな日付ベーストリガーを使って毎日18:00くらいに実行させます。

すると、18:00~19:00にトリガー追加関数が起動し、ボボボボ・ボーボボボット実行のトリガーをセット、20:00にボボボーボ・ボーボボボットが起動してボボボーボ・ボーボが行われるようになります。

既存トリガーを削除し新しいトリガーを作成する.js
function add_trigger(){
  delete_trigger();
  console.log("Trigger Creating");
  let date=new Date();
  date.setDate(date.getDate());
  date.setHours(20);
  date.setMinutes(0);
  date.setSeconds(0);
  let trigger = ScriptApp.newTrigger("[ボボボーボ・ボーボボボットメイン関数名]")
    .timeBased()
    .at(date)
    .create();
  console.log(date);
  Logger.log("Trigger Process Ended");
}

function delete_trigger() {
  Logger.log("Trigger Deleting");
  let allTriggers = ScriptApp.getProjectTriggers();
  console.log("   found: " + allTriggers.length + " trigger(s)")
  for (var i = 0; i < allTriggers.length; i++) {
    if (allTriggers[i].getHandlerFunction() == "[ボボボーボ・ボーボボボットメイン関数名]") {
      ScriptApp.deleteTrigger(allTriggers[i]);
      console.log("Deleted: "+allTriggers[i]);
    }
  }
}

(約3, 4年前にどこかのサイトから持ってきたものを増改築した記憶があるのですが、どこのサイトか出せませんでした...申し訳ない...)

では、このadd_triggerを18:00~19:00に実行するようにトリガーを設定して...(また権限を聞かれます)
image.png

image.png

20:00ピッタリです!

GASの実行数タブを見てみると...
image.png

指定どうり、ボボボーボ・ボーボボボットを20:00に起動するトリガー追加が18:28に行われ、20:00にボボボーボ・ボーボボボットが起動、ボボーボボボボーボ・が出力されました。
次の日の18:00~19:00に、発動済みのボボボーボ・ボーボボボットを20:00に起動するトリガーは削除され、新しいトリガーが登録されるはずです。多分。

とにかく、これで目的を達成できました。

おわりに

GASを使用して、定時にDiscordにメッセージを投げるスクリプトを作成しました。

GASはGoogle製というのもあり、GoogleカレンダーやGoogleスプレッドシートと連携が簡単にできます。
頑張ればDiscord等で「今週の予定一覧」を投稿したり、スプレッドシートに溜め込んだ過去の実行結果データを元に処理を行うこともできるでしょう。

過去の実行データを使う例

image.png

過去のボボボーボ・ボーボボボットの結果を記録し、重複が起こった場合はその回数や最終重複日時を表示します。
スプレッドシートにはボボボーボ・ボーボボボットの結果や実行日時が記録されています。

ぜひ何か試してみてはいかがでしょうか?

ちなみに、ボーボアニメは確か第13話が一番おすすめです。あまりにカオスで寒気がして風邪をひきかけました。


前の日 << アドベカレンダー >> 次の日


ふろく

なんとなく上記の「過去の実行データを使う例」のコードを配布します。
設計変更が重なった結果スパゲティになっていますが、GASコードをコピペし、Webhook URLとGoogleスプレッドシートのIDを定義すれば動くはずです。

スプレッドシートIDはGoogleドライブで適当にシートを作成して開き、URLを見ることで入手できます(https://docs.google.com/spreadsheets/d/XXXXXXXXXX/edit#gid=0のXXXXXXXXXXがID)

煮るなり焼くなり

20:00と0:00に投稿。
生成後にObserverが生成分とボボボーボ・ボーボボ間の編集距離と、重複有無を調べ表示。
スプレッドシートは[生成した文字列, DBスキームver, 生成日時, 新種なら1さもなくば0]のスキーム。

bobo.js
const WEBHOOK_URL = "[Webhook URL]"

const SHEET_ID = "[GoogleスプレッドシートID]"

const DEBUG = false // trueにすると実行結果をスプレッドシートへ保存しない
const CONBINATION_COUNT = 168

function main() {
  bobo = return_all_element_random([..."ボボボーボ・ボーボボ"])

  console.log(bobo)
  SendDiscord(bobo)

  let name = bobo.slice(bobo.length-4, bobo.length)
  let simil = similarity(bobo, "ボボボーボ・ボーボボ") 

  let checkResult = dup_check_add_sheat(bobo)
  console.log(checkResult)

  string = build_advanced_message(
    "それでは、今回の"+ name +"の結果を見ていきましょう",
    simil,
    build_dupMes(checkResult, name),
    checkResult[4],
    checkResult[1],
    build_footer(simil)
  )

  console.log(string)
  SendDiscordAdvanced(string)
}

function return_all_element_random(array){
  result = ""
  do{
    result += array.splice(Math.floor(Math.random()*array.length),1)[0]
  }while(array.length!=0)
  return result
}

function return_element_random(array){
  return array[Math.floor(Math.random()*array.length)]
}

function str_accuracy(str, correct){
  let min = minValue(str.length, correct.length)
  let max = maxValue(str.length, correct.length)

  let count = 0
  for (let i=0; i<min; i++){
    if (str[i] == correct[i]){
      count++
    }
  }

  console.log("Hit:" + count + " max: " + max)

  return count*1.0 / max
}
function str_accuracy_compare_figure(str,correct){
  if (str.length != correct.length) {
    return ""
  }

  result = ""

  for (let i=0; i<str.length; i++){
    if (str[i] == correct[i]){
      result += ":white_check_mark:"
    } else {
      result += ":x:"
    }
  }

  return result
}

function build_advanced_message(
  desc,
  simil,
  duplMess,
  newCount,
  allDatasNum,
  footer
){
  return `{
    "username": "Bobobo-boBo-boboObserver",
    "avatar_url": "https://dic.nicovideo.jp/oekaki/152101.png",
    "embeds": [{
        "description": "` + desc +`",
        "fields": [
          {
            "name": "類似度",
            "value": "` + simil * 100 +`%"
          },
          {
            "name": "",
            "value": ""
          },
          {
            "name": "重複",
            "value": "`+duplMess[0]+`",
            "inline": true
          },
          {
            "name": "最終重複",
            "value": "`+duplMess[1]+`",
            "inline": "true"
          },
          {
            "name": "",
            "value": ""
          },
          {
            "name": "コンプ率",
            "value": "`+newCount+`/`+CONBINATION_COUNT+` (`+(newCount/CONBINATION_COUNT*100).toFixed(3)+`%)",
            "inline": true
          },
          {
            "name": "全データ数",
            "value": "`+allDatasNum+`",
            "inline": true
          }
        ],
        "color": 16776456,
        "footer": {
          "text": "` + footer + `",
          "icon_url": "https://dic.nicovideo.jp/oekaki/152101.png"
        }
      }
  ]}
	`
}

function build_footer(acc){
  if (acc == 1.0){
    return "みんなありがとう"
  }

  return return_element_random([
    "フン",
    "神に感謝",
    "クッ ボーボボに負けた...",
    "順当な結果ですね"
  ])
}

function build_dupMes(checkData,str){
  if (checkData[0]==0){
    return ["なんと新種"+str+"のようです!","-"]
  }

  return [""+checkData[0]+"回目",""+ date_get_y_m_d_string(checkData[3]) +" ("+checkData[2]+"日前)"]
}
function date_get_y_m_d_string(date){
  return ""+date.getFullYear()+"/"+(date.getMonth()+1)+"/"+date.getDate()
}
function utc_to_jst(date){
  time.setHours(time.getHours() + 9)
  return time
}

function dup_check_add_sheat(str){
  //strには生成したボーボボ文字列が来ることが想定される
  let sheet = SpreadsheetApp.openById(SHEET_ID).getActiveSheet()
  let table = sheet.getDataRange().getValues()
  
  let col = table.length
  console.log("data length: " + col)

  let hitCount = 0
  let lastHit = new Date()
  let newCount = 0
  for (let i=0; i<col; i++){
    if (str == table[i][0]){
      hitCount++
      lastHit = new Date(table[i][2])
    }

    //scheme ver >= 2, new == 1
    if (table[i][1]>=2 &&table[i][3] == 1){
      newCount++
    }
  }

  if (!DEBUG){
    sheet.appendRow([
      str,
      2, //scheme ver
      new Date().toISOString(),
      hitCount==0? 1 : 0
    ])

    col++
    if (hitCount==0){newCount++}
  } else {console.log("Debug is on. result not saved")}
  
  let lastHitDif = Math.floor((new Date() - lastHit)/86400000)

  //ヒット数, 全データ数, 最終ヒットからの日数, 最終ヒットDate型, 確認新種数
  return [hitCount, col, lastHitDif, lastHit, newCount]
}

function levenshtein(s, t){
  if (s.length == 0){
    return t.length
  }
  if (t.length == 0){
    return s.length
  }

  if (s[0] == t[0]){
    return levenshtein(s.slice(1,s.length),t.slice(1,t.length))
  }

  let l1 = levenshtein(s,                    t.slice(1, t.length))
  let l2 = levenshtein(s.slice(1,s.length),  t)
  let l3 = levenshtein(s.slice(1,s.length),  t.slice(1, t.length))
  if (l1 > l2) { l1 = l2 }
  if (l1 > l3) { l1 = l3 }
  return l1 + 1
}
function normalizedLevenshtein(s, t){
  let maxLen = s.length
  if (t.length > maxLen){ maxLen = t.length}

  return levenshtein(s, t) / maxLen
}
function similarity(s, t){
  return -normalizedLevenshtein(s, t) + 1
}

function SendDiscord(string) {
  const payload = {
    username: "ボボボーボ・ボーボボボット 🕶",
    content: string,
  };

  UrlFetchApp.fetch(WEBHOOK_URL, {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload),
  });
}

function SendDiscordAdvanced(payload){
  UrlFetchApp.fetch(WEBHOOK_URL, {
    method: "post",
    contentType: "application/json",
    payload: payload,
  });
}

function minValue(a, b){
  if (a < b){
    return a
  }
  return b
}
function maxValue(a,b){
  if (a > b){
    return a
  }
  return b
}

function add_trigger(){
  delete_trigger();
  console.log("Trigger Creating");
  let date=new Date();
  date.setDate(date.getDate());
  date.setHours(20);
  date.setMinutes(0);
  date.setSeconds(0);
  let trigger = ScriptApp.newTrigger("main")
    .timeBased()
    .at(date)
    .create();
  console.log(date);
  date.setDate(date.getDate()+1);
  date.setHours(0);
  date.setMinutes(0);
  date.setSeconds(0);
  trigger = ScriptApp.newTrigger("main")
    .timeBased()
    .at(date)
    .create();
  console.log(date);
  Logger.log("Trigger Process Ended");
}
function delete_trigger() {
  Logger.log("Trigger Deleting");
  let allTriggers = ScriptApp.getProjectTriggers();
  console.log("   found: " + allTriggers.length + " trigger(s)")
  for (var i = 0; i < allTriggers.length; i++) {
    if (allTriggers[i].getHandlerFunction() == "main") {
      ScriptApp.deleteTrigger(allTriggers[i]);
      console.log("Deleted: "+allTriggers[i]);
    }
  }
}
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up