master
ブランチ master
LittleBeast / src / Std / Lib / Notifications.php
6 KB、143行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib;
39
40use Std\Lib\Fedi\Fedi;
41use Result;
42
43/**
44 * サイトの通知(ヘッダーの「通知」)とメールの待ち行列
45 *
46 * データはdata/notifications/:
47 * {ユーザー名}.json 通知(新しい順、MAX件まで)
48 * mail/{ID}.json 送るメール(cronのnotifyが送る)
49 *
50 * 記事のコメントの通知(Comments)は表示する時に中身を探す。他の物(Gitler等)はlabel・page・excerptを入れて追加する。
51 */
52final class Notifications {
53 /** 1人の通知の最大数 */
54 public const MAX = 200;
55 /** メールの再試行の回数 */
56 private const MAIL_TRIES = 5;
57
58 /** @var string|null 保存先(テスト用。既定はdata) */
59 public static ?string $dataDir = null;
60
61 /**
62 * 通知(新しい順)
63 *
64 * @return array type、from、at、read等
65 */
66 public static function list(string $username): array {
67 if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return [];
68 return Fedi::read(self::path("notifications/{$username}.json"));
69 }
70
71 public static function unreadCount(string $username): int {
72 return count(array_filter(self::list($username), fn($n) => empty($n['read'])));
73 }
74
75 public static function markRead(string $username): void {
76 if (self::unreadCount($username) === 0) return;
77 Fedi::update(self::path("notifications/{$username}.json"), fn($l) => array_map(fn($n) => ['read' => true] + $n, $l));
78 }
79
80 /**
81 * 通知を追加する
82 *
83 * @param array $n type、from(username、又はactor・name・handle・url・icon)等。label・page・excerptが有れば表示にそのまま使う
84 * @param callable|null $same 未読の同じ通知が有れば追加しない(fn(array $old): bool)
85 */
86 public static function add(string $username, array $n, ?callable $same = null): void {
87 if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return;
88 Fedi::update(self::path("notifications/{$username}.json"), function(array $l) use ($n, $same): array {
89 if ($same !== null) foreach ($l as $old) if (empty($old['read']) && $same($old)) return $l;
90 array_unshift($l, $n + ['at' => time(), 'read' => false]);
91 return array_slice($l, 0, self::MAX);
92 });
93 }
94
95 //////// メール
96
97 /** メールを待ち行列に入れる(MAILER_ENABLEDでなければ何もしない) */
98 public static function queueMail(string $to, string $subject, string $body, string $toName = ''): void {
99 if (!MAILER_ENABLED || !filter_var($to, FILTER_VALIDATE_EMAIL)) return;
100 Fedi::write(self::path('notifications/mail/'.Fedi::newId().'.json'), ['to' => $to, 'subject' => $subject, 'body' => $body, 'name' => $toName, 'tries' => 0]);
101 }
102
103 /**
104 * 待っているメールを送る(cronのnotify)
105 *
106 * @param int $budget この秒数を超えたら次に回す
107 */
108 public static function sendMail(int $budget = 50): Result {
109 if (!MAILER_ENABLED) return Result::Success('メールは無効です。');
110 $start = time();
111 $sent = $failed = 0;
112 $files = glob(self::path('notifications/mail/').'*.json') ?: [];
113 sort($files);
114 foreach ($files as $file) {
115 if (time() - $start > $budget) break;
116 // 同時に動いた別のcronと重ならない様に、名前を変えて取る
117 $work = $file.'.work';
118 if (!@rename($file, $work)) continue;
119 $m = Fedi::read($work, null);
120 if (!is_array($m) || !is_string($m['to'] ?? null)) {
121 @unlink($work);
122 continue;
123 }
124 if (Mailer::deliver($m['to'], (string)$m['subject'], (string)$m['body'], (string)($m['name'] ?? '') ?: null)->isSuccess) {
125 @unlink($work);
126 $sent++;
127 continue;
128 }
129 $failed++;
130 $m['tries'] = (int)($m['tries'] ?? 0) + 1;
131 if ($m['tries'] < self::MAIL_TRIES) Fedi::write($file, $m);
132 @unlink($work);
133 }
134 return Result::Success("メール:送信{$sent}件、失敗{$failed}件", ['sent' => $sent, 'failed' => $failed]);
135 }
136
137 private static function path(string $rel): string {
138 $path = rtrim(self::$dataDir ?? ROOT.'/data', '/').'/'.$rel;
139 $dir = dirname($path);
140 if (!is_dir($dir)) @mkdir($dir, 0700, true);
141 return $path;
142 }
143}