moodle webhook & plugin の作業記録 : hkob の雑記録 (502)

はじめに

hkob の雑記録の第502回目(連続75日目)は、週末に作成した moodle webhook と webhook plugin を作成するにあたり、Notion AI と作成した作業記録をそのまま紹介します。私自身は一言は記載しておらず、Notion AI が作業手順を示し、私が実行した結果をチャット欄に貼り付けて、Notion AI に記録してもらっています(具体的なやり取りは一昨日、昨日のブログを参照してください)。URL などは一部分省略していますが、それ以外は原文そのままです。

作業記録

2026-05-23

  • 目的: Moodle の課題(Assignment)作成イベントを外部 Worker へ Webhook 送信し、Notion の Tasks にタスクを自動作成する。
  • 実装方針: 先日作成した「moodle webhook の設計」(Task ID 2489) の第1部(Worker)から着手。
  • ローカル作業ディレクトリ: ~/Dropbox/workers/moodle-webhook

Worker 作成手順(案)

  1. mkdir -p ~/Dropbox/workers/moodle-webhook
  2. cd ~/Dropbox/workers/moodle-webhook
  3. ntn workers new .
    • 結果: Template downloaded / extracted
    • 入力: Initialize a git repository? = yes
    • 入力: Install dependencies? = yes
    • 結果: Git repository initialized
    • 結果: Dependencies installed with npm
    • 結果: Worker project created
    • notice: ntn 0.14.1 available(current 0.14.0)→ ntn update(実施済み)
  4. ntn update
    • 結果: ✔ Updated ntn to v0.14.1
    • 補足: npx ntn は実行時に ntn@0.14.1 を一時インストールするため、未インストール環境だと Ok to proceed? (y) が出る(正常)。
  5. src/index.ts に Webhook 受信 → 署名検証 → Notion Tasks 行作成を実装

    以下をベースに src/index.ts を作成(設計ページから転記)

     /**
      * Moodle webhook signature verification (HMAC-SHA256).
      * Set MOODLE_WEBHOOK_SECRET via `ntn workers env set`.
      *
      * After 5 consecutive WebhookVerificationError throws, the platform short-circuits
      * and rejects incoming requests without executing the handler.
      * Redeploying the worker resets the counter.
      */
     import crypto from "crypto";
     import { Worker, WebhookVerificationError } from "@notionhq/workers";
    
     const worker = new Worker();
     export default worker;
    
     type MoodleAssignCreatedPayload = {
         event?: string;
         site?: string;
         idempotency_key?: string;
         title?: string;
         url?: string;
     };
    
     function verifyMoodleSignature(rawBody: string, headers: Record<string, string>): void {
         const secret = process.env.MOODLE_WEBHOOK_SECRET;
         if (!secret) {
             throw new WebhookVerificationError("MOODLE_WEBHOOK_SECRET not configured");
         }
    
         const signature = headers["x-signature"];
         if (!signature) {
             throw new WebhookVerificationError("Missing X-Signature");
         }
    
         const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
    
         if (signature.length !== expected.length) {
             throw new WebhookVerificationError("Invalid signature");
         }
    
         if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
             throw new WebhookVerificationError("Invalid signature");
         }
     }
    
     worker.webhook("onMoodleAssignmentCreated", {
         title: "Moodle assignment created → create Notion task",
         description: "Receives Moodle assignment creation webhook and creates a row in Tasks DB.",
         execute: async (events: any[], { notion }: any) => {
             const tasksDataSourceId = process.env.TASKS_DATA_SOURCE_ID ?? "";
             if (!tasksDataSourceId) throw new Error("TASKS_DATA_SOURCE_ID not configured");
    
             const assignee = process.env.ASSIGNEE_USER_ID ?? "";
             if (!assignee) throw new Error("ASSIGNEE_USER_ID not configured");
    
             for (const event of events) {
                 verifyMoodleSignature(event.rawBody, event.headers);
    
                 const body = event.body as MoodleAssignCreatedPayload;
                 if (body.event !== "moodle.assign.created" && body.event !== "moodle.assign.updated") {
                     continue;
                 }
    
                 const externalId =
                     body.idempotency_key ||
                     `${body.site ?? "moodle"}:assign:${crypto
                         .createHash("sha256")
                         .update(event.rawBody)
                         .digest("hex")
                         .slice(0, 16)}`;
    
                 const taskName = (body.title ?? "").trim() || `Moodle assignment ${externalId}`;
                 const link = (body.url ?? "").trim();
    
                 const existing = await notion.dataSources.query({
                     data_source_id: tasksDataSourceId,
                     filter: { property: "External ID", rich_text: { equals: externalId } },
                     page_size: 1,
                 });
                 if (existing.results.length > 0) continue;
    
                 await notion.pages.create({
                     parent: { data_source_id: tasksDataSourceId },
                     properties: {
                         "Task name": { title: [{ text: { content: taskName } }] },
                         ...(link ? { Link: { url: link } } : {}),
                         Assignee: { people: [{ id: assignee }] },
                         "External ID": { rich_text: [{ text: { content: externalId } }] },
                     },
                 });
             }
         },
     });
    
  6. ntn workers deploy

    • 目的: Worker ID を作成して workers.json を生成する(env set の前提)
    • 結果: ✔ Worker updated
    • Webhook: onMoodleAssignmentCreated
    • Webhook URL: https://www.notion.so/webhooks/worker/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/onMoodleAssignmentCreated
    • 備考: build の途中で npm の major update notice が出るが deploy 自体は成功
    • 生ログ:

        ntn workers deploy
        Deploying to workspace: hkob's ambassador workspace
        [build:out] Running npm install...
        [build:out] added 14 packages, and audited 15 packages in 2s
        [build:out] 2 packages are looking for funding
        [build:out] run `npm fund` for details
        [build:out] found 0 vulnerabilities
        [build:err] npm notice
        [build:err] npm notice New major version of npm available! 10.9.7 -> 11.15.0
        [build:err] npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.15.0
        [build:err] npm notice To update run: npm install -g npm@11.15.0
        [build:err] npm notice
        [build:out] Running npm run build...
        [build:out] > @notionhq/workers-template@0.0.0 build
        [build:out] > tsc
        [build:out] Running npm install --omit=dev...
        [build:out] up to date, audited 9 packages in 456ms
        [build:out] 2 packages are looking for funding
        [build:out] run `npm fund` for details
        [build:out] found 0 vulnerabilities
        [build:out] Moving package.json...
        [build:out] Moving package-lock.json...
        [build:out] Moving node_modules...
        [build:out] Creating bundle from dist...
        [build:out] Uploading bundle...
        [build:out] Bundle upload complete.
        [build:out] Preparing runtime filesystem for snapshot...
        ✔ Worker updated
      
        Webhooks:
        + onMoodleAssignmentCreated
      
        Webhook URLs:
        → https://www.notion.so/webhooks/worker/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/onMoodleAssignmentCreated  (onMoodleAssignmentCreated)
      
        To list webhook URLs later, run: ntn workers webhooks list
      
  7. secrets を設定

    • ntn workers env set MOODLE_WEBHOOK_SECRET=...
      • 結果: ✔ Set environment variable 'MOODLE_WEBHOOK_SECRET'
    • ntn workers env set TASKS_DATA_SOURCE_ID=...
      • 結果: ✔ Set environment variable 'TASKS_DATA_SOURCE_ID'
      • 値: 1f70ce2a-6520-80ce-bc36-000b08bc3b11
    • ntn workers env set ASSIGNEE_USER_ID=...
      • 結果: ✔ Set environment variable 'ASSIGNEE_USER_ID'
      • 値: fffd872b594c811b82180002b0392cd8
      • Assignee は固定(Tasks DB の Assignee プロパティに入れる Notion User ID)
    • NOTION_API_TOKEN
      • webhook capability から context.notion を使うには internal integration token が必要
      • エラー例: NOTION_API_TOKEN is not set. context.notion requires an API token...
      • 注意: NOTION_ prefix は reserved のため ntn workers env set NOTION_API_TOKEN=... はできない
      • 対応: .envNOTION_API_TOKEN=... を置き、ntn workers env push で反映(CLI が対応している方法で設定する)
    • 備考:
      • 環境変数名は NOTION_ で始められない(reserved prefix)
      • ntn workers env set ...No worker ID found が出る場合、workers.json が未生成なので先に deploy する
  8. ntn workers webhooks list で Webhook URL を取得

    • 補足: ntn workers list で Worker 一覧と ID を確認できる

        ntn workers list
        ID                                    NAME            CREATED                   UPDATED
        019e5336-4820-7eb9-be17-e23e9d1828d5  moodle-webhook  2026-05-23T05:02:06.880Z  2026-05-23T09:14:25.767Z
      
  9. curl で POST して疎通し、ntn workers runs logs <run-id> で確認

    • 実行例(実施済み):

        curl -i -X POST <WEBHOOK_URL> \
          -H 'Content-Type: application/json' \
          -H "X-Signature: <hmac-hex>" \
          --data-raw '{"event":"moodle.assign.created",...}'
      
    • 結果:

      • HTTP/2 202
      • body: {"eventId":"449601d9-4806-4e0e-a34c-7acf1e27063f"}
    • 注意:
      • WEBHOOK_URL には Markdown 形式([url](url))ではなく、生の URL(https://...)を指定する
      • RAW_BODY 内の URL も Markdown 形式ではなく、生の URL 文字列を指定する
      • 202 は受理を意味する(実際の処理結果は ntn workers runs logs で確認)
    • ntn workers runs list(成功確認):

        RUN ID                                NAME                               EXIT CODE  STARTED AT                ENDED AT
        019e545d-9ff2-7966-9a35-32f7a2189e53  webhook:onMoodleAssignmentCreated  0          2026-05-23T10:24:42.482Z  2026-05-23T10:24:46.350Z
        019e545b-1884-707f-bf66-a45994855f6a  webhook:onMoodleAssignmentCreated  0          2026-05-23T10:21:56.740Z  2026-05-23T10:22:02.006Z
        019e545a-7ef4-790f-9635-9e834f52362d  fetchAndSaveCapabilities           0          2026-05-23T10:21:17.428Z  2026-05-23T10:21:20.494Z
        019e545a-4147-726f-8a0b-504f547fd9bf  deploy                             0          2026-05-23T10:21:01.649Z  2026-05-23T10:21:17.120Z
      

2026-05-24

第2部: Moodle local_ プラグイン作成手順(概要)

  1. local/notionwebhook/ を作成

    • 実行:

        mkdir -p local/notionwebhook
      
    • 確認:

        ls -la local | grep notionwebhook
        drwxr-xr-x   2 hkob  admin    64 May 24 13:32 notionwebhook
      
  2. version.php を作成(Moodle の build に合わせて $plugin->requires を設定)

    • local/notionwebhook/version.php

        <?php
        // This file is part of Moodle - http://moodle.org/
        //
        // Moodle is free software: you can redistribute it and/or modify
        // it under the terms of the GNU General Public License as published by
        // the Free Software Foundation, either version 3 of the License, or
        // (at your option) any later version.
        //
        // Moodle is distributed in the hope that it will be useful,
        // but WITHOUT ANY WARRANTY; without even the implied warranty of
        // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
        // GNU General Public License for more details.
        //
        // You should have received a copy of the GNU General Public License
        // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
      
        defined('MOODLE_INTERNAL') || die();
      
        $plugin->component = 'local_notionwebhook';
        $plugin->version   = 2026052400; // YYYYMMDDXX
        $plugin->requires  = 2025100603; // TODO: Set to your Moodle $CFG->version (site admin).
        $plugin->maturity  = MATURITY_ALPHA;
        $plugin->release   = '0.0.1';
      
    • $CFG->version の取得(サーバ側):

        php -r 'define("CLI_SCRIPT", true); require "config.php"; echo $CFG->version, PHP_EOL;'
        2025100603.09
      
      • requires は通常 integer を想定するので、2025100603(小数点以下を除いた値)を使う
  3. db/events.php\\mod_assign\\event\\course_module_instance_created を購読
    • local/notionwebhook/db/events.php

        <?php
      
        defined('MOODLE_INTERNAL') || die();
      
        $observers = [
            [
                'eventname'   => '\\mod_assign\\event\\course_module_instance_created',
                'callback'    => '\\local_notionwebhook\\observer::assignment_created',
                'includefile' => '/local/notionwebhook/classes/observer.php',
                'priority'    => 9999,
            ],
            [
                'eventname'   => '\\core\\event\\course_module_updated',
                'callback'    => '\\local_notionwebhook\\observer::assignment_updated',
                'includefile' => '/local/notionwebhook/classes/observer.php',
                'priority'    => 9999,
            ],
        ];
      
      • ※ callback は Step 4 で実装する classes/observer.php のメソッド名に合わせる
  4. classes/observer.php を実装
    • local/notionwebhook/classes/observer.php

        <?php
      
        namespace local_notionwebhook;
      
        defined('MOODLE_INTERNAL') || die();
      
        class observer {
            private static function should_send(int $userid): bool {
                $alloweduserid = (int) get_config('local_notionwebhook', 'alloweduserid');
                if (!empty($alloweduserid) && $userid !== $alloweduserid) {
                    return false;
                }
                return true;
            }
      
            private static function webhook_url(): string {
                return (string) get_config('local_notionwebhook', 'webhookurl');
            }
      
            private static function webhook_secret(): string {
                return (string) get_config('local_notionwebhook', 'webhooksecret');
            }
      
            private static function post_json(string $eventname, array $payload): void {
                global $CFG;
      
                $url = self::webhook_url();
                if ($url === '') {
                    return;
                }
      
                $payload['occurred_at'] = gmdate('c');
                $payload['site'] = $payload['site'] ?? $CFG->wwwroot;
      
                $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                if ($json === false) {
                    debugging('[local_notionwebhook] json_encode failed', DEBUG_DEVELOPER);
                    return;
                }
      
                $secret = self::webhook_secret();
                $signature = $secret !== '' ? hash_hmac('sha256', $json, $secret) : '';
      
                $ch = curl_init($url);
                curl_setopt_array($ch, [
                    CURLOPT_POST => true,
                    CURLOPT_POSTFIELDS => $json,
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_HTTPHEADER => array_filter([
                        'Content-Type: application/json',
                        'X-Moodle-Event: ' . $eventname,
                        $signature !== '' ? ('X-Signature: ' . $signature) : null,
                    ]),
                    CURLOPT_TIMEOUT => 5,
                ]);
      
                $resp = curl_exec($ch);
                $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
                $err  = curl_error($ch);
                curl_close($ch);
      
                if ($resp === false || $code < 200 || $code >= 300) {
                    debugging('[local_notionwebhook] webhook failed code=' . $code . ' err=' . $err, DEBUG_DEVELOPER);
                }
            }
      
            public static function assignment_created(\mod_assign\event\course_module_instance_created $event): void {
                global $CFG, $DB;
      
                if (!self::should_send((int) $event->userid)) {
                    return;
                }
      
                $cmid = (int) $event->contextinstanceid;
                $assignid = (int) $event->objectid;
      
                $assignname = (string) $DB->get_field('assign', 'name', ['id' => $assignid], IGNORE_MISSING);
                $courseid = (int) $event->courseid;
                $courseshortname = (string) $DB->get_field('course', 'shortname', ['id' => $courseid], IGNORE_MISSING);
                $title = $courseshortname !== '' ? ($courseshortname . ': ' . $assignname) : $assignname;
                $payload = [
                    'event' => 'moodle.assign.created',
                    'idempotency_key' => $CFG->wwwroot . ':assign:' . $assignid,
                    'title' => $title,
                    'url' => $CFG->wwwroot . '/mod/assign/view.php?id=' . $cmid,
                ];
      
                self::post_json('moodle.assign.created', $payload);
            }
      
            public static function assignment_updated(\core\event\course_module_updated $event): void {
                // course_module_updated は全モジュールで発火するので assign のみ通す
                $cmid = (int) $event->contextinstanceid;
                $cm = get_coursemodule_from_id(null, $cmid, 0, false, IGNORE_MISSING);
                if (!$cm || ($cm->modname ?? '') !== 'assign') {
                    return;
                }
      
                if (!self::should_send((int) $event->userid)) {
                    return;
                }
      
                global $CFG, $DB;
                $assignid = (int) ($cm->instance ?? 0);
                if ($assignid <= 0) {
                    return;
                }
      
                $assignname = (string) $DB->get_field('assign', 'name', ['id' => $assignid], IGNORE_MISSING);
                $courseid = (int) $event->courseid;
                $courseshortname = (string) $DB->get_field('course', 'shortname', ['id' => $courseid], IGNORE_MISSING);
                $title = $courseshortname !== '' ? ($courseshortname . ': ' . $assignname) : $assignname;
                $payload = [
                    'event' => 'moodle.assign.updated',
                    'idempotency_key' => $CFG->wwwroot . ':assign:' . $assignid,
                    'title' => $title,
                    'url' => $CFG->wwwroot . '/mod/assign/view.php?id=' . $cmid,
                ];
      
                self::post_json('moodle.assign.updated', $payload);
            }
        }
      
      • title は Worker 側で最終的に Task name を作る想定(必要なら Step 4 で course/assign 名を引いて詰める)
    • alloweduserid チェック(任意)
    • Webhook URL / secret を設定値から取得
    • payload を JSON 化し、HMAC-SHA256 で X-Signature を付与して POST
  5. settings.php + lang/en/local_notionwebhook.php を作成(管理画面から Webhook URL/secret 等を設定)

    • 作業結果: settings.php / lang の作成と php -l の構文チェックを実施(出力は下に記録)
    • local/notionwebhook/settings.php

        <?php
      
        defined('MOODLE_INTERNAL') || die();
      
        if ($hassiteconfig) {
            $settings = new admin_settingpage('local_notionwebhook', get_string('pluginname', 'local_notionwebhook'));
      
            $settings->add(new admin_setting_configtext(
                'local_notionwebhook/webhookurl',
                get_string('webhookurl', 'local_notionwebhook'),
                get_string('webhookurl_desc', 'local_notionwebhook'),
                '',
                PARAM_URL
            ));
      
            $settings->add(new admin_setting_configtext(
                'local_notionwebhook/webhooksecret',
                get_string('webhooksecret', 'local_notionwebhook'),
                get_string('webhooksecret_desc', 'local_notionwebhook'),
                '',
                PARAM_RAW_TRIMMED
            ));
      
            $settings->add(new admin_setting_configtext(
                'local_notionwebhook/alloweduserid',
                get_string('alloweduserid', 'local_notionwebhook'),
                get_string('alloweduserid_desc', 'local_notionwebhook'),
                '',
                PARAM_INT
            ));
      
            $ADMIN->add('localplugins', $settings);
        }
      
    • local/notionwebhook/lang/en/local_notionwebhook.php

        <?php
      
        defined('MOODLE_INTERNAL') || die();
      
        $string['pluginname'] = 'Notion Webhook (local)';
      
        $string['webhookurl'] = 'Webhook URL';
        $string['webhookurl_desc'] = 'POST destination URL for assignment events.';
      
        $string['webhooksecret'] = 'Webhook secret';
        $string['webhooksecret_desc'] = 'Shared secret used to compute X-Signature (HMAC-SHA256) over the raw request body.';
      
        $string['alloweduserid'] = 'Allowed user id';
        $string['alloweduserid_desc'] = 'Only send webhooks when this Moodle user id triggers the event. Leave empty to allow all.';
      
    • 作成・構文チェック:

        ls -la local/notionwebhook
        total 16
        drwxr-xr-x  7 hkob  admin  224 May 24 15:24 ./
        drwxr-xr-x  4 hkob  admin  128 May 24 14:56 ../
        drwxr-xr-x  3 hkob  admin   96 May 24 15:10 classes/
        drwxr-xr-x  3 hkob  admin   96 May 24 14:40 db/
        drwxr-xr-x  3 hkob  admin   96 May 24 15:21 lang/
        -rw-r--r--  1 hkob  admin  884 May 24 15:24 settings.php
        -rw-r--r--  1 hkob  admin  975 May 24 14:14 version.php
      
        ls -la local/notionwebhook/lang/en
        total 8
        drwxr-xr-x  3 hkob  admin   96 May 24 15:23 ./
        drwxr-xr-x  3 hkob  admin   96 May 24 15:21 ../
        -rw-r--r--  1 hkob  admin  547 May 24 15:25 local_notionwebhook.php
      
        php -l local/notionwebhook/settings.php
        No syntax errors detected in local/notionwebhook/settings.php
      
        php -l local/notionwebhook/lang/en/local_notionwebhook.php
        No syntax errors detected in local/notionwebhook/lang/en/local_notionwebhook.php
      
  6. Moodle にプラグインを配置してインストール(管理画面)

    • CLI で upgrade 実行(結果: upgrade 不要):

        php admin/cli/upgrade.php --non-interactive
        インストール済みバージョン 5.1.3+ (Build: 20260327) (2025100603.09) をアップグレードする必要はありません。ありがとうございます!
      
    • 補足(重要): $CFG->dirroot と作業ディレクトリが異なるため、プラグインが検出されていなかった

      • 作業ディレクトリ: /opt/homebrew/var/www/m2026
      • $CFG->dirroot: /opt/homebrew/var/www/m2026/public
      • local/notionwebhook$CFG->dirroot/local/ 側に配置が必要
      • 期待パス: /opt/homebrew/var/www/m2026/public/local/notionwebhook
    • キャッシュ削除 & upgrade(local プラグイン検出・反映):

        cd /opt/homebrew/var/www/m2026
        php admin/cli/purge_caches.php
        php admin/cli/upgrade.php --non-interactive
        -->local_notionwebhook
        ++ 成功 (0.03 秒) ++
        == 新しいデフォルト値の設定 ==
        新しい設定: local_notionwebhook/webhookurl
        新しい設定: local_notionwebhook/webhooksecret
        新しい設定: local_notionwebhook/alloweduserid
        コマンドラインによる 5.1.3+ (Build: 20260327) (2025100603.09) から 5.1.3+ (Build: 20260327) (2025100603.09) へのアップグレードが正常に完了しました。
      
    • 管理画面で「ローカルプラグイン」に Notion Webhook (local) が追加されたことを確認

      プラグインの表示

    • 「Notion Webhook (local)」をクリックすると、以下の設定項目が表示されることを確認(画像は後で添付)

      • Webhook URL
      • Webhook secret
      • Allowed user id

      プラグインの設定画面

  7. 課題作成でイベント発火→ Worker の run logs と Tasks 作成を確認

おわりに

とりあえず、この作業記録により Notion worker と moodle のプラグインの連携ができました。はまったのは、moodle のアップデートで dirroot が変わっていたこと、update を途中で追加したので、worker 側で蹴られていたことくらいでした。こういう作り方の方が、自分も中身も理解できるので、今後仕様変更などがあっても対応できそうな気がしました。今後も Ruby 以外の言語の場合には、こういうアシストを使っていこうと思います。

https://hkob.notion.site/hkob-16dd8e4e98ab807cbe3cf3cc94cdfe0f?pvs=4hkob.notion.site