作った動機
LINEは通知が来ても流れていってしまう。仕事で大事な連絡がグループに来ても、気づくのが遅れることが何度かあった。
一方Backlogは通知を見る運用になっている。ならば「LINEに何か来た」という事実だけBacklogに転記して、Backlogのお知らせで気づけばいい。さらに転記だけでなく、メンションすればそのまま起票もできたら便利だ。
以前なら LINE Notify で手軽に済ませるところだが、LINE Notify は2025年3月末で終了している。グループの発言を受け取って双方向にやり取りするには、LINE公式アカウント + Messaging API でボットを作るしかない。
そこで LINE グループに常設するボットを作った。
できたもの
1. 全発言ミラー
グループ内の全メッセージを、Backlogの指定課題(週次ログ課題を自動起票 or 固定課題)にコメントとして転記する。形式は 日時 + 表示名 + 本文。画像やスタンプは [画像] のようなプレースホルダになる。
ポイントは、Backlog APIのコメント追加に notifiedUserId[](お知らせの通知先)を付けて投稿すること。コメントが付くとBacklogの「お知らせ」が届くので、LINEの通知を見逃してもBacklog側で気づける(ただし後述の通り、APIキーの持ち主本人には届かない)。
ミラーは管理画面で明示的に有効にしたときだけ動く(初期値は「しない」)。他のメンバーの発言を外部サービスに保存する機能なので、有効にする前にグループのメンバーに了承を取ってほしい。
2. メンション → 起票 / コメント
@bot ランチ会の日程を来週中に決めたい
→ [新規起票] [既存課題にコメント] [キャンセル]
ボットにメンションすると最初に「新規起票か、既存課題へのコメントか」を聞いてくる。AI整形はこの選択後にだけ呼ぶので、無駄にBedrockを叩かない。
-
新規起票 → Bedrock (DeepSeek V3.2) が設定済みテンプレートに沿って
{summary, description, dueDate}のJSONを生成 → プレビュー確認 → 起票- プレビューから「フォームで修正」を選ぶとLIFFの編集画面が開き、件名・詳細・期限・課題タイプ・優先度・担当者を手で直してから起票できる
-
既存課題 → 課題番号だけ入力(
123→SAGA-123、プロジェクトキーは設定済みと自動結合)→ 確認 → コメント投稿 - ボットの起票結果メッセージに「返信」すると、その課題に直接コメント追記できる
3. Backlog → LINE の逆方向
BacklogのWebhookを受けて、他人のコメントをグループに投稿する。APIキー所有者(bot用アカウント)自身の投稿はループ防止で弾く。ミラーコメントはbotアカウント経由なので自然に抑制される。
4. LIFF画面
LIFF(LINE内で開くWebアプリ)をLambda上に同居させ、2つの画面を出している。
- 管理画面:グループごとに「Backlogスペース・APIキー・プロジェクト・課題タイプ・優先度・担当者・ミラー先」を設定する
- 起票フォーム:上記の「フォームで修正」から開く、起票内容の編集画面
アーキテクチャ
LINE group ──Webhook──▶ /webhook/line ─────┐
Backlog ────Webhook──▶ /webhook/backlog/:token ─┤ Lambda (Function URL)
LIFF(管理画面・起票フォーム) ◀──▶ /admin ───┘
│
┌──────────────┬───────────────┼────────────────┐
▼ ▼ ▼ ▼
Backlog REST API Bedrock Converse DynamoDB SSM Parameter Store
(DeepSeek V3.2) (設定・保留・ (シークレット)
紐付け・冪等化)
-
Lambda + Function URL:
AUTH_TYPE=NONEの公開エンドポイント。入口でX-Line-Signature(HMAC-SHA256)を検証 -
DynamoDB:シングルテーブル。
GROUP#設定 /PENDING#確認待ち(TTL 10分)/AWAIT#番号入力待ち /MSG#メッセージ→課題紐付け /SEEN#冪等化 /WBTOKEN#webhookトークン逆引き - SSM:LINEチャネル情報とグループごとのBacklog APIキー(SecureString)
- CDK(TypeScript)で全部管理。リージョンはBedrockの提供状況に合わせて us-west-2
コスト面では、LINEのreplyメッセージは通数にカウントされず無料。カウントされるpushは「Backlog→LINEのコメント通知」と「LIFFの起票フォームから起票したときの完了通知」の2つだけで、どちらも返信トークンがないためpushになる。小さなグループなら無料プラン(月200通)に収まるが、通知をONにするグループが増えると上限に近づくので注意。Bedrockは起票時のみ呼ぶので月数百円もいかない。
実装でハマったこと(本題)
ここからが、見た目以上に苦労した部分。
1. LINE側のセットアップ手順が、ネットの記事と変わっていた
コードより先に、LINE Developersコンソールでつまずいた。
- Messaging APIチャネルをコンソールから直接作れない。「Create a Messaging API channel」を押すと「LINE公式アカウントを作ってから、Official Account ManagerでMessaging APIを有効にしてください」と案内される。順番は「公式アカウント作成 → OA ManagerでMessaging API有効化(ここでプロバイダーを選ぶ。後から変更不可)」
-
プロバイダー名・アカウント名に「line」「LINE」を含められない。
backlog-with-lineは予約語エラーで弾かれた - Messaging APIチャネルにはLIFFを追加できない。LIFFは同じプロバイダー配下にLINE Loginチャネルを別途作り、そちらに追加する。同じプロバイダーならユーザーIDはMessaging APIと一致する
-
その結果、LIFFのIDトークン検証(
client_id)に使う Channel ID は LINE Loginチャネルのものになる。Messaging APIのChannel IDを入れると検証に失敗する - LINE Loginチャネルは作成直後「開発中」で、Adminとテスター以外はLIFFを開けない。グループの他メンバーに使ってもらうには「公開」が必要
最後の「LINE Loginチャネルが別にある」ことが、次のバグの伏線になっていた。
2. LIFFのgroupIdとWebhookのgroupIdは別物だった
一番手こずったバグ。LIFFで liff.getContext().groupId を取ると 34c9c627-xxxx... のようなUUID形式の値が返る。一方、Messaging APIのWebhookイベントに載るグループIDは C... 形式。手元で確かめた限り、この2つはまったく別の値だった(LIFFがLINE Loginチャネルに属しているため、ID空間が違うと考えている)。
設定をLIFF側のIDで保存していたため、Webhookイベントでは設定が引けず「保存できたのに一切動かない」状態になった。さらにメンバー確認API GET /group/{groupId}/member/{userId} にLIFFのIDを投げると 400 invalid で落ち、not_group_member に弾かれる不具合もあった。
対策:ボットがグループに投稿するLIFFリンクに、Webhook側のgroupIdを付けて渡す。
// lambda/index.ts:ボットが投稿するリンク
const adminUrl = `https://liff.line.me/${liffId}/?group=${groupId}`;
// lambda/admin.ts:LIFF側はクエリの group を優先
groupId = new URLSearchParams(location.search).get('group')
|| ctx && ctx.groupId || ctx && ctx.roomId || '';
メンバー確認APIは、未認証のLINE公式アカウントだと「botを友だち追加していないメンバー」に使えない制約もある。そこで、IDトークン検証 + リンク経由のgroupIdを根拠に、確認に失敗してもログを残して通すよう緩和した。
これはセキュリティとのトレードオフだ。?group= はURLなので書き換えられ、正しいIDトークンを持つ人がgroupIdを知っていれば、そのグループの設定を上書きできてしまう。groupIdはグループ内に投稿されたリンクでしか出回らないので個人利用では許容したが、社外の人が入るグループで使うなら認証済みアカウントにしてメンバー確認を厳格に戻すべきだと思う。
3. LIFFの相対パスfetchがiOSで謎のエラーになる
LIFF画面のエンドポイントは https://<FunctionUrl>/admin(末尾スラッシュなし)。ここで fetch('api/config') と相対パスで書くと、ブラウザは /admin/api/config ではなく /api/config に解決する。当然404が返り、そのプレーンテキストを res.json() でパースして失敗する。
厄介なのはiOS(WebKit)でのエラーメッセージで、
The string did not match the expected pattern.
としか出ない。404ともJSONとも書いていないので、原因にたどり着くまで遠回りした。fetch('/admin/api/config') の絶対パスに直して解決。
4. TypeScriptテンプレートリテラル内のインラインJSエスケープ地獄
LIFF画面のHTMLを lambda/admin.ts のテンプレートリテラルで出力している。ここでJSの \/(エスケープ済みスラッシュ)がそのまま / に展開され、// コメント開始になりスクリプト全体がパースエラーに。「読み込み中...」のまま永久に動かない画面ができあがった。
同様に \n が実改行に展開されて文字列が途中で切れるケースもあった。対策として「配信されるHTMLを実際にcurlして、中のscriptを node --check に通す」検証を入れた。テンプレートリテラルにJSを書く場合、この確認は必須だと思う。
5. Bedrockのモデル選びと料金の構造
最初は xAI の Grok 4.6 を使う想定だったが、新しいAWSアカウントでは xai.grok-4.6 is not available for this account で使えない。コンソールのプレイグラウンドでも同じエラーなのでアカウント制限確定。結局、次のように3回モデルを替えた。
- Grok 4.6 → アカウントで開放されず断念
- Claude Haiku 4.5 → 呼び出せたが、請求がAWS Marketplace経由(sold by Anthropic)で、AWSクレジットの対象外だった
- Kimi K3 → Bedrockサービス課金でクレジット対象。ただし推論プロファイル経由での呼び出しが必要
-
DeepSeek V3.2(
deepseek.v3.2)→ 同じくクレジット対象で、us-west-2でオンデマンドに直接呼べる。メモ→JSON整形という用途には十分で、いちばん安かった
ここで知ったのが Bedrockの請求元がモデルによって違うこと。クレジットで賄いたいなら、Bedrockサービス課金のモデルを選ぶ必要がある。料金とクレジットの扱いは2026年10月時点のもので、変わる可能性があるので各自の環境で確認してほしい。モデルIDはスタック側の定数(BEDROCK_MODEL_ID)で差し替えられるようにしてある。
今回はAWS Builder Centerの企画でクレジットを新たにいただいたおかげで、こうしてモデルを色々と試せている。ありがとうございます。
また、新しいAWSアカウントでは、アカウントの検証が終わるまでBedrockの呼び出しが 403 AccessDeniedException になることがある。その間も起票が止まらないよう、Bedrockが失敗したら整形なしの下書きで続行するフォールバックを入れた。
ちなみにBedrock呼び出しのファイルは、Grok想定の名残で今も lambda/grok.ts のまま。
6. 「保存できた?」が分からないUXの連鎖
- ボタンを押しても画面上何も起きない → 何度も押してしまう
- 「接続テスト」でAPIキーが既に保存済みだと空欄なのに必須チェックに引っかかる
- 「読み込み中」のままどこで止まっているか分からない
対応:
-
postbackに
displayTextを付けて、押したボタン名が発言としてチャットに表示されるように(無料・即時フィードバック)
{ label: '新規起票', data: `a=new&p=${pid}`, displayText: '新規起票' },
-
AI生成中は
/chat/loading/startでローディングインジケータを表示 - 「新規起票」ボタンは、生成中の二重押しを「既に生成中です」で返すようガード
- LIFFは「スペースURL・APIキー・プロジェクトキーの接続テスト」→「Backlogからメタ情報を取って詳細設定」の2段階に分離し、段階表示とタイムアウトも入れた
7. 自分自身がループ防止とお知らせから外れる
ループ防止として「APIキー所有者自身のコメントは通知しない」処理を入れていた。ところが最初APIキーを自分のアカウントで発行していたため、自分のコメントが全部ループ判定で消えていた。
同じ原因で、ミラーの「お知らせ先」に自分を指定しても通知が来なかった。ミラーのコメントはAPIキーの持ち主=自分の名義で投稿されるが、Backlogは投稿者本人にはお知らせを送らない。APIで notifiedUserId[] を送っても、コメントの通知先は空になる。
bot用の別アカウントでAPIキーを発行し直したら、どちらも普通に届いた。設計通りなのに「動かない」に見える、仕様由来の罠だった。
まとめ
「LINEの通知を見逃す」という個人的な課題から始まって、双方向の連携ボットになった。
-
LINE発言 → Backlogミラー(
notifiedUserIdでお知らせが来る) - メンション → AI整形 → 起票 / 既存課題へコメント(LIFFフォームで修正も可)
- Backlogコメント → LINE転送
ハマりどころは7つ書いたが、根っこは「LINEにはチャネルが2つあり、IDの世界も2つある」「名義が自分だと通知の仕組みから外れる」の2つに集約される気がする。どれもドキュメントを読むだけでは気づきにくく、実際に組んで初めて分かる類のものだった。
コードはGitHubに公開している(midnight480/backlog-with-line)。セットアップ手順も docs/SETUP-LINE.md に詳しく書いたので、LINEの通知を見逃しがちな人は試してみてほしい。