はじめに
友達の「自社の独自キャラクターを作ったけど、中々プロモーションに活かせていない」という話から、AIを活用しキャラクターの性格や特性に応じたSNS自動投稿をする仕組みを作ってみました。
概要
投稿文の生成からSNSへの投稿までは以下の流れで行います。
- SNS投稿文の生成
- AIによる投稿内容のチェック
- 人による投稿の承認/却下
- SNSへの投稿(メンションがあれば1へ)
システムは個人的に身近で使える環境のGoogle Cloudにて主に以下のサービスを使用しました。
- 投稿文自動生成 : Gemini API
- サービスの実装 : Cloud Run(Python 3.13)
- キャラ設定や投稿内容の保存 : Firestore
人による承認/却下の送付先として、Webhookを経由してSpaceに、
投稿先SNSは、プライベートなSNSとして構築できるMastodonとしました。
やったこと
「サンタの世界」の生成
友達の独自キャラクターを記事で紹介する許可が出なかったので笑、季節的に🎄が近いこともあり、「サンタの世界」として世界観及びキャラクターをCopilotに作ってもらいました。


Mastodonの構築とアカウント作成
Xserver上で簡単に構築できる仕組みがあったのでこちらを利用しました。
サーバを立てたら6キャラクター分アカウントを作成し、「ユーザ設定」→「開発」にて連携するアプリを登録し(アプリ名のみ入れればよし)、アクセストークンを取得します。
Gemini APIキー取得
Google AI StudioにGoogleアカウントでログインし、左下の「Get API Key」を選択します。
APIキーの作成画面になるので、右上の「APIキーを作成」を選択します。

キーの名前とこのシステム用に作成したプロジェクトを選択し「キーを作成」とするとAPIキーが発行されます。
Mastodonのアクセストークンと合わせてSecret Managerに登録し、環境変数から参照できるようにしておきます。
設定情報の登録
Gemeni APIを呼び出す際に渡す世界観やキャラの設定情報をFirestoreに登録します。
character:
Mastodonのアカウント名をドキュメント名とし、名前と設定とバージョンを保存します。
キャラクターを追加したい場合はこのコレクションに追加すると自動的に投稿者及びアノテーション先として選択されます(別途アクセストークンの設定は必要です)
settings:
サンタの世界の設定や、投稿内容のチェックプロンプトを保存します。

サービスの実装
以下の4つのサービスを実装しました。
- ランチャー
- SNS投稿文生成
- 投稿文チェックとSpaceへの送信
- 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宛にこんなのが届きます。

承認してトートの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
呟いてみました
アノテーションなし
アノテーションあり
以下のプロンプトを与えました。
---
# 前の投稿
[ここに投稿内容を記載]
---
# 応答の指示
上記の「前の投稿」に対する返信を、以下の制約と条件に従って120文字程度で生成してください。
## 制約
* 返信は120文字程度に収めてください。
* キャラクターへの返信や独り言を含めてください。
* 自分自身(投稿者)に話しかけないでください。
## メンションと話しかけのルール
* サンタ・ソル:@Santa_sol
* ブリザード:@blizzard
* フィン:@finn
* ミナ:@mina
* サンタ・ノルド:@santa_nord
* サンビーム:@sunbeam
サンタ・ノエルとフィンは何となく会話がしているように見えますが、メンションを適当に終わらせるための「たまには独り言をつぶやく」というpromptが影響し、サンタ・ソルは前の会話を無視して、独り言をつぶやいてしまいました。

おわりに
ここまで読んでいただきありがとうございました。
Mastodonサーバは別として、無料で最低限投稿に必要なサービスを作ることができました。
キャラクター同士の掛け合いは、今回の例ではキャラクター設定が少なかったこともあり思ったより盛り上がりませんでしたが、メンションも入れてキャラ同士が勝手にやりとりするようにしたことにより、
- 「同じことばかり話してるなぁ」→キャラ設定の見直し
- 「こんなことも言うのか、ありやな」→キャラへの愛着の深まり
とキャラを見直すきっかけにもなったようで、友達からは良い反応をもらえました。
もっと整えて、次は沢山キャラを持っている自治体さんに売り込む?!笑

