wordpressにプッシュ通知機能を追加したいという案件に携わりました。
プッシュ通知側に関しては、APIのURLにPOSTする感じなのですが、思った以上にWordpres側で苦労しました。
今回は、ダミーでlogに出力し、各条件で1回ずつpush通知が走っているかどうかを確認します。
・wpでプッシュ通知したい
・FCMかなにかのAPIにpostで送っている
・投稿画面にチェックボックスを追加してチェックがついた時のみ
・新規、上書き、予約投稿時のみ公開ボタン押したら飛ばしたい
・二重送信防止のフラグもいる
・プラグインではなく、functions.phpに入れる
できること
- 投稿編集画面の右側にチェックボックスを出す
- チェック状態を保存する(次回開いたときも維持)
- 公開時 / 更新時に通知する
- 予約投稿が公開になった瞬間にも通知する
- 二重送信を防ぐ(かなり丁寧)
動きの流れ(処理の順番)
投稿編集画面でユーザーが操作したとき、ざっくりこう動きます。
- メタボックス表示
投稿画面の右側に「プッシュ通知」のチェックボックスを表示 - 保存時にチェック状態を保存(save_post)
ON/OFFを post_meta に保存 - 公開/更新時に送信判定(wp_after_insert_post)
管理画面からの publish 保存なら送信 - 予約投稿が公開された瞬間に送信(transition_post_status)
future → publish のとき送信 - 送信本体(my_push_send_once)
二重送信防止をしながら、my_push_send_api() を呼ぶ - MVP送信(今はログ出力)(my_push_send_api)
debug.log に JSON を書く
なぜこの構成が必要なのか(設計の考え方)
-
チェックボックスの表示と保存は別物
表示するだけでは保存されない
保存する処理(save_post)が必要 -
通知タイミングは1つのフックだけでは足りない
通常の公開・更新 → wp_after_insert_post
予約投稿が公開になる瞬間 → transition_post_status
予約投稿は「管理画面で更新ボタンを押した瞬間」ではなく、WPのスケジュール処理で公開されるので、別フックが必要です。 -
WordPressは同じ保存処理で複数フックが動くことがある
そのため、何も対策しないと通知が2回飛ぶことがある。
→ だから my_push_send_once() で 三重の保険 を入れている。
コードを上から順に説明(役割+なぜ必要か)
0. コメントブロック(冒頭の説明)
/**
* WordPress 管理画面からの「疑似プッシュ通知」MVP
* ...
*/
何をしている?
このファイルの目的、仕様、制限を書いている説明書です。
なぜ必要?
あとで自分や他の人が見たときに、
- 何ができるコードなのか
- 何はまだ未実装なのか
がすぐ分かるからです。
特に今回のような「MVP(まず動く最小構成)」は、コメントがすごく大事です。
1. 対象投稿タイプ
function my_push_target_post_types(): array { return ['post']; }
何をしている?
通知対象にする投稿タイプを返しています。今は post のみ。
なぜ必要?
今後、
- 固定ページ(
page) - カスタム投稿(例
news,event)
にも対応したくなったときに、ここだけ直せば済むようにするためです。
例(将来)
return ['post', 'page', 'event'];
2. 定数(メタキーなど)
const MY_PUSH_META_ENABLE = '_my_push_enable';
const MY_PUSH_META_LAST_GMT = '_my_push_last_sent_post_modified_gmt';
define('MY_PUSH_API_KEY', 'CHANGE_ME_LONG_RANDOM');
何をしている?
文字列の「名前」を定数にしています。
-
MY_PUSH_META_ENABLE
→ チェックON/OFF保存用の post_meta キー -
MY_PUSH_META_LAST_GMT
→ 最後に送信した更新時刻(GMT)保存用キー -
MY_PUSH_API_KEY
→ REST API を外から叩く時の認証キー
なぜ必要?
文字列をコード内にベタ書きすると、
- タイプミスしやすい
- 変更時に漏れやすい
定数にすると安全で読みやすいです。
3. ログ出力関数
function my_push_log(string $msg): void {
$file = WP_CONTENT_DIR . '/debug.log';
@file_put_contents($file, '[' . gmdate('c') . '] ' . $msg . PHP_EOL, FILE_APPEND);
}
何をしている?
wp-content/debug.log に1行ずつ追記します。
なぜ必要?
MVPでは実際の通知サービス(FCMなど)ではなく、まずは「送信されたつもり」をログで確認するため。
この設計の良い点
- Apacheログより見やすい
- WP側の処理として追跡しやすい
- 後で
my_push_send_api()を差し替えれば本番通知に移行できる
補足
@file_put_contents の @ はエラー抑制です。
MVPでは手軽ですが、本番ではログ失敗も追いたいなら外すこともあります。
4. メタボックス追加(右側チェックボックス表示)
add_action('add_meta_boxes', function(){
foreach (my_push_target_post_types() as $pt) {
add_meta_box(...)
}
});
何をしている?
投稿編集画面の右側サイドバーに「プッシュ通知」ボックスを追加しています。
中で表示しているもの:
- チェックボックス
- nonce(改ざん防止トークン)
add_action('add_meta_boxes', ...) が必要な理由
WordPressでは、管理画面に独自のUI(メタボックス)を追加するときは、このフックを使うのが定番です。
中のコールバック(メタボックスの描画)
$enabled = get_post_meta($post->ID, MY_PUSH_META_ENABLE, true);
wp_nonce_field('my_push_save', 'my_push_nonce');
何をしている?
- 現在のチェック状態を DB から読む
- nonce を出力する(保存時の正当性確認用)
なぜ必要?
チェック状態を保持して再表示するため。
nonce は「他サイトから勝手にPOSTされる攻撃(CSRF)」対策です。
checked($enabled,'1'); の意味
<input type="checkbox" ... <?php checked($enabled,'1');?>>
何をしている?
$enabled === '1' のときだけ checked="checked" を出力してくれるWP関数です。
なぜ必要?
自分で if 文で属性を書くより安全で読みやすい。
例(内部的にはこんな感じ)
if ($enabled == '1') echo 'checked="checked"';
5. チェック状態の保存(save_post)
add_action('save_post', function($post_id, $post, $update){ ... }, 10, 3);
ここはとても大事です。
「チェックが次回開くと外れる」問題は、ここが不十分だと起きます。
何をしている?
投稿保存時に、チェック状態を post_meta に保存しています。
各ガード(return)の意味と「なぜ必要か」
対象投稿タイプだけに限定
if (!in_array($post->post_type, my_push_target_post_types(), true)) return;
→ 投稿以外(添付ファイルなど)で動かさないため。
自動保存/リビジョン除外
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_autosave($post_id)) return;
if (wp_is_post_revision($post_id)) return;
→ WordPressは裏で自動保存やリビジョン保存を行うので、そこでも保存処理が走ることがあります。
そこでメタを書き換えると意図しない挙動になりやすい。
権限チェック
if (!current_user_can('edit_post', $post_id)) return;
→ 編集権限がないユーザーからの保存を拒否。セキュリティ上必要。
nonceチェック
if (!isset($_POST['my_push_nonce']) || !wp_verify_nonce($_POST['my_push_nonce'], 'my_push_save')) return;
→ 正規の編集画面からの保存か確認するため。
チェックボックスの保存で最重要ポイント
$enable = (isset($_POST['my_push_enable']) && $_POST['my_push_enable'] === '1') ? '1' : '0';
なぜ isset() が必要?
HTMLのチェックボックスは OFFのときPOSTに送られません。
つまり、未チェックだと $_POST['my_push_enable'] 自体が存在しない。
そのため、以下のようにすると警告が出ることがあります。
$_POST['my_push_enable'] // OFF時は未定義
今回のコードはこの点を正しく処理できています。素晴らしいです。
保存
update_post_meta($post_id, MY_PUSH_META_ENABLE, $enable);
→ '1' or '0' を保存。
次回メタボックス表示時に checked() の判定に使われます。
6. 通常の公開/更新で送信(wp_after_insert_post)
add_action('wp_after_insert_post', function($post_id, $post, $update, $before){ ... }, 10, 4);
何をしている?
投稿が保存された後に、通知を送るか判定しています。
なぜ wp_after_insert_post を使っている?
- 投稿データが保存された後なので、タイトルやURLが安定して取得しやすい
-
$postオブジェクトが使える - 新規・更新の両方を扱いやすい
なぜこんなに条件が多い?
WordPressは同じ保存処理の中で色々なケースが混じるので、必要なものだけに絞るためです。
特に重要な条件① 管理画面の editpost だけに限定
if (!is_admin()) return;
if (empty($_POST['action']) || $_POST['action'] !== 'editpost') return;
なぜ必要?
wp_after_insert_post は、
- REST API更新
- XML-RPC
- 他プラグインの更新
などでも走る可能性があります。
MVP段階では「管理画面の更新/公開ボタン」だけを対象にした方が安定しやすいです。
特に重要な条件② チェックONのときだけ
if (get_post_meta($post_id, MY_PUSH_META_ENABLE, true) !== '1') return;
→ ユーザーが通知したい投稿だけ通知するため。
特に重要な条件③ 予約中(future)はここで送らない
if ($post->post_status === 'future') return;
なぜ必要?
予約投稿はこの時点ではまだ未公開。
公開になった瞬間に送りたいので、ここでは送らない。
publish のとき送信
if ($post->post_status === 'publish') {
my_push_send_once($post, 'wp_after_insert_post');
}
→ 新規公開 / 更新 の両方で送る。
7. 予約投稿の公開瞬間に送信(transition_post_status)
add_action('transition_post_status', function($new, $old, $post){ ... }, 10, 3);
何をしている?
投稿ステータスが変わった瞬間を監視しています。
特に、
-
future(予約投稿) - →
publish(公開)
になった瞬間だけ通知。
なぜこれが必須?
予約投稿は、設定した時刻になるとWordPress側のスケジュール処理で公開されます。
そのときは管理画面の「更新」ボタンを押していないので、$_POST['action'] === 'editpost' の条件に入らない。
つまり、wp_after_insert_post だけでは予約公開を取りこぼす可能性があるためです。
判定ロジック
if ($new === 'publish' && $old === 'future') {
if (get_post_meta($post->ID, MY_PUSH_META_ENABLE, true) === '1') {
my_push_send_once($post, 'transition_post_status');
}
}
→ 予約から公開に変わったときだけ、かつチェックONだけ送る。
8. 二重送信防止+送信本体(最重要)
function my_push_send_once(WP_Post $post, string $from_hook = ''): void { ... }
この関数はかなり良い設計です。
「通知を送る前に、二重送信じゃないかを確認する司令塔」です。
役割
- 複数フックから呼ばれても1回だけ送る
- 同一更新で2回送らない
- 近接タイミングの重複も防ぐ
- 送信用 payload を組み立てる
- 実際の送信関数を呼ぶ
① 同一リクエスト内の重複防止(static)
static $already_sent = [];
if (!empty($already_sent[$post->ID])) return;
$already_sent[$post->ID] = true;
何をしている?
同じPHPリクエスト中で、同じ投稿IDに対して2回目以降は送らない。
なぜ必要?
1回の保存処理の中で複数のフックが連続して発火することがあるため。
② 同一更新判定(post_modified_gmt)
$modified_gmt = $post->post_modified_gmt ?: gmdate('Y-m-d H:i:s');
$last = get_post_meta($post->ID, MY_PUSH_META_LAST_GMT, true);
if ($last === $modified_gmt) return;
何をしている?
この投稿の「最終更新時刻(GMT)」が、前回送信済みの時刻と同じなら送らない。
なぜ必要?
同じ保存操作に対して別の経路で送信関数が呼ばれても、「同じ更新」とみなして止めるため。
これはかなり効く保険です。
③ 短時間ロック(transient)
$lock = 'my_push_lock_' . $post->ID;
if (get_transient($lock)) return;
set_transient($lock, 1, 30);
何をしている?
30秒間のロックをかける。
なぜ必要?
別リクエスト(例えば同時実行・再送・予期しない二重実行)で近接して処理が走った場合の保険。
static は同一リクエスト内だけですが、transient はDB/キャッシュ経由なので別リクエストにも効きます。
payload 作成
$payload = [
'title' => get_bloginfo('name'),
'body' => '更新: ' . wp_strip_all_tags(get_the_title($post)),
'url' => get_permalink($post),
'post_id' => (string)$post->ID,
];
何をしている?
通知に必要なデータを1つの配列にまとめている。
なぜ必要?
送信先(ログ/REST/FCM)を変えても、通知データの形を統一できるから。
この「payloadを先に作る」設計は後で拡張しやすいです。
送信して成功したら送信済み時刻を保存
$ok = my_push_send_api($payload, $from_hook);
if ($ok) {
update_post_meta($post->ID, MY_PUSH_META_LAST_GMT, $modified_gmt);
}
なぜ成功時だけ保存?
送信失敗したのに「送信済み」と記録してしまうと、再送できなくなるから。
ここも正しい設計です。
ロック解除
delete_transient($lock);
なぜ必要?
正常終了後すぐ解除して、次の本当の更新を邪魔しないようにするため。
※ただし「送信処理が途中で致命的エラーになった場合」にロックだけ残る可能性はありますが、30秒で切れるのでMVPとしては十分です。
9. 送信API(MVPはログ出力)
function my_push_send_api(array $payload, string $from_hook = ''): bool {
my_push_log('[my-push] SEND from=' . $from_hook . ' ' . wp_json_encode($payload, JSON_UNESCAPED_UNICODE));
return true;
}
何をしている?
今は「送信したことにして」ログを書いています。
なぜ必要?
まず通知トリガーの正しさを検証するため。
本物の通知(FCM等)を先に入れると、原因切り分けが難しくなるからです。
from_hook をログに入れている理由
どのフック経由で送られたか分かる。
例:
from=wp_after_insert_postfrom=transition_post_status
これがあると、通常公開なのか予約公開なのかが追跡しやすいです。
コメントアウトされている wp_remote_post() 部分
これは将来用の差し替え候補です。
何を意図している?
WPのREST API(自サイトの /wp-json/my-push/v1/send)にPOSTする例を残している。
注意(コメントにもある通り)
「自分自身にHTTPでPOST」は、環境によっては
- ループバック失敗
- タイムアウト
- 認証/SSL問題
が出やすいので、MVPではログだけにしてるのは正解です。
10. 自前REST API(外部から叩く用)
add_action('rest_api_init', function () {
register_rest_route('my-push/v1', '/send', [...]);
});
何をしている?
POST /wp-json/my-push/v1/send を受けるAPIを登録しています。
なぜ必要?
将来的に
- 別サーバー
- バッチ
- 外部通知サービス連携
から通知を受けたい場合に使えるようにしている。
MVPの時点で「将来の拡張口」を用意している感じです。
permission_callback => '__return_true' なのに安全なの?
'permission_callback' => '__return_true',
一見危なそうですが、実際にはコールバック my_push_rest_send() の中で
$key = $req->get_header('x-api-key');
if (!$key || !hash_equals(MY_PUSH_API_KEY, $key)) {
return new WP_REST_Response(['ok'=>false,'error'=>'unauthorized'], 401);
}
と APIキー認証しているので、MVPとしてはOKです。
ただし本番では
- APIキーを本当に長いランダム値にする
-
wp-config.phpなどに置く - 可能ならIP制限/署名/HTTPS前提
を検討するとより安全です。
入力値のバリデーション / サニタイズ
$data = $req->get_json_params();
$title = sanitize_text_field($data['title'] ?? '');
$body = sanitize_textarea_field($data['body'] ?? '');
なぜ必要?
外部入力は信用しないのが原則。
文字列を安全に整えてから使います。
必須チェック
if ($title === '' || $body === '') {
return new WP_REST_Response(['ok'=>false,'error'=>'title/body required'], 400);
}
→ タイトルか本文が空ならエラー。
受信ログ
my_push_log('[my-push] received ' . wp_json_encode($data, JSON_UNESCAPED_UNICODE));
→ RESTの疎通確認用。MVPとしてとても役立つ。
11. 管理画面のテスト表示(admin_notices)
add_action('admin_notices', function () {
echo '<div class="notice notice-success"><p>my-push: functions.php loaded</p></div>';
});
何をしている?
管理画面上部に「functions.php loaded」の通知を表示。
なぜ必要?
「そもそもこのコードが読み込まれているか?」の確認用。
使いどころ
- functions.phpに書いたけど反映されない
- テーマ違いの可能性
- PHPエラーで途中までしか読まれてない可能性
の切り分けに便利。
注意
常に表示されるので、動作確認が終わったら削除/コメントアウト推奨。
このコードの「特に良いところ」
あなたが理解しておくと自信になるポイントです。
1. チェックボックスOFF時のPOST未送信を正しく処理している
isset($_POST['my_push_enable'])
これは実務でよくハマるところ。正解です。
2. 予約投稿に対応している
transition_post_status を使っているので「未来公開」を取りこぼしにくい。
3. 二重送信対策が丁寧
- static
- transient
- post_modified_gmt
の3段構えはかなりしっかりしてます。
4. 送信処理を関数分離している
my_push_send_api() を差し替えるだけで本番移行しやすい。
初学者向けに「関数・フック」を超ざっくり整理
add_action(...) とは?
WordPressの「このタイミングでこの処理をしてね」という登録。
例:
-
save_post→ 投稿保存時 -
add_meta_boxes→ 編集画面作成時 -
rest_api_init→ REST API登録時
無名関数 function(){ ... } を使っている理由
短い処理をその場で書けるからです。
別に名前付き関数でもOKです。
例(同じ意味)
add_action('add_meta_boxes', 'my_push_add_boxes');
function my_push_add_boxes() {
...
}
return; が多い理由
「条件に合わないものを最初に落とす」ため。
これを ガード節 と言います。
メリット:
- ネストが浅くなる
- 読みやすい
- バグが減る
ここだけ注意(今後改善ポイント)
MVPとしては十分ですが、将来のために知っておくと良い点です。
1. MY_PUSH_API_KEY を functions.php に直書き
本番では wp-config.php に置く方が安全です。
2. admin_notices のHTMLを常時出している
検証後は削除する。
3. $_POST 参照時の wp_unslash()(厳密には)
WordPressは $_POST にスラッシュが入る場合があるので、厳密運用なら wp_unslash() を通す場面があります。
今回の '1' 判定なら大きな問題にはなりにくいですが、覚えておくと良いです。
あなたがこのコードを説明するときの言い方(そのまま使える)
社内・お客様向けに説明しやすい文章です。
一言で
投稿編集画面で「通知する」を選んだ投稿だけ、公開・更新・予約公開のタイミングで通知処理を呼ぶ仕組みです。
まずは実通知の代わりにログ出力で動作確認できるMVP構成にしています。
二重送信防止の説明
WordPressは保存時に複数のフックが動くことがあるため、同一リクエスト・短時間連続実行・同一更新時刻の3段階で重複送信を防いでいます。
予約投稿の説明
通常の保存フックだけでは予約投稿の公開瞬間を拾いにくいため、ステータス遷移(future→publish)を監視するフックを併用しています。
最後に
このコードを理解できたか確認するポイントは、この3つです。
-
チェックボックス表示と保存は別処理(
add_meta_boxesとsave_post) -
通常公開と予約公開でフックが違う(
wp_after_insert_postとtransition_post_status) -
送信前に二重送信防止を必ず通す(
my_push_send_once)
<?php
/**
* WordPress 管理画面からの「疑似プッシュ通知」MVP
*
* ✅ 投稿編集画面の右側にチェックボックスを追加
* ✅ チェックONのときだけ通知
* ✅ 新規公開 / 更新(publish)時に通知(更新のたび送る)
* ✅ 予約投稿(future→publish)で公開になった瞬間にも通知
* ✅ 二重送信防止(同一リクエスト / 短時間 / 同一更新)を入れる
* ✅ 通知の保存(CPTなど)は不要(post_metaだけ使う)
*
* ※このMVPでは「送信」はログ出力(wp-content/debug.log)で代用。
* 後で外部API/FCM/WebPushに差し替えるなら my_push_send_api() を変更。
*/
/** 対象投稿タイプ(必要なら配列に追加) */
function my_push_target_post_types(): array { return ['post']; }
/** チェック保持用メタキー */
const MY_PUSH_META_ENABLE = '_my_push_enable';
/** 同一更新の二重送信防止用メタキー */
const MY_PUSH_META_LAST_GMT = '_my_push_last_sent_post_modified_gmt';
/** REST API用の共通鍵(外部から叩くなら長いランダムに) */
define('MY_PUSH_API_KEY', 'CHANGE_ME_LONG_RANDOM');
/**
* ログ出力(wp-content/debug.log に追記)
* Apache/PHPのerror_log先が環境でブレるので、MVPはこの方法が安定です。
*/
function my_push_log(string $msg): void {
$file = WP_CONTENT_DIR . '/debug.log';
@file_put_contents($file, '[' . gmdate('c') . '] ' . $msg . PHP_EOL, FILE_APPEND);
}
/** 1) 右側メタボックス:通知ON/OFF */
add_action('add_meta_boxes', function(){
foreach (my_push_target_post_types() as $pt) {
add_meta_box('my_push_box', 'プッシュ通知', function($post){
$enabled = get_post_meta($post->ID, MY_PUSH_META_ENABLE, true);
wp_nonce_field('my_push_save', 'my_push_nonce');
?>
<label>
<input type="checkbox" name="my_push_enable" value="1" <?php checked($enabled,'1');?>>
公開時(更新・予約公開含む)に通知する(更新のたび送る)
</label>
<?php
}, $pt, 'side', 'high');
}
});
/** 2) チェック状態を post_meta に保存(次回も checked のまま) */
add_action('save_post', function($post_id, $post, $update){
if (!in_array($post->post_type, my_push_target_post_types(), true)) return;
// 自動保存/リビジョンは除外
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_autosave($post_id)) return;
if (wp_is_post_revision($post_id)) return;
// 権限・nonceチェック
if (!current_user_can('edit_post', $post_id)) return;
if (!isset($_POST['my_push_nonce']) || !wp_verify_nonce($_POST['my_push_nonce'], 'my_push_save')) return;
// ★チェックOFFのときはPOSTに値が来ない → issetで判定するのが正解
$enable = (isset($_POST['my_push_enable']) && $_POST['my_push_enable'] === '1') ? '1' : '0';
update_post_meta($post_id, MY_PUSH_META_ENABLE, $enable);
}, 10, 3);
/**
* 3) 新規/更新(publish)で送信
* wp_after_insert_post は保存周辺で複数回走るケースがあるので、
* 「管理画面の更新/公開ボタンによる保存」だけに寄せて二重を減らします。
*/
add_action('wp_after_insert_post', function($post_id, $post, $update, $before){
if (!in_array($post->post_type, my_push_target_post_types(), true)) return;
// 自動保存/リビジョンは除外
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_autosave($post_id)) return;
if (wp_is_post_revision($post_id)) return;
// 管理画面の「更新/公開」ボタンによる保存だけ(MVPではこれが安定)
if (!is_admin()) return;
if (empty($_POST['action']) || $_POST['action'] !== 'editpost') return;
// チェックONのときだけ
if (get_post_meta($post_id, MY_PUSH_META_ENABLE, true) !== '1') return;
// 予約(future)は公開になった瞬間に送るのでここでは送らない
if ($post->post_status === 'future') return;
// 公開済み(publish)の保存(新規公開/更新)で送る
if ($post->post_status === 'publish') {
my_push_send_once($post, 'wp_after_insert_post');
}
}, 10, 4);
/**
* 4) 予約公開(future→publish)で送信
* ※このフックは「公開になった瞬間」を捉えるために必須
*/
add_action('transition_post_status', function($new, $old, $post){
if (!in_array($post->post_type, my_push_target_post_types(), true)) return;
if ($new === 'publish' && $old === 'future') {
if (get_post_meta($post->ID, MY_PUSH_META_ENABLE, true) === '1') {
my_push_send_once($post, 'transition_post_status');
}
}
}, 10, 3);
/**
* 5) 二重送信防止+送信本体
* - 同一リクエスト内の重複を止める(static)
* - 同時実行/短時間の重複を止める(transient 30秒)
* - 同一更新(post_modified_gmt)なら止める(post_meta)
*/
function my_push_send_once(WP_Post $post, string $from_hook = ''): void {
// 同一リクエスト内の二重発火を止める
static $already_sent = [];
if (!empty($already_sent[$post->ID])) return;
$already_sent[$post->ID] = true;
$modified_gmt = $post->post_modified_gmt ?: gmdate('Y-m-d H:i:s');
// 同一更新なら送らない(同じ保存処理が複数フックで走った時の保険)
$last = get_post_meta($post->ID, MY_PUSH_META_LAST_GMT, true);
if ($last === $modified_gmt) return;
// 短時間ロック(別リクエストで近接して走る場合の保険)
$lock = 'my_push_lock_' . $post->ID;
if (get_transient($lock)) return;
set_transient($lock, 1, 30);
$payload = [
'title' => get_bloginfo('name'),
'body' => '更新: ' . wp_strip_all_tags(get_the_title($post)),
'url' => get_permalink($post),
'post_id' => (string)$post->ID,
];
$ok = my_push_send_api($payload, $from_hook);
if ($ok) {
update_post_meta($post->ID, MY_PUSH_META_LAST_GMT, $modified_gmt);
}
delete_transient($lock);
}
/**
* 6) 送信API(MVPはログ出力)
* ※後で外部API/FCM/WebPushに差し替える場合は、この関数を変更すればOK。
*/
function my_push_send_api(array $payload, string $from_hook = ''): bool {
// MVP:wp-content/debug.log に出す(Apacheログを汚さない)
my_push_log('[my-push] SEND from=' . $from_hook . ' ' . wp_json_encode($payload, JSON_UNESCAPED_UNICODE));
return true;
// ---- 自分自身のRESTへHTTPしたい場合(推奨しないが残しておく) ----
/*
$endpoint = rest_url('my-push/v1/send');
$res = wp_remote_post($endpoint, [
'headers' => [
'Content-Type' => 'application/json; charset=utf-8',
'X-API-KEY' => MY_PUSH_API_KEY,
],
'timeout' => 15,
'redirection' => 3,
'body' => wp_json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
if (is_wp_error($res)) {
my_push_log('[my-push] http error: ' . $res->get_error_message());
return false;
}
$code = wp_remote_retrieve_response_code($res);
$body = wp_remote_retrieve_body($res);
my_push_log('[my-push] http response: ' . $code . ' body=' . $body);
return ($code >= 200 && $code < 300);
*/
}
/**
* 7) 自前REST API(外から叩く用)
* POST /wp-json/my-push/v1/send
*/
add_action('rest_api_init', function () {
register_rest_route('my-push/v1', '/send', [
'methods' => 'POST',
'callback' => 'my_push_rest_send',
'permission_callback' => '__return_true',
]);
});
function my_push_rest_send(WP_REST_Request $req) {
$key = $req->get_header('x-api-key');
if (!$key || !hash_equals(MY_PUSH_API_KEY, $key)) {
return new WP_REST_Response(['ok'=>false,'error'=>'unauthorized'], 401);
}
$data = $req->get_json_params();
$title = sanitize_text_field($data['title'] ?? '');
$body = sanitize_textarea_field($data['body'] ?? '');
if ($title === '' || $body === '') {
return new WP_REST_Response(['ok'=>false,'error'=>'title/body required'], 400);
}
// 受信ログ(MVP)
my_push_log('[my-push] received ' . wp_json_encode($data, JSON_UNESCAPED_UNICODE));
return new WP_REST_Response(['ok'=>true], 200);
}
/**
* 8) テスト用:functions.php が読み込まれているか(不要になったら削除OK)
* ※「init fired」はログ洪水になるので入れない(必要なら特定フックに限定)
*/
add_action('admin_notices', function () {
echo '<div class="notice notice-success"><p>my-push: functions.php loaded</p></div>';
});
よくある「二重送信」「送れたり送れなかったり」の原因と対策
• save_post / transition_post_status / wp_after_insert_post が 複数回走る
→ このコードは
• pending(送信待ち)
• post_modified_gmt の一致チェック
• transientロック
の 三段で抑えます。
• 予約投稿は future で保存→ publish に変わる瞬間が別
→ future_to_publish 相当の動き(transition_post_status)で送っています。