TeamsToNotion の再設計&再実装 : hkob の雑記録 (528)

はじめに

hkob の雑記録の第528回目(連続101日目)は、以前実装を諦めた Teams Notion Webhook の再検討した件を記録しておきます。

GAS 実装の提案

まず、以下のように提案しました。

GAS 実装の提案
!image.png

基本、前回の GAS のスクリプトで Task 作成までできているので流用することを伝えました。

スクリプトの流用を提案

手順書 & 作業記録

これらを受けて手順書を書いてもらい、作業記録を追記しました。引用文のスクリプトが見にくいので、いつもと反対で私の対応を引用表示にします。

9. 代替案: Google Spreadsheet → GAS → Notion API(HTTP Premium 回避)

Power Automate の HTTP アクションが Premium のため、Spreadsheet に 1 行追加 → GAS トリガで Notion API を直接叩く方式に切り替える。

9.1 Sheet1 のヘッダ(1行目)

以下の列名で Sheet1 の 1 行目を作成する(このまま推奨)。

createdAt | status | externalId | teamId | channelId | messageId | messageUrl | fromName | textPlain | notionPageId | error

作成しました。

!image.png

9.2 Power Automate 側(Google Sheets に 1 行追加)

最低限、以下を埋めて 1 行追加する。

  • status: NEW
  • externalId: 例 teams:channel:<teamId>:channel:<channelId>:message:<messageId>
  • messageUrl: LinkToMessage
  • fromName: MessagePayload.From.User.DisplayName
  • textPlain: MessagePayload.Body.PlainText

(任意)後で検証しやすいので teamId / channelId / messageId も入れる。

9.3 Apps Script(既存 Calendar GAS に追記)

既存の Notion ユーティリティ(sendNotion / queryTasksByExternalId / createTaskPage 等)を流用し、以下の関数群を追加する。

9.3.1 追加コード(Teams Inbox 処理)

// ===== Teams -> Notion Tasks (via Google Sheets) =====

function sheet1() {
  return SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; // 最初のシート
}

function headerMap_(headers) {
  const m = {};
  headers.forEach((h, i) => { if (h) m[String(h).trim()] = i; });
  return m;
}

function cell_(row, map, key) {
  const idx = map[key];
  if (idx === undefined) return "";
  return row[idx];
}

function setCell_(row, map, key, value) {
  const idx = map[key];
  if (idx === undefined) return;
  row[idx] = value;
}

// Sheet1 の NEW 行を拾って Notion Tasks に作る(冪等: External ID)
function processTeamsInboxSheet() {
  const sh = sheet1();
  const values = sh.getDataRange().getValues();
  if (values.length < 2) return;

  const headers = values[0];
  const map = headerMap_(headers);

  const nowIso = new Date().toISOString();

  // 2行目以降
  for (let r = 1; r < values.length; r++) {
    const row = values[r];

    const status = String(cell_(row, map, "status") || "").trim();
    if (status !== "NEW") continue;

    try {
      const externalId = String(cell_(row, map, "externalId") || "").trim();
      const messageUrl = String(cell_(row, map, "messageUrl") || "").trim();
      const fromName = String(cell_(row, map, "fromName") || "").trim();
      const textPlain = String(cell_(row, map, "textPlain") || "").trim();

      if (!externalId) throw new Error("externalId is required");
      if (!messageUrl) throw new Error("messageUrl is required");
      if (!fromName) throw new Error("fromName is required");
      if (!textPlain) throw new Error("textPlain is required");

      // Notion 側で存在確認(冪等)
      const q = queryTasksByExternalId(externalId);
      const existing = (q && q.results && q.results[0]) ? q.results[0] : null;
      if (existing && existing.id) {
        setCell_(row, map, "status", "DONE");
        setCell_(row, map, "createdAt", cell_(row, map, "createdAt") || nowIso);
        setCell_(row, map, "notionPageId", existing.id);
        setCell_(row, map, "error", "");
        sh.getRange(r + 1, 1, 1, headers.length).setValues([row]);
        continue;
      }

      // Task name(先頭だけ)
      const head = textPlain.replace(/\s+/g, " ").slice(0, 60);
      const taskName = `【Teams】${fromName}: ${head}`;

      const props = {
        "Task name": { title: [{ text: { content: taskName } }] },
        "External ID": { rich_text: [{ text: { content: externalId } }] },
        "Link": { url: messageUrl },
        "Summary": { rich_text: [{ text: { content: textPlain.slice(0, 2000) } }] },
      };

      const created = createTaskPage(props);

      setCell_(row, map, "status", "DONE");
      setCell_(row, map, "createdAt", cell_(row, map, "createdAt") || nowIso);
      setCell_(row, map, "notionPageId", created && created.id ? created.id : "");
      setCell_(row, map, "error", "");
      sh.getRange(r + 1, 1, 1, headers.length).setValues([row]);

      Utilities.sleep(200); // 叩きすぎ防止
    } catch (err) {
      setCell_(row, map, "status", "ERROR");
      setCell_(row, map, "error", String(err && err.message ? err.message : err));
      sh.getRange(r + 1, 1, 1, headers.length).setValues([row]);
    }
  }
}

9.4 トリガ設定(推奨)

Apps Script のトリガで processTeamsInboxSheet を時間主導(例: 1分ごと)で実行する。

1分ごとのトリガは流石にやりすぎなので、変更時トリガに設定しています。必要な項目を書いて status を NEW に変更したタイミングでページが作成されました。

!image.png


9.5 本番用の Power Automate(Google Sheets: 行を追加)を作成

9.3/9.4 までで GAS 側の受け口はできたので、あとは Teams の「選択されたメッセージ」から Sheet1 に 1 行追加できれば運用できる。

9.5.1 フロー全体(最低限)

  1. トリガ: Teams 「選択されたメッセージに対して(V2)」
  2. アクション: Google Sheets 「行を追加」(Sheet1)

9.5.2 行追加で埋める列(最低限)

  • status: NEW
  • externalId: teams:channel:<teamId>:<channelId>:message:<messageId>
  • teamId: @{triggerOutputs()?['body/teamsFlowRunContext/ChannelData/Team/Id']}
  • channelId: @{triggerOutputs()?['body/teamsFlowRunContext/ChannelData/Channel/Id']}
  • messageId: @{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/Id']}
  • messageUrl: @{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/LinkToMessage']}
  • fromName: @{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/From/User/DisplayName']}
  • textPlain: @{triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/Body/PlainText']}

createdAt / notionPageId / error は GAS が埋める(Power Automate 側は空でよい)。

9.5.3 externalId の式(推奨)

Power Automate の式(Expression)で組み立てる例:

concat(
  'teams:channel:',
  triggerOutputs()?['body/teamsFlowRunContext/ChannelData/Team/Id'],
  ':channel:',
  triggerOutputs()?['body/teamsFlowRunContext/ChannelData/Channel/Id'],
  ':message:',
  triggerOutputs()?['body/teamsFlowRunContext/MessagePayload/Id']
)

※ Team/Channel の Name は null のことがあるため、ID ベースで作る。

Power Automate の画面はこんな感じです。前回までのやり取りで面倒な数式を全部作り込んでもらっているので、そのままコピーできるものが得られていました。

Power Automate の設定


9.6 動作確認(本番フロー)

  1. Teams でタスク化したいメッセージを選択 → フロー実行
  2. Sheet1 に 1 行追加されること(status=NEW)を確認
  3. 1分以内(トリガ間隔)に status=DONE / notionPageId が埋まることを確認
  4. Notion Tasks に 1 件作成され、External ID が入っていることを確認
  5. 同じ Teams メッセージで再実行し、Tasks が増えないこと(冪等)を確認

9.7 運用メモ

  • 失敗時は Sheet の status=ERRORerror を確認し、必要なら status=NEW に戻して再実行する。
  • textPlain が長すぎる場合は GAS 側で 2000 文字に切り詰めている(必要なら調整)。
  • Teams の本文に改行が含まれるため、Sheet ではセル内改行として格納される(問題なければそのまま)。

9.8 片付け(任意)

  • Webhook(Notion Workers)は当面未使用のため、運用では GAS ルートを正とする。
  • 将来 HTTP Premium が解決したら、Step 3(HTTP→Webhook)の案に戻せる(実装は残しておく)。

Webhook は時期を見て、削除するかもしれません。8月になってどの単位でクレジットが使われるのかで、対応策は変わりそうです。

おわりに

今回は、TeamsToNotion は正味10分ほどで完成してしまいました。4年前に自力で書いたときは1日がかりだったと思います。いい時代ですね。

hkob.notion.site