走り終わった直後に Slack と X へ投げる ― Strava Webhook 起点の自動投稿
⚠ NOTICE — この記事の文体について
本記事の文章は 生成 AI(Claude)の協力のもとで執筆しています。実装内容・コード・設計判断はすべて筆者本人のものですが、説明の構成や言い回しに AI 特有のクセが残っている可能性があります。バイブコーディング(AI ペアプロ駆動開発)の実践レポートとして読んでもらえると幸いです。
はじめに
このサイトは、自分が走り終わると勝手に X と Slack へ結果を投げる。距離とペースとタイムと心拍、そのときの気温と湿度、前の晩の睡眠、走りの実績から出した今日の調子。それを 1 枚の画像と一緒に流す。
最初は毎朝 07:00 の cron 1 本でやっていた。仕組みそのものは前の記事に書いたとおりで、あれはあれで動いていた。ただ、3:45 に起きて 4 時台に走り終わる人間にとって、07:00 の投稿は遅い。走り終わってから 3 時間近く経って、ようやく今日のランが流れる。誰かが待っているわけでもないが、自分で見て気持ち悪かった。
そこを Strava の Webhook で埋めた。
cron を捨てずに Webhook を足す
Strava には Push Subscription という仕組みがあって、アクティビティが作られたときに指定した URL へ POST を飛ばしてくれる。走り終わって時計が同期した数十秒後には届く。
素直に考えると「じゃあ cron は要らないな」となるが、残した。Webhook は届かないことがある。Strava 側の都合でも、こちらのデプロイ中でも落ちる。届かなかった日に投稿が丸ごと消えるのは困るので、07:00 の cron は保険として生かしてある。
// app/api/strava/webhook/route.ts のヘッダコメントより
/**
* - GET : 購読登録時の検証ハンドシェイク。Strava が `hub.challenge` を投げてくるので、
* `hub.verify_token` が一致したら challenge をそのまま echo する。
* - POST : アクティビティ/アスリートのイベント通知。新規アクティビティ (create) の
* ときだけ「今日のランを Slack と X へ投稿」する。Strava は 2 秒以内の 200 応答を
* 要求するため、投稿本体は after() でレスポンス後に非同期実行する。
*/
2 経路あるということは、同じランが 2 回流れうるということでもある。そっちは Upstash KV のマーカー 1 個で潰した。
Webhook の受け口で気をつけたこと
Strava の Webhook は、POST に署名を付けてこない。GitHub のような X-Hub-Signature が無いので、届いた POST が本当に Strava から来たのかをヘッダだけでは判定できない。
Strava 標準の設計は、購読を登録するときの GET ハンドシェイクで正当性を担保する形になっている。自分で決めた verify_token を Strava に渡しておき、Strava がコールバック URL へ確認の GET を投げてきたときに一致したら hub.challenge をそのまま返す。ここを通らないと購読自体が作れないので、URL の正当性はここで押さえる。
// app/api/strava/webhook/route.ts
export async function GET(req: Request): Promise<Response> {
const expected = process.env.STRAVA_WEBHOOK_VERIFY_TOKEN;
if (!expected) {
// 未設定のまま公開すると誰でも購読を乗っ取れるためフェイルセーフ。
return NextResponse.json({ error: "verify token not configured" }, { status: 500 });
}
const url = new URL(req.url);
const mode = url.searchParams.get("hub.mode");
const token = url.searchParams.get("hub.verify_token") ?? "";
const challenge = url.searchParams.get("hub.challenge");
if (mode === "subscribe" && challenge && timingSafeEqual(token, expected)) {
return NextResponse.json({ "hub.challenge": challenge });
}
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
比較には cron 認証で使っている timingSafeEqual を流用した。ここは秘密を1文字ずつ探られるほど攻撃価値が高い場所ではないと思うが、関数がもう手元にあるので使わない理由もない。
POST 側でやっているのは絞り込みだけだ。body が 8KB を超えたら中身を見ずに捨てる。Strava のイベントは数百バイトなので、桁が違う入力は相手にしない。あとは owner_id が自分のアスリート ID と一致するものだけ処理して、何が来ても 200 を返す。
const isNewActivity = event.object_type === "activity" && event.aspect_type === "create";
const ownerId = process.env.STRAVA_ATHLETE_ID;
const ownerMatches = !ownerId || String(event.owner_id ?? "") === ownerId;
if (isNewActivity && ownerMatches) {
// 投稿処理はレスポンス後に回す (Strava の 2 秒応答制約を守る)。
after(postTodaysRun);
}
// create 以外・重複・自分以外も含め常に 200 (非 200 だと Strava がリトライを続ける)。
return NextResponse.json({ ok: true });
常に 200 を返すのは、エラーで返すと Strava がリトライし続けるからだ。壊れた JSON が来ても 200。無視した、という事実だけレスポンスに書いて返す。
投稿本体を after() に逃がしているのは 2 秒制約のため。画像を生成して X にアップロードして投稿して、さらに Slack にも投げる。2 秒では終わらない。Next.js の after() はレスポンスを返した後に処理を続けてくれるので、Strava には即座に 200 を返しつつ裏で走らせられる。
もう1つ、地味だが効いたのがキャッシュの迂回だった。
// webhook はアップロード直後に届く。90 秒キャッシュに残った「走る前」の応答を
// 掴むと即時投稿が空振りするため、この経路だけキャッシュを迂回する。
const run = await getTodaysRunFresh();
if (!run.hasRun) return;
Strava の取得は普段 90 秒の unstable_cache を挟んでいる。Webhook が届くのはアクティビティが作られた直後なので、その少し前に誰かがサイトを開いていると「今日はまだ走っていない」がキャッシュに残っていて、投稿が空振りする。この経路だけ生で取りに行く。
本文を作る場所を1か所に寄せる
X と Slack に別々のテンプレートを持つと、指標を1つ足すたびに 2 か所直すことになる。実際そうしかけて、やめた。
いまは lib/post-run-text.ts に buildPostRunText(X 用)と buildSlackRunText(Slack 用)が並んでいて、距離やペース、タイム、心拍の整形と、天気行の生成は同じ関数を呼んでいる。どちらも副作用を持たない純粋な関数なので、入力を与えれば出力が決まる。単体テストもここに集中させられる。
見出し行も 2026-07-26 に統一した。それまで X は 2026.07.26 06:21 START、Slack は 2026.07.26 のラン と別々に持っていて、揃える理由もないのに揃っていなかった。
/**
* X / Slack 共通の先頭行を作る。例: `RUN LOG — 2026.07.26 SUN`
*
* `withStartTime` は X 専用。X は本文に時刻行を持たないため、早朝に走っている事実が
* 投稿から消えないよう末尾に `06:21 START` を残す。Slack は直下に「スタート:」行が
* あるので重複させない。
*/
function buildRunHeader(
startLocalIso: string | null | undefined,
fallback: string,
{ withStartTime = false }: { readonly withStartTime?: boolean } = {},
): string {
if (!startLocalIso) return fallback;
const head = `RUN LOG — ${formatLocalIsoAsDotDateWithWeekday(startLocalIso)}`;
return withStartTime ? `${head} ${startLocalIso.slice(11, 16)} START` : head;
}
曜日を入れたのは、日付だけだと日誌として読みにくかったから。2026.07.26 を見て指を折って曜日を数える手間が、SUN の 3 文字で消える。
曜日の取り方には1つ罠があった。startLocalIso は末尾に Z が付いているが、中身はローカル時刻で UTC ではない。getDay() で読むと、Vercel の UTC 実行環境では 3 時台のランが前日の曜日になる。getUTCDay() なら文字列上の数値をそのまま取れるので、実行環境の TZ に依らず走った日の曜日が出る。
export function formatLocalIsoAsDotDateWithWeekday(iso: string): string {
return `${formatLocalIsoAsDotDate(iso)} ${WEEKDAY_LABELS[new Date(iso).getUTCDay()]}`;
}
早朝ランのテストを1本足して固定してある。3 時台に走る生活だと、この手の1日ズレが普通に踏み抜けるので。
280 と 3000
X と Slack で本文が分かれているのは、単に上限が違うからだ。X は 280、Slack の section block は 3000 文字。10 倍違えば載せられるものも変わる。
Slack にだけ載せている情報は 4 つある。起床時刻(Apple Health の sleepEnd から取る)、安静時心拍と心拍変動、走りの調子の軸ごとの内訳、そして目標距離までの進捗バー。X には入らないので落としている。
落とし方はオプション1つで切り替える形にした。
export interface ConditionLinesOptions {
/**
* RHR / HRV の行を出すか (既定 true)。X は 280 weighted の制限があり、3 行だと
* 余力が 20 まで落ちて天気の文言次第で溢れる (溢れると x-client が投稿自体を捨てる)。
* X では false にして 2 行に抑える。詳細は Slack と /condition で見られるので情報は失わない。
*/
readonly withVitals?: boolean;
}
余力 20 というのは実測で、そこまで詰めると「雷雨」の日に溢れる。溢れたら投稿が捨てられるので、余裕を持たせて 2 行に減らした。
280 の数え方
ここでハマった。X の 280 は weighted length で判定される。日本語は1文字が 2 カウントになる。
/** weight 1 として扱うコードポイント範囲 (twitter-text v3 の ranges と同一)。 */
const SINGLE_WEIGHT_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x0000, 0x10ff],
[0x2000, 0x200d],
[0x2010, 0x201f],
[0x2032, 0x2037],
];
export const TWEET_MAX_WEIGHTED_LENGTH = 280;
export function countTweetWeightedLength(text: string): number {
let total = 0;
for (const char of text) {
const codePoint = char.codePointAt(0) ?? 0;
total += SINGLE_WEIGHT_RANGES.some(([lo, hi]) => codePoint >= lo && codePoint <= hi) ? 1 : 2;
}
return total;
}
text.length で数えていると、日本語だけの本文はローカルの上限チェックを余裕で通り、X 側で 400 近い判定を食らって弾かれる。特に痛かったのが進捗バーで、█ や ░ を並べたバーは 30 文字ぶんまるごと過小評価になっていた。投稿前の長さ判定はこの関数に一本化してある。
なお進捗バー自体は、いま Slack にしか出していない。X は 1 ラン 1 本に絞ったので、reply でぶら下げていたバーは 2026-07-26 に廃止した。
OAuth 1.0a を手で署名する
X API v2 の POST /2/tweets は OAuth 2.0 User Context と OAuth 1.0a のどちらでも通る。1.0a を選んだ。
理由は refresh token を持ちたくなかったからだ。OAuth 2.0 だとトークンの期限が切れるたびに更新して保存する必要があって、cron や Webhook から呼ぶだけのバックエンドにその状態管理を持ち込みたくなかった。1.0a なら環境変数に 4 つ置いておけば、あとは毎回署名するだけで済む。
そのかわり署名を自分で組む。ライブラリを入れてもよかったが、やることは HMAC-SHA1 とパーセントエンコードだけなので lib/x-oauth.ts に純粋関数として書いた。
引っかかった点が 2 つある。1 つ目はエンコード。
/**
* RFC 3986 準拠のパーセントエンコード。
* `encodeURIComponent` は `! ' ( ) *` を残すため、それらも必ず置換する。
*/
export function percentEncode(value: string): string {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}
encodeURIComponent は RFC 3986 と挙動が違って、この 5 文字を残す。そのまま署名すると X 側が組み立てる署名と食い違って 401 が返る。エラーメッセージからは原因が分からない類のやつだった。
2 つ目は、署名対象に何を含めるか。
/**
* OAuth 1.0a の Signature Base String を構築する (RFC 5849 §3.4.1)。
*
* 重要: application/json の body パラメータは base string に含めない。
* application/x-www-form-urlencoded の場合のみ body も含める必要がある。
*/
ツイート本文は JSON で送るので、base string には入れない。ここを「body も含めるものだ」と思い込んで足すと通らない。画像アップロードの multipart も同じで、body は署名に含めない。おかげでツイート投稿とメディアアップロードの署名生成は、まったく同じコードで済んでいる。
テストは nonce と timestamp を注入できるようにして固定した。乱数と時刻が入る関数はそのままだとテストできないので、引数で差し込めるようにしておく。
export function createOAuthHeader(
method: string,
url: string,
queryParams: ReadonlyMap<string, string>,
credentials: XCredentials,
options: OAuthHeaderOptions = {},
): string {
const nonce = options.nonce ?? randomBytes(16).toString("hex");
const timestamp = (options.timestampSec ?? Math.floor(Date.now() / 1000)).toString();
// ...
}
画像は v1.1 の media/upload.json に単発アップロードして media_id をもらい、v2 のツイートに添える。単発アップロードの上限は 5MB で、こちらが生成している OG 画像はその半分にも届かないので分割アップロードは実装していない。
Slack 側の画像の渡し方
Slack 側は Incoming Webhook 1 本で、こちらは拍子抜けするほど簡単だった。JSON を POST するだけで、署名も無い。
X と違うのは画像の扱いで、Slack にはメディアアップロード API が無い。image block へ公開 URL を書いておくと、Slack が自分でその URL を取りに来る。
const payload = {
// 通知プレビュー (プッシュ通知やサイドバー) 用のフォールバックテキスト。
text: trimmed.slice(0, SLACK_MESSAGE_MAX_LENGTH),
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: trimmed.slice(0, SLACK_MESSAGE_MAX_LENGTH) },
},
{
type: "image",
image_url: imageUrl,
alt_text: altText,
},
],
};
このサイトの OG 画像は /api/og/today-run で動的生成していて認証も要らないので、URL をそのまま渡せば済む。バイト列を送らないぶん、Slack 側の実装は X の半分もない。
楽をしたぶんの代償は、あとで払わされた。
Slack が前日のランの画像を貼ってきた
2026 年 7 月 26 日、本文は今日のランなのに、貼られている画像が昨日のランだった。
犯人は Slack のプロキシキャッシュだった。Slack は image block の URL を自前のプロキシ経由で取得して、それをキャッシュする。こちらが渡していた URL は毎日同じ /api/og/today-run なので、Slack から見れば昨日取ってきたのと同じ URL でしかない。取りに来ず、手元のものを使う。
疑いはしたが確証がなかったので、同じ日に URL の末尾へ日付クエリを付けたものと付けないものを両方投稿して比べた。付けたほうだけ当日の画像になった。
const result = await postRunCardToSlack({
text,
// `?d=<JST日付>` は **Slack のキャッシュを外すためだけ**の付加で、OG ルートは読まない
// (未知のクエリは無視され、同一バイトの画像が返ることを実測済み)。
imageUrl: `${SITE_URL}/api/og/today-run?d=${jstDate}`,
});
?d= は OG ルート側では一切読んでいない。未知のクエリは無視して同じ画像を返す。ただ Slack にとっては別 URL になるので、1 日 1 回は取りに来る。
X 側で同じことが起きなかったのは、X には画像のバイト列を直接アップロードしているからだ。URL を渡す方式にした時点で、いつ取りに来るかを決めるのは相手になる。Slack に別の画像を出すときも、同じ対策が要る。
二重投稿を止める
Webhook と cron の 2 経路があり、Strava は create の直後に update を投げてくることもある。放っておくと同じランが何度も流れる。
止めているのは Upstash KV の 1 キーだけ。
export async function acquirePostMarker(job: string, jstDate: string): Promise<boolean> {
const redis = getRedis();
if (!redis) return true;
const result = await redis.set(markerKey(job, jstDate), 1, {
nx: true,
ex: MARKER_TTL_SECONDS,
});
return result !== null;
}
xpost:<ジョブ名>:<JST日付> を NX で立てて、立てられた側だけが投稿する。TTL は 48 時間。ジョブ名を分けているので、X の投稿と Slack の投稿は別々にマーカーを持ち、片方が落ちてももう片方は投稿される。
ここで一度やらかした。投稿に失敗したときにマーカーを立てたまま放置していたので、一度失敗したらその日はもう投稿されない。保険のはずの cron が投稿済みと誤認して、何もせずに終わる。7/25 に進捗バーの投稿が 403 で落ちて、そこで気づいた。
if (tweet.ok) {
if (tweet.tweetId) await recordPostedTweetId("post-run", jstDate, tweet.tweetId);
} else if (acquired) {
// 失敗した投稿でマーカーを塞いだままにすると、保険の cron まで「投稿済み」と誤認する。
await releasePostMarker("post-run", jstDate);
}
失敗したら外す。二重投稿を止めるつもりで置いたマーカーで、自分でリトライを潰していたわけだ。
KV が未設定のときは acquire が常に true を返すようにもしてある。ローカルで KV を用意していなくても投稿処理が動く、という都合。二重投稿のガードが環境変数の有無で消えるのは気持ち悪いが、本番以外で困らないので妥協した。
天気の行と調子の行
投稿本文には天気の行と調子の行が入る。この 2 つの役割分担を、7 月に作り直した。
もともとは天気から文言を選ばせていた。気温と風と降水量を見て「絶好のコンディション」とか「今日は厳しい」とか出す仕組みだ。酷暑の閾値を 30℃ に置いていたせいで、28℃ で湿度 78% の真夏の朝に「絶好のコンディション」と投稿された。走っている本人は死にそうだったのに。
/**
* 気象の 1 行。**事実だけを並べ、良し悪しの判定はしない** (2026-07-26 本人裁定)。
*
* 走りの良し悪しは `lib/run-condition.ts` の調子評価が
* 実績 (ペース・心拍・自律神経) から出すので、天気は素材の提示に徹する。
*
* 例:
* "気温28℃(体感31℃) 湿度78% 曇"
*/
いまは天気の行に素材だけ並べて、良し悪しは走った結果のほうから出している。ペースと心拍と自律神経を、それぞれ同距離帯の直近 90 日と比べて z-score で合成し、A から D の段階を付ける。X では 1 行に畳み、Slack では軸ごとに開く。
export function buildConditionVerdictLine(v: RunConditionVerdict): string {
return `調子: ${v.grade} — ${v.headline}`;
}
export function buildConditionVerdictBlock(v: RunConditionVerdict): string[] {
const lines = [buildConditionVerdictLine(v)];
const rows: readonly (readonly [string, ConditionAxis | null])[] = [
["ペース", v.pace],
["心拍", v.heartRate],
["自律神経", v.autonomic],
];
for (const [label, axis] of rows) {
if (axis) lines.push(` ${label} ${axis.grade} ・ ${axis.detail}`);
}
return lines;
}
ベースラインを同距離帯(±2km)の直近 90 日で取っているのは、全ランの平均と比べると 31km 走った日が必ず低評価になるからだ。12km と同じ物差しで測っても仕方ない。
天気に判定させるのをやめた理由は、たぶん単純で、天気は走る前の話だからだと思う。実際どうだったかは、走った後の数字にしか出ていない。
投稿から URL を外した
X の本文に、以前はラン詳細ページの URL を入れていた。回遊してほしかったので。7 月に外した。
/**
* ラン詳細ページの URL は載せない (2026-07-25 本人判断)。X API が従量課金に
* 移行し、URL を含む投稿は 1 件 $0.20 と報告されている (URL なしは $0.01) ため。
*/
X API の従量課金で、URL を含む投稿は 1 件 $0.20、URL なしは $0.01 と報告されている。1 日 1 本でも 20 倍は効く。個人サイトへの流入と引き換えにする金額ではないと判断した。
同じ理由で、03:45 の死活監視ツイートと進捗バーの reply も消した。1 ラン 1 本に絞ってある。無料枠のうちに作った仕組みが課金で削られるのは、まあ、そういうものだと思う。
まとめ
作ったものを並べると、こうなる。
- Strava Push Subscription を受けて即時投稿。07:00 の cron は保険として残す
- Webhook の POST に署名は無いので、GET ハンドシェイクと
owner_idで絞り、何が来ても 200 を返す - 投稿本体は
after()に逃がして Strava の 2 秒制約を守る - 本文の組み立ては
lib/post-run-text.tsに集約。X は 280 weighted、Slack は 3000 文字で載せる情報を出し分ける - X は OAuth 1.0a を自前署名。JSON body は署名対象に含めない
- Slack は公開画像 URL を渡すだけ。ただし URL に日付を付けないと前日の画像が貼られる
- 二重投稿は KV の
NXマーカーで止め、投稿に失敗したら必ず外す
走り終わって時計が同期すると、数十秒後に Slack に今日のカードが出る。距離とペースと、昨夜どれだけ寝たかと、今日の調子が並ぶ。それを見てから朝飯を食う。作ってよかったと思うのは、たいていこの瞬間だ。
関連する実装は別記事に分けてある。
シリーズナビ
この記事を書いた人

気に入ったら、次の記事もメールで。月1〜2通だけ。
Next Action / 次の動き
走行データは、こちらから。
読了ありがとうございます。気が向いたら、生データもどうぞ。