はじめに
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 作成手順(案)
mkdir -p ~/Dropbox/workers/moodle-webhookcd ~/Dropbox/workers/moodle-webhookntn 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(実施済み)
ntn update- 結果: ✔ Updated ntn to v0.14.1
- 補足:
npx ntnは実行時にntn@0.14.1を一時インストールするため、未インストール環境だとOk to proceed? (y)が出る(正常)。
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 } }] }, }, }); } }, });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
- 目的: Worker ID を作成して
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=...はできない - 対応:
.envにNOTION_API_TOKEN=...を置き、ntn workers env pushで反映(CLI が対応している方法で設定する)
- webhook capability から
- 備考:
- 環境変数名は
NOTION_で始められない(reserved prefix) ntn workers env set ...でNo worker ID foundが出る場合、workers.jsonが未生成なので先に deploy する
- 環境変数名は
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
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で確認)
- WEBHOOK_URL には Markdown 形式(
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_ プラグイン作成手順(概要)
local/notionwebhook/を作成実行:
mkdir -p local/notionwebhook確認:
ls -la local | grep notionwebhook drwxr-xr-x 2 hkob admin 64 May 24 13:32 notionwebhook
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.09requiresは通常 integer を想定するので、2025100603(小数点以下を除いた値)を使う
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のメソッド名に合わせる
- ※ callback は Step 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
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
- 作業結果: settings.php / lang の作成と
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/publiclocal/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

プラグインの設定画面
課題作成でイベント発火→ Worker の run logs と Tasks 作成を確認
おわりに
とりあえず、この作業記録により Notion worker と moodle のプラグインの連携ができました。はまったのは、moodle のアップデートで dirroot が変わっていたこと、update を途中で追加したので、worker 側で蹴られていたことくらいでした。こういう作り方の方が、自分も中身も理解できるので、今後仕様変更などがあっても対応できそうな気がしました。今後も Ruby 以外の言語の場合には、こういうアシストを使っていこうと思います。
https://hkob.notion.site/hkob-16dd8e4e98ab807cbe3cf3cc94cdfe0f?pvs=4hkob.notion.site