9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gemini APIとCloud Runを使ってMastodonにサンタの日常(?)を呟いてみた

Last updated at Posted at 2025-12-12

はじめに

友達の「自社の独自キャラクターを作ったけど、中々プロモーションに活かせていない」という話から、AIを活用しキャラクターの性格や特性に応じたSNS自動投稿をする仕組みを作ってみました。

概要

投稿文の生成からSNSへの投稿までは以下の流れで行います。

  1. SNS投稿文の生成
  2. AIによる投稿内容のチェック
  3. 人による投稿の承認/却下
  4. SNSへの投稿(メンションがあれば1へ)

システムは個人的に身近で使える環境のGoogle Cloudにて主に以下のサービスを使用しました。

  • 投稿文自動生成 : Gemini API
  • サービスの実装 : Cloud Run(Python 3.13)
  • キャラ設定や投稿内容の保存 : Firestore

人による承認/却下の送付先として、Webhookを経由してSpaceに、
投稿先SNSは、プライベートなSNSとして構築できるMastodonとしました。

やったこと

「サンタの世界」の生成

友達の独自キャラクターを記事で紹介する許可が出なかったので笑、季節的に🎄が近いこともあり、「サンタの世界」として世界観及びキャラクターをCopilotに作ってもらいました。
Copilot_20251209_132423.png
image.png

Mastodonの構築とアカウント作成

Xserver上で簡単に構築できる仕組みがあったのでこちらを利用しました。

サーバを立てたら6キャラクター分アカウントを作成し、「ユーザ設定」→「開発」にて連携するアプリを登録し(アプリ名のみ入れればよし)、アクセストークンを取得します。

mastodon_access_token.png

Gemini APIキー取得

Google AI StudioにGoogleアカウントでログインし、左下の「Get API Key」を選択します。

APIキーの作成画面になるので、右上の「APIキーを作成」を選択します。
ApiKey.png

キーの名前とこのシステム用に作成したプロジェクトを選択し「キーを作成」とするとAPIキーが発行されます。

Mastodonのアクセストークンと合わせてSecret Managerに登録し、環境変数から参照できるようにしておきます。

設定情報の登録

Gemeni APIを呼び出す際に渡す世界観やキャラの設定情報をFirestoreに登録します。
character:
 Mastodonのアカウント名をドキュメント名とし、名前と設定とバージョンを保存します。
キャラクターを追加したい場合はこのコレクションに追加すると自動的に投稿者及びアノテーション先として選択されます(別途アクセストークンの設定は必要です)
settings:
 サンタの世界の設定や、投稿内容のチェックプロンプトを保存します。
firestore.png

サービスの実装

以下の4つのサービスを実装しました。

  1. ランチャー
  2. SNS投稿文生成
  3. 投稿文チェックとSpaceへの送信
  4. SNSへの投稿

1. ランチャー

Cloud Schedulerから定期的に呼ばれルサービスです。キャラクターをランダムに選択し、SNS投稿文の生成サービスを呼び出します。

# Firestoreクライアントの初期化
try:
    # FirestoreにアクセスするプロジェクトIDとアクセス先のデータベース名を指定
    firestore_client = firestore.Client(
        project = PROJECT_ID,
        database = DATABASE_ID
    )
except Exception as e:
    # 開発環境などで認証設定がない場合のエラー処理

キャラクターはcharacterコレクション内のドキュメント一覧から抽出します。

def get_document_ids_in_collection(collection_name: str) -> list[str]:
    if not firestore_client:
        print("エラー: Firestoreクライアントが初期化されていません。")
        return []
    try:
        # 1. コレクションへの参照を取得
        collection_ref = firestore_client.collection(collection_name)  
        # 2. コレクション内の全ドキュメントを取得
        # get() メソッドは DocumentSnapshot オブジェクトのリストを返します
        docs = collection_ref.stream()        
        # 3. 各ドキュメントスナップショットからIDを抽出
        document_ids = [doc.id for doc in docs]        
        return document_ids
    except Exception as e:

ランダムに選択したキャラクターのアカウントIDを渡して投稿生成サービスを呼び出します。

@functions_framework.http
def auto_toot(request):
    # キャラクターのアカウントID一覧を取得
    ids = get_document_ids_in_collection(CHARACTER_COLLECTION)
    if ids:
        # ランダムに選択し、投稿生成サービスを呼び出す
        target_id = random.choice(ids)
        data_to_send = {
            "chara_type": target_id
        }
        try:
            call_response = requests.get(
                CLOUD_RUN_BASE_URL,
                params=data_to_send # 辞書として渡すことで、requestsがURLエンコードと構築を行う
            )
            call_response.raise_for_status() # HTTPエラーが発生したら例外を発生
            return f"トート生成サービスを呼び出しました{target_id}" ,200          
        except requests.exceptions.HTTPError as e:
            # 次のサービスがエラーを返した場合
            print(f"WARNING: トート生成ササービスからのエラー: {e}")
            return f"トート生成サービス呼び出しに失敗しました: {e}", 200 # 200を維持するかは要件次第
    return f"トートを生成しませんでした: " ,200

2. SNS投稿文の生成

Gemini APIを呼ぶためにGemini クライアントを初期化します。

# Geminiクライアントの初期化
try:
    client = genai.Client()
except Exception as e:
    print(f"Geminiクライアントの初期化に失敗しました: {e}")
    client = None

キャラクター設定や背景情報はpromptとは別にsystem_instructionとして渡します。

    try:
        response = client.models.generate_content(
            model="gemini-2.5-flash",  # または gemini-2.5-pro など
            contents=prompt,
            config=types.GenerateContentConfig(
                # システムインストラクションを渡す
                system_instruction=character_dic.get('prompt') + kingdom_dic.get('prompt')
            ),
        )
        tweet_content = response.text
    except Exception as e:
        # Geminiが失敗したら即座に500エラーを返す
        return f"Gemini APIエラー: {e}", 500
    try:
        request_id = str(uuid.uuid4())
        firestore_client.collection(request_chara + '_toot').document(request_id).set({
            'toot_content': tweet_content,
            'status': 'pending', # 承認待ち
            'created_at': datetime.datetime.now()
        })
    except Exception as e:
        # Firestoreが失敗したら即座に500エラーを返す
        return f"Firestoreエラー: {e}", 500
    

3. 投稿文チェックとWebhookへのメッセージ送信

投稿内容とNGチェックプロンプトを渡して、適切かチェックします。

# FirestoreからNGチェックプロンプトを取得
    prompt_ref = firestore_client.collection('settings').document('toot_check_setting')
    prompt_doc = prompt_ref.get()
    if not prompt_doc.exists:
        # プロンプトに対応するデータがない場合はエラー
        return "トートのチェックプロンプトを設定してください", 404
    prompt_dic = prompt_doc.to_dict()
    prompt = prompt_dic.get('content') 
   
    # リクエストパラメータで取得した投稿ID及びキャラクターをキーにしてデータを取得
    doc_ref = firestore_client.collection(request_chara + '_toot').document(request_id)
    doc = doc_ref.get()
    if not doc.exists:
        # IDに対応するデータがない場合はエラー
        return f"チェック待ちのデータが見つかりません:{request_chara}_toot", 404
    toot_data = doc.to_dict()
    toot_content = toot_data['toot_content']

    # toot内容についてチェックを依頼する
    try:
        response = client.models.generate_content(
            model="gemini-2.5-flash",  # または gemini-2.5-pro など
            contents = toot_content + prompt,
        )
        check_status = response.text
    except Exception as e:
        # Geminiが失敗したら即座に500エラーを返す
        return f"Gemini APIエラー: {e}", 500

チェックを通れば、Webhookにメッセージを送信します。通らなければ再度投稿を生成するサービスを呼び出します。

if check_status == "OK":
    try:
        create_url = f"{CLOUD_RUN_BASE_URL}?chara_type={request_chara}"
        approve_url = f"{CLOUD_RUN_POST_URL}?id={request_id}&chara_type={request_chara}"
        chat_payload = {
            # Google Chatの基本テキスト形式を使用
            "text": (
                f"🎄【ツイート承認依頼】🎄\n\n"
                f"---{character_name}のツイート文言---\n"
                f"```\n{toot_content}\n```\n\n"
                f"--- \n\n"
                f"✅ 承認してトート: \n{approve_url}\n\n"
                f"❌ NGにして再生成: \n{create_url}\n"
            )
        }
        # Webhookにメッセージを送信
        response = requests.post(
            CHAT_WEBHOOK_URL, 
            json=chat_payload,
            headers={"Content-Type": "application/json"}
        )
        # HTTPステータスコードをチェックし、200番台以外なら例外を発生
        response.raise_for_status()
        return "トート案をメッセージで送りました",200
    except requests.exceptions.HTTPError as e:
    

Space宛にこんなのが届きます。
スクリーンショット 2025-12-12 20.17.21.png
承認してトートのURLを選択すると、Mastodonへ投稿されます。

4. SNSへの投稿

ここまできたら、あとは呟くのみです。
呟いたのち、投稿にメンションが含まれていればそのキャラクターで再度投稿を生成します。
メンションがある限り延々と投稿が作成されます。

# アクセストークンはキャラ別に取得
mastodon_access_token = os.environ.get(request_chara.upper() + '_ACCESS_TOKEN')
# 認証情報の存在チェック
if not all([MASTODON_API_BASE, mastodon_access_token]):
    return "Error: Mastodon credentials missing from environment variables.", 500

try:
    # 初期化
    api = Mastodon(
        api_base_url  = MASTODON_API_BASE,
        access_token  = mastodon_access_token
    )
    # 投稿
    api.toot(format(tweet_content))
    # トート実行後、ステータスを更新
    doc_ref.update({'status': 'approved'})
    # トート文言にメンションがあるか確認
    #ids = get_document_ids_in_collection(CHARACTER_COLLECTION)
    mentions, found = extract_present_ids(tweet_content, CACHED_IDS)
    if found:
        target_id = mentions[0]
        data_to_send = {
            "chara_type": target_id,
            "previous_post_id": request_id
        }
        try:
            call_response = requests.get(
                CLOUD_RUN_BASE_URL,
                params=data_to_send # 辞書として渡すことで、requestsがURLエンコードと構築を行う
            )
            call_response.raise_for_status() # HTTPエラーが発生したら例外を発生
            return f"トート生成サービスを呼び出しました{target_id}" ,200          
        except requests.exceptions.HTTPError as e:
            # 次のサービスがエラーを返した場合
            print(f"WARNING: トート生成ササービスからのエラー: {e}")
            return f"トート生成サービス呼び出しに失敗しました: {e}", 200 # 200を維持するかは要件次第
        except requests.exceptions.RequestException as e:
            # ネットワークエラーの場合
            print(f"WARNING: トート生成ササービスへの接続エラー: {e}")
            return f"トート生成サービスへの接続に失敗しました: {e}", 200
    return f"Successfully tooted: {tweet_content}", 200

except Exception as e:
     print(f"Mastodon Error: {e}")
    return f"Error posting to Mastodon: {e}", 500

呟いてみました

アノテーションなし

アノテーションなし.png

アノテーションあり

以下のプロンプトを与えました。

---
# 前の投稿
[ここに投稿内容を記載]
---

# 応答の指示
上記の「前の投稿」に対する返信を、以下の制約と条件に従って120文字程度で生成してください。

## 制約
* 返信は120文字程度に収めてください。
* キャラクターへの返信や独り言を含めてください。
* 自分自身(投稿者)に話しかけないでください。

## メンションと話しかけのルール
* サンタ・ソル:@Santa_sol
* ブリザード:@blizzard
* フィン:@finn
* ミナ:@mina
* サンタ・ノルド:@santa_nord
* サンビーム:@sunbeam

サンタ・ノエルとフィンは何となく会話がしているように見えますが、メンションを適当に終わらせるための「たまには独り言をつぶやく」というpromptが影響し、サンタ・ソルは前の会話を無視して、独り言をつぶやいてしまいました。
アノテーションあり.png

おわりに

ここまで読んでいただきありがとうございました。
Mastodonサーバは別として、無料で最低限投稿に必要なサービスを作ることができました。
キャラクター同士の掛け合いは、今回の例ではキャラクター設定が少なかったこともあり思ったより盛り上がりませんでしたが、メンションも入れてキャラ同士が勝手にやりとりするようにしたことにより、

  • 「同じことばかり話してるなぁ」→キャラ設定の見直し
  • 「こんなことも言うのか、ありやな」→キャラへの愛着の深まり

とキャラを見直すきっかけにもなったようで、友達からは良い反応をもらえました。
もっと整えて、次は沢山キャラを持っている自治体さんに売り込む?!笑

9
1
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
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?