はじめに
前回の記事(無料3,000リクエストでAI同士を喋らせ続ける —— さくらのAI Engine × GAS で作る「AIテラリウム」)では、複数のAIボットがSlack上で自律的に会話し続ける「AIテラリウム」の設計を紹介しました。
そこでは触れなかった話があります。「AI同士の会話を、チャンネル直下ではなくスレッド内の『返信』として成立させる」 ための実装です。
一見するとSlack APIの chat.postMessage に thread_ts を渡すだけの話に見えます。実際、公式ドキュメント上はそれで終わりです。
しかし Google Apps Script + スプレッドシートをキューとして使う という制約の中では、この thread_ts という値が驚くほど壊れます。今回は、AIテラリウムがスレッド返信を安定させるまでに踏んだ地雷と、その回避策を全部書きます。
1. そもそも Slack のスレッドはどう決まるのか
Slackのメッセージは、すべて ts(タイムスタンプ)という一意なIDを持ちます。形式は "1775649326.499519" のような、小数点以下6桁の文字列です。
スレッドのルールは単純です。
| 状況 | イベントに含まれる値 |
|---|---|
| チャンネル直下の新規投稿 |
ts のみ(thread_ts は無い) |
| スレッド内の返信 |
ts(自分のID)と thread_ts(親のID)の両方 |
したがって、受信側の「このメッセージが属するスレッドのID」は次の1行で求まります。
const slackThreadTs = event.thread_ts || event.ts || "";
thread_ts があればそれがスレッドID、なければ自分自身が新規スレッドの起点になる、というわけです。AIテラリウム側も同じ判定をしています。
const eventTs = rawTs || String(event.ts || "");
const eventThreadTs = event.thread_ts ? String(event.thread_ts) : eventTs;
// 新規スレッドかどうか
const isNewThread = (!event.thread_ts || event.thread_ts === eventTs);
そして返信するときは chat.postMessage に thread_ts を載せる。それだけです。
const payload = { channel, text };
if (threadTsStr) payload.thread_ts = threadTsStr;
……これで動けば記事は3行で終わっていました。終わりませんでした。
2. 地雷①:JSONパースの時点で ts が数値になる
GASのWebアプリは doPost(e) でPOSTを受け取ります。素直に書くとこうなります。
const body = JSON.parse(e.postData.contents);
const ts = body.event.ts; // ← ここが罠
Slackは ts を文字列で送ってくることが多いのですが、経路や実装によっては数値リテラルとして届くケースがあります。数値で届いた瞬間、1775649326.499519 は JavaScript の double に載り、末尾の桁が丸められる可能性が出てきます。
thread_ts は1桁でも違えば別スレッド扱いです。Slack APIは黙って新規投稿として処理し、チャンネル直下に流れます。エラーは出ません。サイレントに壊れるのが一番厄介でした。
対策は、パース前の生テキストから正規表現で文字列として抜くことです。
// Slack event
if (body.event && body.event.type) {
// ts の桁落ち防止:生テキストから ts を文字列として抽出
// 数値形式 ("ts":1775649326.499519) と文字列形式 ("ts":"1775649326.499519") の両方に対応
let rawTs = null;
let tsMatch = rawBody.match(/"ts"\s*:\s*"([^"]+)"/); // 文字列形式
if (!tsMatch) tsMatch = rawBody.match(/"ts"\s*:\s*([0-9.]+)/); // 数値形式
if (tsMatch) rawTs = String(tsMatch[1]);
return handleSlackEvent(body, rawTs);
}
抽出した rawTs は、パース済みの body と一緒に下流へ渡します。
function handleSlackEvent(body, rawTs) {
// ...
if (typeof _terrariumHandleSlackEvent === 'function') {
_terrariumHandleSlackEvent(body, rawTs);
}
}
正規表現でJSONを触るのは行儀が悪い、という指摘はその通りです。ただ「数値化される前に文字列として確保する」という目的に対しては、これが最も確実でした。
なお、GASの
execURL はリダイレクトを挟むためX-Slack-Signatureヘッダーが消失します。署名検証は実装不可能なので、LINORINではSLACK_CHANNEL_ID照合 +event_idによる重複排除で防御しています。
3. 地雷②:スプレッドシートが15桁で桁落ちする
AIテラリウムはキューをスプレッドシートで管理しています。GASの6分制限を回避するため、受信と処理を分離する設計です(前回記事参照)。
ここで2つ目の地雷を踏みます。Googleスプレッドシートの数値精度は15桁です。
1775649326.499519 は16桁。セルに素直に書き込むと、こうなります。
-
1775649326.49952に丸められる - あるいは
1.77565E+09と科学記法で表示される
どちらもスレッドIDとしては死んでいます。
対策は**「数式としてテキストを書き込む」**でした。
// ts と thread_ts は数値桁落ちを防ぐため文字列として保存
// Sheets は 15 桁を超える数値を桁落ちするため、数式でテキストとして扱う
const tsStr = String(ev.ts || "");
const threadTsStr = String(ev.thread_ts || "");
sheet.getRange(lastRow, 1).setValue(queueId);
sheet.getRange(lastRow, 2).setValue(ev.type || "");
sheet.getRange(lastRow, 3).setFormula('="' + tsStr + '"'); // ← 数式でテキスト化
sheet.getRange(lastRow, 4).setFormula('="' + threadTsStr + '"'); // ← 同上
sheet.getRange(lastRow, 5).setValue(ev.text || "");
="1775649326.499519" という数式にすることで、セルの中身は完全にテキストとして扱われます。
ログシート側は setNumberFormat("@")(プレーンテキスト書式)を使う方式にしています。
function _logTerrarium(threadTs, botId, role, message) {
const now = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
const sheet = _getTerrariumLogSheet();
const lastRow = sheet.getLastRow() + 1;
// thread_ts は 15 桁超の数値なので、テキストとして保存(桁落ち防止)
const threadTsStr = String(threadTs);
sheet.getRange(lastRow, 1).setValue(now);
sheet.getRange(lastRow, 2).setNumberFormat("@").setValue(threadTsStr); // ← 書式でテキスト化
sheet.getRange(lastRow, 3).setValue(botId);
sheet.getRange(lastRow, 4).setValue(role);
sheet.getRange(lastRow, 5).setValue(message);
}
そして読み出し側も対策が必要です。getValues() は数式を評価した「値」を返すため、せっかくテキストで入れても数値に戻される可能性があります。getDisplayValues()(画面表示上の文字列)を使います。
// ts と thread_ts はテキスト形式で取得(桁落ち防止のため getDisplayValues を使用)
const values = sheet.getRange(2, 1, lastRow - 1, 14).getValues();
const tsValues = sheet.getRange(2, 3, lastRow - 1, 1).getDisplayValues();
const threadTsValues = sheet.getRange(2, 4, lastRow - 1, 1).getDisplayValues();
受信時の正規表現抽出・書き込み時の setFormula / setNumberFormat・読み出し時の getDisplayValues。この3段ガードで、ようやく thread_ts が壊れずに往復するようになりました。
4. 地雷③:末尾のゼロが消えて比較が壊れる
3段ガードを入れてもまだバグが残りました。「スレッド履歴が取得できず、AIが会話を継続しない」という症状です。
原因は末尾ゼロでした。
1775649326.499500 のように末尾が0で終わる ts は、経路のどこかで 1775649326.4995 と正規化されてしまうことがあります。文字列比較では当然不一致になります。
"1775649326.4995" === "1775649326.499500" // false
これは値としては同じなのに、履歴検索で別スレッド扱いされます。対策は比較前に両辺を小数6桁へ正規化するヘルパーです。
function _padTsLocal_(ts) {
if (!ts && ts !== 0) return String(ts || "");
const s = String(ts);
const dot = s.indexOf(".");
if (dot < 0) return s + ".000000";
const dec = s.length - dot - 1;
return dec < 6 ? s + "0".repeat(6 - dec) : s;
}
thread_ts を比較するすべての箇所でこれを通します。
function _getThreadHistory(threadTs) {
const sheet = _getTerrariumLogSheet();
const lastRow = sheet.getLastRow();
if (lastRow <= 1) return [];
const startRow = Math.max(2, lastRow - 199);
const rows = sheet.getRange(startRow, 1, lastRow - startRow + 1, 5).getDisplayValues();
// thread_ts は Sheets 上でテキスト保存されているのでそのまま比較
const normalizedTarget = _padTsLocal_(threadTs);
const history = [];
for (let i = 0; i < rows.length; i++) {
if (rows[i][3] === "debug") continue;
const storedTs = String(rows[i][1] || "");
if (_padTsLocal_(storedTs) === normalizedTarget) {
history.push({
time: rows[i][0], thread_ts: storedTs,
bot_id: rows[i][2], role: rows[i][3], message: rows[i][4]
});
}
}
return history;
}
Slack API へ投げるときも同じ正規化をかけています。
// thread_ts は Slack API に文字列として渡す(数値だと精度落ちする)
const threadTsStr = threadTs ? String(_padTsLocal_(threadTs)) : null;
「同じ値なのに文字列としては違う」を潰す。 浮動小数点を文字列IDとして扱うシステムでは、正規化関数を1本用意して全比較を通すのが唯一の安全策でした。
5. 地雷④:username を指定するとスレッドを見失う
これは完全に想定外でした。
AIテラリウムでは、複数のゲストボットを1つのトークンで動かすことがあります。表示名を出し分けるために chat.postMessage の username / icon_emoji を使っていました。
ところが、この指定によってSlack側が別ユーザーの投稿として扱い、スレッドへの所属が正しく認識されないケースが発生しました。しかも chat:write.customize スコープが無い環境ではAPIエラーになります。
対策はフォールバック付きのリトライです。まずカスタム表示名つきで投稿し、スコープ不足で失敗したら username / icon_emoji を落として再送します。thread_ts は両方に必ず載せます。
function _postTerrariumMessage(channel, text, threadTs, bot, conf) {
const token = bot.token || conf.mainToken;
if (!token) { Logger.log("Terrarium: no token"); return null; }
// thread_ts は Slack API に文字列として渡す(数値だと精度落ちする)
const threadTsStr = threadTs ? String(_padTsLocal_(threadTs)) : null;
const payload = { channel, text };
if (threadTsStr) payload.thread_ts = threadTsStr;
// ゲストボットの表示名を設定(パートナーbotと区別するため)
// chat:write.customize スコープが必要。ない場合はフォールバック
if (bot.name && !bot.isPartner) {
payload.username = bot.name;
const emoji = bot.emoji || "";
if (emoji.startsWith(":")) payload.icon_emoji = emoji;
}
// 表示名が使えない場合でも誰の発言か分かるよう本文にも埋め込む
if (bot.name) {
payload.text = (bot.emoji || "") + "(" + bot.name + ")" + text;
}
const _post = function(pl) {
try {
const res = UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", {
method: "post", contentType: "application/json",
headers: { "Authorization": "Bearer " + token },
payload: JSON.stringify(pl), muteHttpExceptions: true
});
return JSON.parse(res.getContentText() || "{}");
} catch (e) { Logger.log("Terrarium Slack post exception: " + e); return null; }
};
let json = _post(payload);
// chat:write.customize 不足でエラーの場合、username/icon_emoji を外してリトライ
if (json && !json.ok &&
(json.error === "missing_scope" || json.error === "not_authed" || json.error === "invalid_auth")) {
Logger.log("Terrarium: Slack API error '" + json.error + "', retrying without username/icon_emoji");
var fallback = { channel: payload.channel, text: payload.text };
if (threadTsStr) fallback.thread_ts = threadTsStr;
json = _post(fallback);
}
if (!json || !json.ok) { Logger.log("Terrarium Slack error: " + (json && json.error)); return null; }
return json.message || { ts: (json.ts || "") };
}
ポイントは、表示名を本文側((bot)こんばんは のような接頭辞)にも二重で持たせたことです。APIの表示名機能が使えない環境でも、誰の発言かは失われません。
デバッグ行を仕込む
このあたりのバグは「投稿は成功しているのにスレッドに入らない」という形で出るため、ログが命でした。投稿直後に、送った値とSlackが返した値を並べて記録しています。
_logTerrarium(
threadTsStr ? "'" + threadTsStr : "new",
"_debug_",
"debug",
"sent_thread_ts=" + (payload.thread_ts || "null")
+ " / result_ts=" + msg.ts
+ " result_thread_ts=" + (msg.thread_ts || "none")
+ " / slack_error=" + (json.ok ? "none" : json.error)
);
sent_thread_ts と result_thread_ts を突き合わせれば、「渡した値が壊れていたのか」「Slackが受け付けなかったのか」を一発で切り分けられます。桁落ちバグの発見はこのログのおかげでした。なお debug ロールの行は履歴取得時に除外されるので、AIの文脈は汚しません。
6. 工夫①:スレッドの「初手」を確実に起こす
技術的な地雷を全部踏み終えても、まだ問題が残っていました。スレッドは立つが、誰も返信しないまま終わるのです。
AIテラリウムは「連鎖確率」で自己増殖を抑えています(前回記事の実装③)。ところがこの確率制御が、スレッドの初手にも効いてしまい、会話の種が芽を出さないまま枯れていました。
対策は2段階です。
対策A:初手は確率100%にする
const isHumanInvolved = !pending.is_bot;
const isThreadStart = (threadHistory.length <= 2);
const streak = _getAIStreak(threadTs);
// 初手(history ≤ 2)・synthetic・human 参加時は確率 100%
const prob = (isSynthetic || isThreadStart || isHumanInvolved)
? 1.0
: _replyProbability(streak, false);
if (Math.random() > prob) { Logger.log("Terrarium: SKIP probability"); return; }
スレッドが育ってからは確率で間引く。でも最初の一手だけは必ず打つ。これで「立ったまま無反応なスレッド」が消えました。
対策B:スレッドを立てた直後に、2発目を強制エンキューする
さらに根本的な問題として、Bot自身の投稿がSlackイベントとして返ってこないことがある(bot_message の配信設定やタイミングに依存する)ため、そもそも返信のトリガーが発火しませんでした。
そこで、スレッドを立てた側が自分で「返信してくれ」というイベントをキューに積みます。
const ts = posted.ts || String(Date.now());
_logTerrarium(ts, bot.name, "bot", text);
_createTerrariumThread(text, ts, bot.bot_id);
_incrementTerrariumDailyCount();
// SlackイベントがなければキューにフォールバックReplyを積む(dedup keyで重複防止)
_enqueueSyntheticReply(ts, bot);
// 2発目を強制エンキュー:synthetic 後の別Botによる返信を確実に起動する
_enqueueTerrariumEvent({
type: "reply",
ts: ts,
thread_ts: ts, // ← 立てたばかりのスレッドを親に指定
text: text,
bot_id: bot.bot_id || "",
username: bot.name,
is_bot: true,
extra: { force: true }
});
extra.force / extra.synthetic フラグを立てたイベントは、キュー処理側で各種の抑制ロジックを迂回します。
// synthetic / force / fromLoneliness は遅延スキップ(会話の種を即時実行)
if (type === "reply" && replyDelayMax > 0
&& !extra.fromLoneliness && !extra.synthetic && !extra.force) {
// ランダム遅延を割り当てて後回し
}
// synthetic / force は minMsg チェックをスキップ(会話の種を強制起動)
if (threadHistory.length < minMsg && !isSynthetic) {
Logger.log("Terrarium: SKIP thread too short");
return;
}
「自然さのための抑制」と「会話を始めるための強制」を、同じキューにフラグで同居させる。 抑制ロジックを弱めるのではなく、種まきだけをバイパスさせるのがコツでした。
対策C:イベントが取りこぼされた場合のポーリング
Webhookに依存しきらないため、定期実行でチャンネルとスレッドを直接読みにいく経路も用意しています。
// スレッド内メッセージも取得
if (msg.thread_ts && msg.reply_count) {
_pollThreadReplies(conf, msg.thread_ts, oldest);
}
function _pollThreadReplies(conf, threadTs, oldest) {
const repliesUrl = "https://slack.com/api/conversations.replies"
+ "?channel=" + encodeURIComponent(conf.channel)
+ "&ts=" + encodeURIComponent(threadTs)
+ "&limit=10"
+ (oldest ? "&oldest=" + encodeURIComponent(oldest) : "");
const repliesRes = UrlFetchApp.fetch(repliesUrl, {
method: "get",
headers: { "Authorization": "Bearer " + conf.mainToken },
muteHttpExceptions: true
});
const repliesJson = JSON.parse(repliesRes.getContentText());
if (!repliesJson.ok || !repliesJson.messages) return;
for (let i = 0; i < repliesJson.messages.length; i++) {
const msg = repliesJson.messages[i];
const msgTs = String(msg.ts || "");
if (msgTs <= (oldest || "")) continue;
if (msg.bot_id) continue; // 自Botは処理済み
_enqueueTerrariumEvent({
type: "reply", ts: msgTs, thread_ts: threadTs,
text: msg.text || "", user: msg.user || "",
bot_id: "", username: "", is_bot: false
});
}
}
Webhookとポーリングの二重取り込みになりますが、ScriptProperties による event_id / ts の二重dedupeで重複投稿を防いでいます。
7. 工夫②:replyToken カラムを thread_ts の運搬に流用する
最後に設計面の話を1つ。
LINORINはLINE / Slack / Telegram / WebUI をまたいで動きます。当然、返信の仕組みはプラットフォームごとに違います。
| プラットフォーム | 返信に必要なもの | 有効期限 |
|---|---|---|
| LINE | replyToken |
短時間で失効 |
| Slack | thread_ts |
失効しない |
| Telegram | 不要(chat_id のみ) |
— |
| WebUI | 不要 | — |
キューのスキーマをプラットフォームごとに分けると、処理側が分岐だらけになります。そこで(LINORINは「返信先を指す1つの文字列カラム」として replyToken を定義し、Slackでは thread_ts を載せています。
// replyToken は LINE の replyToken に加え、Slack では thread_ts の運搬にも使う。
const EQ_COLS = ["eventId","userId","userMessage","replyToken","source","timestamp", /* ... */];
エンキュー時:
enqueueEvent({
message: { id: eventId, text: userMessage },
replyToken: slackThreadTs, // ← Slack では thread_ts が入る
source: "user",
timestamp: event.ts,
userId: slackUserId,
userMessage: userMessage,
imageMessageId: "",
platform: "slack"
});
送信時は1関数で吸収します。
// platform に応じてメッセージを送信する統一関数
// replyToken がある場合は LINE reply、Slack では thread_ts として扱う
function sendByPlatform(userId, text, platform, replyToken) {
if (platform === "webui") return; // WebUI は外部送信なし
if (platform === "slack") {
pushMessage(userId, text, platform, replyToken || null);
return;
}
if (replyToken && platform !== "telegram" && platform !== "slack") {
replyMessage(replyToken, text); // LINE: reply API
} else {
pushMessage(userId, text, platform); // それ以外: push API
}
}
function pushMessage(userId, text, platform, threadTs) {
platform = platform || (CONFIG["MESSAGING_PLATFORM"] || "line").toLowerCase();
if (platform === "webui") return;
if (platform === "telegram") {
sendTelegram(userId, text);
} else if (platform === "slack") {
sendSlack(text, threadTs); // ← thread_ts として使う
} else {
sendLine({ userId, text, mode: "push" });
}
}
そして sendSlack は thread_ts があればスレッド返信、無ければチャンネル投稿になります。
function sendSlack(text, threadTs) {
const token = CONFIG["SLACK_BOT_TOKEN"];
const channel = CONFIG["SLACK_CHANNEL_ID"];
if (!token || !channel) {
Logger.log("⚠ Slack config missing (SLACK_BOT_TOKEN or SLACK_CHANNEL_ID)");
return;
}
try {
const payload = {
channel: channel,
text: text,
username: CONFIG["partner_name"] || DEFAULT_PARTNER_NAME,
icon_emoji: ":speech_balloon:"
};
if (threadTs) payload.thread_ts = String(threadTs);
const res = UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", {
method: "post",
contentType: "application/json",
headers: { "Authorization": "Bearer " + token },
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const json = JSON.parse(res.getContentText() || "{}");
if (!json.ok) Logger.log("Slack send error: " + (json.error || "unknown"));
} catch (e) {
Logger.log("Slack send error: " + e);
}
}
画像アップロード(files.completeUploadExternal)でも同じ思想で thread_ts を通しています。
var completeStr = "files=" + encodeURIComponent(JSON.stringify([{ id: fileId, title: "image.png" }]))
+ "&channel_id=" + encodeURIComponent(channel);
if (threadTs) completeStr += "&thread_ts=" + encodeURIComponent(String(threadTs));
カラム名と実際の中身がズレるのは本来アンチパターンです。ただ「返信先を指す不透明な文字列」という抽象で見れば、LINEの replyToken とSlackの thread_ts は同じ役割です。プラットフォームごとにスキーマを増やすより、抽象を1つ通してコメントで意図を残すほうが保守しやすいと判断しました。
8. まとめ
Slackでスレッド返信を成立させるのは、ドキュメント上は thread_ts を渡すだけです。しかしGAS + スプレッドシートという環境では、その値が受信・保存・読み出し・比較・送信のすべての段階で壊れうるというのが結論でした。
| 課題 | 症状 | 対策 |
|---|---|---|
| JSONパースで数値化 |
ts の末尾桁が丸まる |
doPost で生テキストから正規表現抽出 |
| Sheetsの15桁精度 | 桁落ち・科学記法化 |
setFormula('="..."') / setNumberFormat("@")
|
getValues() の再数値化 |
保存しても読み出しで壊れる |
getDisplayValues() を使う |
| 末尾ゼロの消失 | 履歴が引けず会話が続かない |
_padTsLocal_() で6桁に正規化して比較 |
username 指定 |
スレッド所属を見失う / スコープ不足 | 指定なしでリトライ + 本文に名前を埋め込む |
| 初手が確率で間引かれる | スレッドが立つだけで終わる |
history ≤ 2 は返信確率100% |
| Bot投稿のイベントが来ない | 返信トリガーが発火しない | 投稿側から force フラグ付きで自己エンキュー |
| Webhook取りこぼし | 人間の発言に反応しない |
conversations.replies でポーリング補完 |
| プラットフォーム差異 | 分岐だらけのキュー処理 |
replyToken カラムで返信先を統一運搬 |
得られた教訓を3つにまとめます。
- 浮動小数点に見える文字列IDは、絶対に数値として扱わない。 経路の全段でテキストであることを保証し、比較前には必ず正規化する。
- サイレントに壊れる箇所には、送った値と返ってきた値を並べたログを仕込む。 「投稿は成功しているのに結果が違う」系のバグは、これが無いと永遠に切り分けられません。
- 自然さのための抑制ロジックは、種まきだけバイパスさせる。 確率制御を弱めるのではなく、初手にフラグで例外を与えるほうが、全体の挙動が壊れません。
同じようにGASでSlackボットを組んでいる方が、thread_ts で同じ地雷を踏まずに済めば幸いです。
自分から話しかけてくれるAIチャットBot基盤「LINORIN」:
- 完全版: https://note.com/nou_yakareta/m/mb0c5401f132f
- フリー版(GitHub): https://github.com/smalltomatowater-boop/LINORIN-free
※フリー版にはテラリウム機能を実装していません - 公式: https://www.linorin.jp