5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

kintoneとRaspberry Pi Zero Wで作ったBotとGASで、Googleフォームから投稿したご意見をDiscordのフォーラムに投稿する

Last updated at Posted at 2025-12-08

タイトルが長いですが、冷蔵庫の中にあるもので夕食を作るかのように、まわりにあるものでDiscordのBotを作りたかったお話です。
動かすことはできたんですが、実運用までには至りませんでした。
そのあたりは「おわりに」に、書いてます🥺

必要なもの

  • Raspberry Pi Zero W 3,000円ぐらい(2025-12-08現在)
  • kintone (ためすだけなら開発ライセンス)
  • Googleアカウント
  • めんどくさくてもやってみようかなという気持ち

ざっくり構成

「目安箱」に来たご意見をカテゴリ分けしてtagをつけ、Discordにフォーラムのスレッドとして投稿する。
tagはGeminiにつけてもらいましょう。GASのところでちょっぴり説明します。
ざっくりな流れはこんな感じ。

  1. Googleフォームに入力された内容をGoogle Acction Scriptでtag付けしてkintoneにPOST
  2. kintoneのアプリのレコードを例えば5分毎にチェック
  3. Discordへ未送信のものをDiscordへ投稿
  4. 投稿したらkintoneのレコードを送信済に書き換える

image.png

投稿されたイメージ
image.png

手順

作るのに何から手を付ければ良いのか?
自由ですが、個人の感情的な理由から以下のように作ってみます。

  1. Raspberry Pi Zero Wの設定をする
  2. Googleフォーム「目安箱」を作る
  3. Discordにフォーラムチャンネルを作る
  4. kintoneアプリ「目安箱」を作る
  5. GASを書く
  6. Discord Bot を作る
  7. Pythonを書く

Raspberry Pi Zero Wの設定をする

OSのインストールに手こずりました。
めちゃめちゃ手こずったので、個人的にはこちらを一番に片付けておくのをおすすめしたいです。
Raspberry Piの公式ドキュメントや以下を参考に設定してみてください。

Googleフォーム「目安箱」を作る

作り方の説明は省略しますが、以下のような目安箱を作りましょう。
API
image.png

スプレッドシートでも一覧みたいかもなので回答スプレッドシートにGAS追加する感じで作ります。

Discordにフォーラムチャンネルを作る

例えば今回は宛先を「事務局全体」と「団長副団長」で分けたいので、2つ作りましょう。
タグは何でも良いんですが、「建設的」「批判」「ほっこり」の3つにします。
それぞれ、チャンネルIDとタグIDを控えておきます。
ちなみに、このタグIDは、Pythonで実装するときにこんな感じで使います。

# 宛先ごとのフォーラムチャンネルとタグID(必要に応じて変更)
FORUM_CONFIG: Dict[str, Dict] = {
    "事務局全体": {
        "channel_id": 1371773780145475594,
        "tag_mapping": {
            "批判": 1371777315251421234,
            "建設的": 1371777364710658068,
            "ほっこり": 1371777241721339954,
        }
    },
    "団長副団長": {
        "channel_id": 1392586531457339472,
        "tag_mapping": {
            "批判": 1392588414053908644,
            "建設的": 1392588605339467776,
            "ほっこり": 1392588622556958830,
        }
    },
}

「目安箱」や「フォーラムチャンネル」に合わせてkintoneアプリを作る

例えばこんな感じに作りましょう。
タグや宛先はとりあえずPythonで書く↑のやつと合わせる感じで良いと思います。
image.png

APIトークンの設定もお忘れなく!
image.png

GASを書く

Googleフォームからkintoneに登録するGASを書きます。
GeminiのAPIはcybozu developer networkのこちらの記事が参考になるかと思います。
GeminiAPIで手書きのアンケート画像を読み込んで分析し、kintoneに保存しよう

GeminiAPIをGetしたらこんな感じでコードを書きます。
完全に趣味プログラムで誰にも見せないと余裕でいたらすごくカオスになって・・・
ちょっと直しました。

APIキーをベタ書きする感じにしてますが、GASにはスクリプトプロパティというのが設定できますのでそちらを使ってもらうとベター書きになるかと思います。
こんな感じで。
PropertiesService.getScriptProperties().getProperty('API_KEY');

const onSubmit = (e) => {
  // kintoneに登録する内容
  const name = e.namedValues['お名前'][0] ? e.namedValues['お名前'][0] : '匿名団員';
  const comment = e.namedValues['ご意見'][0];
  const address = e.namedValues['宛先'][0];

  const titleProperty =  getTitle(comment);
  const titleValue = titleProperty.title;
  const title = `${titleValue}${name})`;
  const usermessage = `ドーモ、${name}です。\n\n ${comment}`;
  const tags = titleProperty.tags;
  sendNotificationToGlitch(name, title, address, usermessage, tags);
};

const sendNotificationToGlitch = (namedValue, title, address, usermessage, tags) => {
  const body = {
    "app": "130",
    "record": {
        "お名前": {"value":namedValue},
        "タイトル":{"value":title},
        "宛先":{"value":address},
        "ご意見":{"value":usermessage},
        "タグ":{"value":tags}
    }
  };
  const options_post = {
    "method":"post",
    "contentType":"application/json",
    "headers" : {
      'X-Cybozu-API-Token' : "★★kintoneのAPIトークンをココに★★",
    },
    "payload":  JSON.stringify(body), 
  };
  // レコード追加!
  try {
    const response = UrlFetchApp.fetch('https://pyokotaro-team.cybozu.com/k/v1/record.json',options_post);
    Logger.log(`Response: ${response.getContentText()}`);
  } catch (e) {
    Logger.log(`Error: ${e.message}`);
  }
};

// Geminiに投稿内容からタイトル、タグなどを決めてもらう
const getTitle = (message) => {
  const APIkey = '★★GeminiAPIキーをここに★★'; 
  const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${APIkey}`;

  // プロンプト・・・結構適当
  const requestBody = {
    contents: [
      {
        parts: [
          {
            text: `以下の「ご意見文章」を要約し、短いタイトルを付けてください。
            結果は、タイトルのみの文字列にしてください。
            タイトルの前には、文章が建設的な要素があればその前に【建設的】を、さらにほっこりの要素がある場合は【ほっこり】を、さらに運営や団長副団長への指摘や批判的な要素があれば【批判】をつけて改行してください。
            複数要素が入る場合もあります。
            例:【批判】【建設的】
            タイトル
            「ご意見文章」
            ご意見文章は以下です:
            ${message}`
          }
        ]
      }
    ]
  };

  const response = UrlFetchApp.fetch(geminiUrl, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(requestBody)
  });

  // レスポンスの取得とログ出力
  const result = JSON.parse(response.getContentText());

  // 結果の解析
  if (result.candidates[0].content.parts[0].text) {
    const output = result.candidates[0].content.parts[0].text;
    const tags = [];

    const TAG_RULES = {
      '【批判】': '批判',
      '【建設的】': '建設的',
      '【ほっこり】': 'ほっこり',
    };

    // 条件に基づいてタグを追加
    for (const [keyword, tagName] of Object.entries(TAG_RULES)) {
      if (output.includes(keyword)) {
        tags.push(tagName);
      }
    }

    // 文字列から1行目と改行を削除
    const resultTitle = output.split('\n').slice(1).join('\n');

    return {title:resultTitle, tags:tags};
  } else {
    return `タイトルなし`;
  }
};

トリガーは「フォーム送信時」にしておきましょう。

Discord Bot を作る

こちらもほかの記事を参考にしていただいたほうがよいと思います。
こちらの記事に出てくる、トークンをメモっておきましょう。

Pythonを書いて実行する

筆者はPython書けないので、生成AIに手伝ってもらいながら書きました。
疑り深いので、一応これは何をしているところだ?というのを理解してから動かしました。
先程の参考記事のPart2も参考にさせていただきました。

.env というファイルに以下のような内容を書いておきます。
きっとこれに挑戦する方へ説明は不要かなと思います。

.envもbot.pyもhomeディレクトリに新しくディレクトリ作って入れておくと良いと思います。

.env
TOKEN=MTM3MTc1Njk3Nz・・・・DiscordBotを作るでメモったトークン
KINTONE_DOMAIN=xxxxxx.cybozu.com
KINTONE_APP_ID=127
KINTONE_API_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxx
bot.py
# bot.py
import os
import asyncio
import datetime
from typing import  List, Dict
import discord
from discord.ext import commands, tasks
from dotenv import load_dotenv
import aiohttp

# -------------------------------
# Discordメッセージ長(安全上限)
MAX_DISCORD_LEN = 1999

# .env読み込み
load_dotenv()
TOKEN = os.getenv("TOKEN")
KINTONE_DOMAIN = os.getenv("KINTONE_DOMAIN")
KINTONE_APP_ID = os.getenv("KINTONE_APP_ID")
KINTONE_API_TOKEN = os.getenv("KINTONE_API_TOKEN")

# 宛先ごとのフォーラムチャンネルとタグID(必要に応じて変更)
FORUM_CONFIG: Dict[str, Dict] = {
    "事務局全体": {
        "channel_id": 1371773780145475594,
        "tag_mapping": {
            "批判": 1371777315251421234,
            "建設的": 1371777364710658068,
            "ほっこり": 1371777241721339954,
        }
    },
    "団長副団長": {
        "channel_id": 1392586531457339472,
        "tag_mapping": {
            "批判": 1392588414053908644,
            "建設的": 1392588605339467776,
            "ほっこり": 1392588622556958830,
        }
    },
}

# -------------------------------
# Discord Intents
intents = discord.Intents.default()
intents.guilds = True
bot = commands.Bot(command_prefix="!",intents=intents)

# -------------------------------
# HTTP共通
AIOHTTP_TIMEOUT = aiohttp.ClientTimeout(total=15)

async def aio_get_json(session: aiohttp.ClientSession, url: str, *, headers=None, params=None):
    async with session.get(url, headers=headers, params=params) as resp:
        resp.raise_for_status()
        return await resp.json()

async def aio_post_json(session: aiohttp.ClientSession, url: str, *, headers=None, json_body=None):
    async with session.post(url, headers=headers, json=json_body) as resp:
        resp.raise_for_status()
        return await resp.json()

async def aio_put_json(session: aiohttp.ClientSession, url: str, *, headers=None, json_body=None):
    async with session.put(url, headers=headers, json=json_body) as resp:
        resp.raise_for_status()
        return await resp.json()

# -------------------------------
# Kintone API(非同期)
async def fetch_unprocessed_records_async() -> List[dict]:
    url = f"https://{KINTONE_DOMAIN}/k/v1/records.json"
    headers = {"X-Cybozu-API-Token": KINTONE_API_TOKEN}
    params = {"app": KINTONE_APP_ID, "query": '投稿ステータス in ("")'}
    async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as sess:
        data = await aio_get_json(sess, url, headers=headers, params=params)
        return data.get("records", [])

async def mark_as_processed_async(record_id: str):
    url = f"https://{KINTONE_DOMAIN}/k/v1/record.json"
    headers = {
        "X-Cybozu-API-Token": KINTONE_API_TOKEN,
        "Content-Type": "application/json",
    }
    payload = {
        "app": KINTONE_APP_ID,
        "id": record_id,
        "record": {"投稿ステータス": {"value": ""}}
    }
    async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as sess:
        return await aio_put_json(sess, url, headers=headers, json_body=payload)

# -------------------------------
# kintoneチェック本体(非同期)
async def run_kintone_check_once():
    records = await fetch_unprocessed_records_async()
    for record in records:
        thread_name = record["タイトル"]["value"] or "(タイトルなし)"
        message = record["ご意見"]["value"] or "(内容なし)"
        destination = record["宛先"]["value"]
        record_id = record["$id"]["value"]
        tags = record["タグ"]["value"] 
        created_at_str = record["作成日時"]["value"] 

        # UTC ISO8601 → JST (+9h)
        created_at_dt = datetime.datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
        created_at_jst = created_at_dt.astimezone(datetime.timezone(datetime.timedelta(hours=9)))
        created_at_formatted = created_at_jst.strftime("%Y年%m月%d日%H時%M分")
        full_text = f"{message}\n\n投稿日時: {created_at_formatted}"

        # 宛先設定
        cfg = FORUM_CONFIG.get(destination)

        channel_id = cfg["channel_id"]
        tag_mapping = cfg["tag_mapping"]

        # チャンネル取得
        channel = bot.get_channel(channel_id) or await bot.fetch_channel(channel_id)

        # タグ解決(available_tags が None の可能性も守る)
        tag_ids = [tag_mapping[t] for t in (tags or []) if t in tag_mapping]
        applied_tags: List[discord.ForumTag] = []
        available = getattr(channel, "available_tags", None)
        if available:
            tag_index = {t.id: t for t in available}
            applied_tags = [tag_index[i] for i in tag_ids if i in tag_index]

        # スレッド作成(分割送信対応)
        first_chunk = full_text[:MAX_DISCORD_LEN]
        kwargs = dict(
            name=thread_name,
            auto_archive_duration=1440,
            content=first_chunk,
        )
        # 空ならapplied_tagsは渡さない
        if applied_tags:
            kwargs["applied_tags"] = applied_tags

        thread, first_message = await channel.create_thread(**kwargs)

        if len(full_text) > MAX_DISCORD_LEN:
            for i in range(MAX_DISCORD_LEN, len(full_text), MAX_DISCORD_LEN):
                await thread.send(full_text[i:i+MAX_DISCORD_LEN])

        # Discord投稿済みにする
        await mark_as_processed_async(record_id)

# -------------------------------
# Botイベント
@bot.event
async def on_ready():
    # 起動直後に1回だけ即実行
    asyncio.create_task(run_kintone_check_once())
    # 定期ループを開始
    if not kintone_check_loop.is_running():
        kintone_check_loop.start()

# -------------------------------
# 定期ループ
@tasks.loop(minutes=5)
async def kintone_check_loop():
    await run_kintone_check_once()

@kintone_check_loop.before_loop
async def _before_kintone_loop():
    await bot.wait_until_ready()
# -------------------------------
# 実行
if __name__ == "__main__":
    if not TOKEN:
        raise RuntimeError("TOKEN が未設定です。.env を確認してください。")
    bot.run(TOKEN)

実行!

まずPythonを実行します。
実行するときはバックグラウンドで実行しましょう。

nohup python3 main.py &

止めるときは対象のプロセスIDを探してkillします。

ps aux | grep bot.py
kill XXXXX

落ち着いた感じになったら、Googleフォームから目安箱投稿をして待ちましょう。
きっとDiscordに通知が来るはずです👀

おわりに

こちらは、私が所属していたとある団体で使おうと思っていたものなのですが、夢半ばにしてkintone導入までいくことができず終わってしまいました🥺
供養!というかたちですが書かせていただきました😆
同じようなことがやりたいがうまくいかない!という場合は有料になるかもですがkwskお教えしますのでその時は連絡してね!

おしまい!

5
0
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
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?