Geminiとペアプロして、AppSheetの変更通知(差分表示)をChatworkに爆速実装した話

どうも3号です。
正月休みにストレンジャーシングスの最新シリーズを観ました。
やっぱり面白い。何回泣いたか。何回ワクワクしたか。終わり方も完璧でした。
最近ドラマや映画を観ることがなくなってきているので、改めてこのような作品に出会うために、
色々観てみようかなと感じたいい休暇でした。

ではここから本題です。

はじめに
AppSheetを使っていると、「データが変わったときに通知が欲しい」と思うこと、ありますよね。 標準のAutomationでもメール通知などはできますが、今回は以下の要件を満たすためにGAS(Google Apps Script)を使って自作することにしました。

【やりたいこと】

営業案件管理をAppSheetで行っています。
ステータスや情報が変更になったときに値を変えると同時に、どこが変わったのかをChatworkに通知をしたい!

「データが変わりました」だけじゃなく、「どこが、どう変わったか(差分)」を知りたい!

「案件名」や「取引先名」も通知に含めて、ひと目で内容を把握したい!

ということで、今回は生成AIのGeminiに壁打ち相手になってもらいながら、コード作成からエラー解決まで行いました。その全手順と、ハマったポイントを共有します。

完成イメージ
こんな感じで通知が届きます。

▼ 更新(変更)のとき ここがポイントです!変更があった項目だけが「変更前 → 変更後」の形式で通知されます。
(例:ステージが Bヨミ → Aヨミ に変わった、など)

▼ 新規登録のとき 案件名や担当者など、主要な情報がサマリーで届きます。

実装の仕組み

構成はシンプルです。

  1. AppSheet: データ追加・更新を検知し、Webhookでデータを送信。
  2. GAS: データを受け取り、「新規」か「更新」かを判定。更新なら差分を抽出してメッセージを作成。
  3. Chatwork: メッセージを受信。

手順1:GAS(Google Apps Script)の作成
Geminiに書いてもらったコードです。 ポイントは以下の3点。
差分検知: _Old と _New の値を比較して、違う場合のみログに残す。
重複防止: AppSheetがリトライして何度も通知が来ないよう、CacheServiceで短時間の重複実行をブロック。
情報の整形: << >> などの不要な記号を除去し、見やすく整形。

// Chatwork設定
const CHATWORK_TOKEN = 'ここにAPIトークン'; 
const ROOM_ID = 'ここにルームID'; 

function doPost(e) {
  // 重複送信の防止(ロック処理)
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10000)) return ContentService.createTextOutput("Busy");

  try {
    const params = JSON.parse(e.postData.contents);
    const keyId = params.KeyID;
    const mode = params.Mode; // Add か Edit か
    
    // 重複チェック(同じIDの処理が1分以内に来たら無視)
    const cacheKey = mode + "_" + keyId;
    const cache = CacheService.getScriptCache();
    if (cache.get(cacheKey)) return ContentService.createTextOutput("Already processed");
    cache.put(cacheKey, "done", 60);

    // 案件名や取引先名の取得
    let dealName = String(params['商談名'] || params['商談名_New'] || "(不明)").replace(/^<<|>>$/g, '');
    let clientName = String(params['取引先名'] || params['取引先名_New'] || "").replace(/^<<|>>$/g, '');
    let userName = String(params.UpdateUser || "").replace(/^<<|>>$/g, '');

    let messageBody = "";
    let title = "";

    if (mode === 'Add') {
      // --- 新規追加時の処理 ---
      title = "【新着】新しい商談が登録されました";
      const excludeKeys = ['Mode', 'KeyID', 'UpdateUser', '商談名', '取引先名'];
      let infoList = [];
      for (let key in params) {
        let val = String(params[key]).replace(/^<<|>>$/g, '');
        if (!excludeKeys.includes(key) && val !== "") {
           infoList.push(`・${key}: ${val}`);
        }
      }
      messageBody = infoList.join('\n');

    } else {
      // --- 更新時の処理(差分比較) ---
      title = "【更新】商談情報が変更されました";
      let changeLog = [];
      for (let key in params) {
        if (key.endsWith('_New')) {
          let baseName = key.replace('_New', '');
          let oldValue = params[baseName + '_Old'];
          let newValue = params[key];

          if (String(oldValue) !== String(newValue)) {
            let displayOld = String(oldValue || "(未設定)").replace(/^<<|>>$/g, '');
            let displayNew = String(newValue || "(未設定)").replace(/^<<|>>$/g, '');
            changeLog.push(`■ ${baseName}\n  ${displayOld}  →  ${displayNew}`);
          }
        }
      }
      if (changeLog.length === 0) return ContentService.createTextOutput("No changes");
      messageBody = changeLog.join('\n\n');
    }

    // Chatworkへ送信
    let infoHeader = `案件名: ${dealName}\n取引先: ${clientName}\n担当者: ${userName}`;
    let body = `[info][title]${title}[/title]${infoHeader}\n[hr]${messageBody}[/info]`;

    sendChatwork(body);
    return ContentService.createTextOutput("Success");

  } catch (error) {
    return ContentService.createTextOutput("Error");
  } finally {
    lock.releaseLock();
  }
}

function sendChatwork(body) {
  const url = `https://api.chatwork.com/v2/rooms/${ROOM_ID}/messages`;
  const options = {
    method: 'post',
    headers: { 'X-ChatWorkToken': CHATWORK_TOKEN },
    payload: { 'body': body }
  };
  UrlFetchApp.fetch(url, options);
}

手順2:AppSheet Automationの設定
AppSheet側では、「追加(Adds only)」と「更新(Updates only)」の2つのBotを作ります。

共通設定
Process: Call a webhook
Url: GASのウェブアプリURL
HTTP Verb: POST
HTTP Content Type: JSON

ポイント:JSON Bodyの書き方
ここがハマりポイントでした。[項目名] だけだと文字扱いされるため、<<[項目名]>> と二重括弧で囲む必要があります。 また、GASで判定するために Mode というキーを追加しています。

//更新用(Updates only)のBody設定

JSON

{
  "Mode": "Edit",
  "UpdateUser": "<<[担当]>>",
  "KeyID": "<<[_THISROW].[ID]>>",
  "商談名_Old": "<<[_THISROW_BEFORE].[商談名]>>",
  "商談名_New": "<<[商談名]>>",
  "ステージ_Old": "<<[_THISROW_BEFORE].[ステージ]>>",
  "ステージ_New": "<<[ステージ]>>"
  // ...監視したい項目を_Old, _Newペアで記述
}
//追加用(Adds only)のBody設定

JSON

{
  "Mode": "Add",
  "UpdateUser": "<<[担当]>>",
  "KeyID": "<<[_THISROW].[ID]>>",
  "商談名": "<<[商談名]>>",
  "ステージ": "<<[ステージ]>>"
  // ...通知したい項目を記述
}

開発中にハマったこと&解決策
Geminiと会話しながら解決したエラーたちです。

401 Unauthorized エラー
原因: GASのデプロイ権限が「自分のみ」になっていた。
解決: 「全員(Anyone)」に変更して解決。AppSheetは外部からのアクセス扱いになるため。

302 Moved Temporarily エラー
原因: GASが処理完了後にリダイレクトを返すが、AppSheetがそれをエラーと勘違いする。
解決: 実は通知自体は飛んでいた。しかしAppSheetが「失敗した」と思ってリトライするため、次の問題が発生。

通知が4回連続で来る問題
原因: 上記の誤検知によるAppSheetの自動リトライ機能。
解決: GAS側に CacheService を実装。「同じIDの処理は1分間無視する」というロジックを入れて、重複通知をシャットアウトしました。

まとめ
Geminiにエラーログを投げると「それは権限の問題です」「それはリダイレクトの仕様です」と即答してくれたので、一人で悩むより圧倒的に早く実装できました。

AppSheet標準の通知機能に物足りなさを感じている方は、ぜひGAS連携を試してみてください。自分好みの通知Botが作れると、入力作業も少し楽しくなりますよ!

また、弊社ではAppSheetのマンツーマンセミナーも行っております。
そもそも作り方よくわかんないよという方はまずこちらがおススメです!!

木下 慶太郎
木下 慶太郎KINOSHITA KEITAROU
記事一覧

ヒカリシステム株式会社 アシスタントマネジャー

法政大学経済学部を卒業後、2012年にヒカリシステム株式会社へ入社。

7年間にわたりパチンコホールの現場業務に従事。2019年よりDXチームへ異動し、AppSheetやLooker Studioを活用した勤怠管理システム、SFA(営業支援システム)、請求管理システムなどの内製化を主導。

現場の課題解決への貢献が評価され、2021年度に社長賞を受賞。

現場で培った経験を基に、テクノロジーを活用して業務効率化や課題解決を実現することに情熱を注いでいます。休日は音楽フェスに参加したり、ゲームをしたりしてリフレッシュしています。

保有資格:

Google AI Essentials
Google Prompting Essentials
ITパスポート

※この記事は、リサーチの一部に生成AIを活用し、最終的な分析・執筆・編集は木下慶太郎が責任を持って行っています。

関連記事