• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / #2526

13 Dec 2025 02:30PM UTC coverage: 90.238% (-0.03%) from 90.269%
#2526

push

php-coveralls

edwh
Fix empty push notifications and remove legacy notification code.

- Add notification text and route for TYPE_EXHORT notifications so
  "Could you review..." push notifications include the message body.
- Remove legacy dual-notification sending (was sending both legacy
  and new format for backwards compatibility).
- Remove legacy Android notification handling that used FCM auto-display.
- All apps now use data-only messages and handle notification display.

14 of 15 new or added lines in 2 files covered. (93.33%)

6 existing lines in 1 file now uncovered.

26549 of 29421 relevant lines covered (90.24%)

31.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.0
/include/user/PushNotifications.php
1
<?php
2
namespace Freegle\Iznik;
3

4
use Minishlink\WebPush\WebPush;
5
use Minishlink\WebPush\Subscription;
6
use Pheanstalk\Pheanstalk;
7
use Kreait\Firebase\Factory;
8
use Kreait\Firebase\ServiceAccount;
9
use Kreait\Firebase\Messaging\CloudMessage;
10
use Kreait\Firebase\Messaging\AndroidConfig;
11
use Kreait\Firebase\Messaging\ApnsConfig;
12
use Kreait\Firebase\Exception\Messaging\InvalidMessage;
13

14
class PushNotifications
15
{
16
    const PUSH_GOOGLE = 'Google'; // Obsolete
17
    const PUSH_FIREFOX = 'Firefox'; // Obsolete
18
    const PUSH_TEST = 'Test';
19
    const PUSH_FCM_ANDROID = 'FCMAndroid';
20
    const PUSH_FCM_IOS = 'FCMIOS';
21
    const APPTYPE_MODTOOLS = 'ModTools';
22
    const APPTYPE_USER = 'User';
23
    const PUSH_BROWSER_PUSH = 'BrowserPush';
24

25
    // Notification categories - these map to Android channels and iOS categories
26
    const CATEGORY_CHAT_MESSAGE = 'CHAT_MESSAGE';
27
    const CATEGORY_CHITCHAT_COMMENT = 'CHITCHAT_COMMENT';
28
    const CATEGORY_CHITCHAT_REPLY = 'CHITCHAT_REPLY';
29
    const CATEGORY_CHITCHAT_LOVED = 'CHITCHAT_LOVED';
30
    const CATEGORY_POST_REMINDER = 'POST_REMINDER';
31
    const CATEGORY_NEW_POSTS = 'NEW_POSTS';
32
    const CATEGORY_COLLECTION = 'COLLECTION';
33
    const CATEGORY_EVENT_SUMMARY = 'EVENT_SUMMARY';
34
    const CATEGORY_EXHORT = 'EXHORT';
35

36
    // Category configuration: iOS interruption level, Android channel ID, Android priority
37
    const CATEGORIES = [
38
        self::CATEGORY_CHAT_MESSAGE => [
39
            'ios_interruption' => 'time-sensitive',
40
            'android_channel' => 'chat_messages',
41
            'android_priority' => 'high'
42
        ],
43
        self::CATEGORY_CHITCHAT_COMMENT => [
44
            'ios_interruption' => 'passive',
45
            'android_channel' => 'social',
46
            'android_priority' => 'normal'
47
        ],
48
        self::CATEGORY_CHITCHAT_REPLY => [
49
            'ios_interruption' => 'passive',
50
            'android_channel' => 'social',
51
            'android_priority' => 'normal'
52
        ],
53
        self::CATEGORY_CHITCHAT_LOVED => [
54
            'ios_interruption' => 'passive',
55
            'android_channel' => 'social',
56
            'android_priority' => 'normal'
57
        ],
58
        self::CATEGORY_POST_REMINDER => [
59
            'ios_interruption' => 'active',
60
            'android_channel' => 'reminders',
61
            'android_priority' => 'normal'
62
        ],
63
        self::CATEGORY_NEW_POSTS => [
64
            'ios_interruption' => 'passive',
65
            'android_channel' => 'new_posts',
66
            'android_priority' => 'normal'
67
        ],
68
        self::CATEGORY_COLLECTION => [
69
            'ios_interruption' => 'active',
70
            'android_channel' => 'reminders',
71
            'android_priority' => 'normal'
72
        ],
73
        self::CATEGORY_EVENT_SUMMARY => [
74
            'ios_interruption' => 'passive',
75
            'android_channel' => 'social',
76
            'android_priority' => 'normal'
77
        ],
78
        self::CATEGORY_EXHORT => [
79
            'ios_interruption' => 'passive',
80
            'android_channel' => 'tips',
81
            'android_priority' => 'normal'
82
        ]
83
    ];
84

85
    private $dbhr, $dbhm, $log, $pheanstalk = NULL, $firebase = NULL, $messaging = NULL;
86

87
    function __construct(LoggedPDO $dbhr, LoggedPDO $dbhm)
88
    {
89
        $this->dbhr = $dbhr;
652✔
90
        $this->dbhm = $dbhm;
652✔
91
        $this->log = new Log($dbhr, $dbhm);
652✔
92

93
        if (file_exists('/etc/firebase.json')) {
652✔
94
            $factory = (new Factory)
×
95
                ->withServiceAccount('/etc/firebase.json');
×
96
            $this->messaging = $factory->createMessaging();
×
97
        }
98
    }
99

100
    public function get($userid)
101
    {
102
        # Cache the notification - saves a DB call in GET of session, which is very common.
103
        $ret = Utils::presdef('notification', $_SESSION, []);
21✔
104

105
        if (!$ret) {
21✔
106
            $sql = "SELECT * FROM users_push_notifications WHERE userid = ?;";
21✔
107
            $notifs = $this->dbhr->preQuery($sql, [$userid]);
21✔
108
            foreach ($notifs as &$notif) {
21✔
109
                $notif['added'] = Utils::ISODate($notif['added']);
3✔
110
                $ret[] = $notif;
3✔
111
            }
112

113
            $_SESSION['notification'] = $ret;
21✔
114
        }
115

116
        return ($ret);
21✔
117
    }
118

119
    public function add($userid, $type, $val, $modtools = NULL)
120
    {
121
        if (is_null($modtools)) {
14✔
122
            $modtools = Session::modtools();
2✔
123
        }
124

125
        $rc = NULL;
14✔
126

127
        if ($userid) {
14✔
128
            $apptype = $modtools ? PushNotifications::APPTYPE_MODTOOLS : PushNotifications::APPTYPE_USER;
14✔
129
            $sql = "INSERT INTO users_push_notifications (`userid`, `type`, `subscription`, `apptype`) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE userid = ?, type = ?, apptype = ?;";
14✔
130
            $rc = $this->dbhm->preExec($sql, [$userid, $type, $val, $apptype, $userid, $type, $apptype]);
14✔
131
            Session::clearSessionCache();
14✔
132
        }
133

134
        return ($rc);
14✔
135
    }
136

137
    public function remove($userid)
138
    {
139
        $sql = "DELETE FROM users_push_notifications WHERE userid = ?;";
1✔
140
        $rc = $this->dbhm->preExec($sql, [$userid]);
1✔
141
        return ($rc);
1✔
142
    }
143

144
    public function uthook($rc = NULL)
145
    {
146
        # Mocked in UT to force an exception.
147
        return ($rc);
124✔
148
    }
149

150
    private function queueSend($userid, $type, $params, $endpoint, $payload)
151
    {
152
        #error_log("queueSend $userid $endpoint params " . var_export($params, TRUE));
153
        try {
154
            $this->uthook();
4✔
155

156
            if (!$this->pheanstalk) {
2✔
157
                $this->pheanstalk = Pheanstalk::create(PHEANSTALK_SERVER);
2✔
158
            }
159

160
            $str = json_encode(array(
2✔
161
                'type' => 'webpush',
2✔
162
                'notiftype' => $type,
2✔
163
                'queued' => microtime(TRUE),
2✔
164
                'userid' => $userid,
2✔
165
                'params' => $params,
2✔
166
                'endpoint' => $endpoint,
2✔
167
                'payload' => $payload,
2✔
168
                'ttr' => Utils::PHEANSTALK_TTR
2✔
169
            ));
2✔
170

171
            $id = $this->pheanstalk->put($str);
2✔
172
        } catch (\Exception $e) {
4✔
173
            error_log("Beanstalk exception " . $e->getMessage());
4✔
174
            $this->pheanstalk = NULL;
4✔
175
        }
176
    }
177

178
    public function executeSend($userid, $notiftype, $params, $endpoint, $payload)
179
    {
180
        #error_log("Execute send type $notiftype params " . var_export($params, TRUE) . " payload " . var_export($payload, TRUE) . " endpoint $endpoint");
181
        try {
182
            error_log("notiftype " . $notiftype . " userid " . $userid);
3✔
183

184
            switch ($notiftype) {
185
                case PushNotifications::PUSH_FCM_ANDROID:
186
                case PushNotifications::PUSH_FCM_IOS:
187
                case PushNotifications::PUSH_BROWSER_PUSH:
188
                    {
2✔
189
                        # Everything is in one array as passed to this function; split it out into what we need
190
                        # for FCM.
191
                        #error_log("FCM notif " . var_export($payload, TRUE));
192
                        $data = $payload;
2✔
193

194
                        # We can only have key => string, so the chatids needs to be converted from an array to
195
                        # a string (if it isn't already).
196
                        if (is_array($data['chatids'])) {
2✔
197
                            $data['chatids'] = implode(',', $data['chatids']);
2✔
198
                        } else {
2✔
199
                            $data['chatids'] = (string)$data['chatids'];
2✔
200
                        }
2✔
201

202
                        # And anything that isn't a string needs to pretend to be one.  How dull.
203
                        foreach ($data as $key => $val) {
2✔
204
                            if (gettype($val) !== 'string') {
2✔
205
                                $data[$key] = "$val";
2✔
206
                            }
2✔
207
                        }
2✔
208

209
                        $data['notId'] = (string)floor(microtime(TRUE));
2✔
210

211
                        #error_log("Data is " . var_export($data, TRUE));
212

213
                        if ($notiftype == PushNotifications::PUSH_FCM_ANDROID) {
2✔
214
                            # Need to omit notification for reasons to do with Cordova plugin.
215
                            if ($payload['count']) {
2✔
216
                                $data['content-available'] = "1";
2✔
217
                            }
2✔
218

219
                            $message = CloudMessage::fromArray([
2✔
220
                                'token' => $endpoint,
2✔
221
                                'data' => $data
2✔
222
                            ]);
2✔
223

224
                            # Build Android config
225
                            $androidConfig = [
2✔
226
                                'ttl' => '3600s',
2✔
227
                                'priority' => 'normal'
2✔
228
                            ];
2✔
229

230
                            $category = Utils::presdef('category', $payload, NULL);
2✔
231

232
                            if ($category && isset(self::CATEGORIES[$category])) {
2✔
233
                                $categoryConfig = self::CATEGORIES[$category];
2✔
234
                                $androidConfig['priority'] = $categoryConfig['android_priority'];
2✔
235

236
                                # Add category to data so app can add action buttons
237
                                $data['category'] = $category;
2✔
238
                                # Update the message with the modified data
239
                                $message = CloudMessage::fromArray([
2✔
240
                                    'token' => $endpoint,
2✔
241
                                    'data' => $data
2✔
242
                                ]);
2✔
243
                            }
2✔
244

245
                            $message = $message->withAndroidConfig($androidConfig);
2✔
246
                        } else {
2✔
247
                            # For IOS and browser push notifications.
248
                            $ios = [
2✔
249
                                'token' => $endpoint,
2✔
250
                                'data' => $data
2✔
251
                            ];
2✔
252

253
                            if (!empty($payload['title'])) {   // Don't set notification if clearing
2✔
254
                                $iostitle = $payload['title'];
2✔
255
                                $iosbody = $payload['message'];
2✔
256

257
                                if (empty($iosbody)) {  // older iOS only shows body and doesn't show if body empty
2✔
258
                                    $iosbody = $iostitle;
2✔
259
                                    $iostitle = ' ';
2✔
260
                                }
2✔
261

262
                                $ios['notification'] = [
2✔
263
                                    'title' => $iostitle,
2✔
264
                                    'body' => $iosbody,
2✔
265
                                ];
2✔
266

267
                                if ($notiftype == PushNotifications::PUSH_BROWSER_PUSH) {
2✔
268
                                    $ios['notification']['webpush'] = [
2✔
269
                                        'fcm_options' => [
2✔
270
                                            'link' => $payload['route']
2✔
271
                                        ]
2✔
272
                                    ];
2✔
273
                                }
2✔
274
                            }
2✔
275

276
                            #error_log("ios is " . var_export($ios, TRUE));
277
                            $message = CloudMessage::fromArray($ios);
2✔
278

279
                            $aps = [
2✔
280
                                'badge' => $payload['count'],
2✔
281
                                'sound' => "default"
2✔
282
                            ];
2✔
283

284
                            # Add category, interruption-level and thread-id if category is set (iOS 15+)
285
                            $category = Utils::presdef('category', $payload, NULL);
2✔
286
                            if ($category && isset(self::CATEGORIES[$category])) {
2✔
287
                                $categoryConfig = self::CATEGORIES[$category];
2✔
288
                                # Category is required for iOS action buttons to work
289
                                $aps['category'] = $category;
2✔
290
                                $aps['interruption-level'] = $categoryConfig['ios_interruption'];
2✔
291

292
                                # Add thread-id for notification grouping
293
                                $threadId = Utils::presdef('threadId', $payload, NULL);
2✔
294
                                if ($threadId) {
1✔
295
                                    $aps['thread-id'] = $threadId;
2✔
296
                                }
2✔
297

298
                                # Add channel_id to data so the app can filter on it
299
                                $data['channel_id'] = $categoryConfig['android_channel'];
2✔
300
                                $ios['data'] = $data;
2✔
301
                                $message = CloudMessage::fromArray($ios);
2✔
302
                            }
2✔
303

304
                            # For iOS, add image via mutable-content (requires Notification Service Extension)
305
                            $image = Utils::presdef('image', $payload, NULL);
2✔
306
                            if ($image && strpos($image, 'http') === 0) {
2✔
307
                                $aps['mutable-content'] = 1;
2✔
308
                                # Image URL goes in data for the Service Extension to fetch
309
                                $data['imageUrl'] = $image;
2✔
310
                                $ios['data'] = $data;
2✔
311
                                $message = CloudMessage::fromArray($ios);
2✔
312
                            }
2✔
313

314
                            $params = [
2✔
315
                                'headers' => [
2✔
316
                                    'apns-priority' => '10',
2✔
317
                                ],
2✔
318
                                'payload' => [
2✔
319
                                    'aps' => $aps
2✔
320
                                ],
2✔
321
                            ];
2✔
322

323
                            #error_log("Send params " . var_export($params, TRUE));
324
                            #error_log("Send payload " . var_export($ios, TRUE));
325
                            $message = $message->withApnsConfig($params);
2✔
326
                        }
2✔
327

328
                        try {
2✔
329
                            if ($this->messaging) {
2✔
330
                                $this->messaging->validate($message);
2✔
331
                            }
2✔
332
                        } catch (InvalidMessage $e) {
×
333
                            # We might not want to remove the subscription.  Check the nature of the error
334
                            # and (for now) record unknown ones to check.
335
                            $error = $e->errors()['error'];
2✔
336
                            file_put_contents('/tmp/fcmerrors', date(DATE_RFC2822) . ': ' . $userid . ' - ' . $endpoint . ' - ' . var_export($error, TRUE) . "\r\n", FILE_APPEND);
2✔
337
                            error_log("FCM InvalidMessage " . var_export($error, TRUE));
2✔
338
                            $errorCode = 'CODE NOT FOUND';
2✔
339
                            if (array_key_exists('errorCode', $error['details'][0])) {
×
340
                                $errorCode = $error['details'][0]['errorCode'];
2✔
341
                            }
2✔
342
                            error_log("FCM errorCode " . $errorCode);
2✔
343

344
                            if ($errorCode == 'UNREGISTERED') {
×
345
                                # We do want to remove the subscription in this case.
346
                                throw new \Exception($errorCode);
×
347
                            }
2✔
348

349
                            foreach ($error['details'] as $detail) {
×
350
                                if (array_key_exists('fieldViolations', $detail)) {
×
351
                                    if ($detail['fieldViolations'][0]['description'] == 'Invalid registration token') {
×
352
                                        # We do want to remove the subscription in this case.
353
                                        throw new \Exception($detail['fieldViolations'][0]['description']);
×
354
                                    }
2✔
355
                                    if ($detail['fieldViolations'][0]['description'] == 'The registration token is not a valid FCM registration token') {
×
356
                                        # We do want to remove the subscription in this case.
357
                                        throw new \Exception($detail['fieldViolations'][0]['description']);
×
358
                                    }
2✔
359
                                }
2✔
360
                            }
2✔
361

362
                            $rc = TRUE; // Problem is ignored and subscription/token NOT removed: eyeball logs to check
2✔
363
                            break;
2✔
364
                        }
2✔
365

366
                        if ($this->messaging) {
2✔
367
                            $ret = $this->messaging->send($message);
2✔
368
                            error_log("FCM send " . var_export($ret, TRUE));
2✔
369
                        }
2✔
370

371
                        $rc = TRUE;
2✔
372
                        break;
2✔
373
                    }
2✔
374
                case PushNotifications::PUSH_GOOGLE:
375
                case PushNotifications::PUSH_FIREFOX:
376
                    $params = $params ? $params : [];
2✔
377
                    $webPush = new WebPush($params);
2✔
378
                    $subscription = Subscription::create([
2✔
379
                                                           "endpoint" => $endpoint,
2✔
380
                                                       ]);
2✔
381
                    #error_log("Send params " . var_export($params, TRUE) . " " . ($payload['count'] > 0) . "," . (!is_null($payload['title'])));
382
                    if (($payload && ($payload['count'] > 0) && (!is_null($payload['title'])))) {
2✔
383
                        $rc = $webPush->queueNotification($subscription, $payload['title']);
1✔
384
                    } else
385
                        $rc = TRUE;
2✔
386
                    break;
2✔
387
            }
388

389
            #error_log("Returned " . var_export($rc, TRUE) . " for $userid type $notiftype $endpoint payload " . var_export($payload, TRUE));
390
            $rc = $this->uthook($rc);
3✔
391
        } catch (\Exception $e) {
3✔
392
            $rc = ['exception' => $e->getMessage()];
3✔
393
            #error_log("push exc " . var_export($e, TRUE));
394
            #error_log("push exc " . $e->getMessage());
395
            error_log("Push exception {$rc['exception']}");
3✔
396
        }
397

398
        if ($rc !== TRUE) {
3✔
399
            error_log("Push Notification to $userid failed with " . var_export($rc, TRUE));
3✔
400
            $this->dbhm->preExec("DELETE FROM users_push_notifications WHERE userid = ? AND subscription = ?;", [$userid, $endpoint]);
3✔
401
        } else {
402
            # Don't log - lots of these.
403
            $this->dbhm->preExec("UPDATE users_push_notifications SET lastsent = NOW() WHERE userid = ? AND subscription = ?;", [$userid, $endpoint], FALSE);
2✔
404
        }
405

406
        return $rc;
3✔
407
    }
408

409
    private function notifyIndividualMessages($userid, $notifs, $modtools, $chatid = NULL) {
410
        // Send individual per-message notifications for admin users (new rich format)
411
        // If $chatid is specified, only send notifications for that specific chat
412
        $count = 0;
3✔
413
        $u = User::get($this->dbhr, $this->dbhm, $userid);
3✔
414
        $email = $u->getEmailPreferred();
3✔
415

416
        // Get unread chat messages for this user that haven't been notified yet
417
        $chatFilter = $chatid ? "AND cm.chatid = ?" : "";
3✔
418
        $params = [$userid, $userid, $userid, $userid];
3✔
419
        if ($chatid) {
3✔
420
            $params[] = $chatid;
2✔
421
        }
422

423
        // Only check lastmsgnotified, not lastmsgseen - we want to send notifications even if the app
424
        // has marked messages as "seen" (which can happen due to background polling or incorrect client logic)
425
        $chats = $this->dbhr->preQuery("
3✔
426
            SELECT cm.id, cm.chatid, cm.userid as senderid, cm.message, cm.date
427
            FROM chat_messages cm
428
            INNER JOIN chat_rooms cr ON cm.chatid = cr.id
429
            LEFT JOIN chat_roster roster ON roster.chatid = cm.chatid AND roster.userid = ?
430
            WHERE (cr.user1 = ? OR cr.user2 = ?)
431
            AND cm.userid != ?
432
            AND cm.reviewrequired = 0
433
            AND cm.reviewrejected = 0
434
            AND cm.id > COALESCE(roster.lastmsgnotified, 0)
435
            AND cm.date >= DATE_SUB(NOW(), INTERVAL 7 DAY)
436
            $chatFilter
3✔
437
            ORDER BY cm.date ASC
438
            LIMIT 20
439
        ", $params);
3✔
440

441
        $lastMsgId = 0;
3✔
442
        $chatidsNotified = [];
3✔
443

444
        foreach ($chats as $chat) {
3✔
445
            // Get chat room info including icon
446
            $r = new ChatRoom($this->dbhr, $this->dbhm, $chat['chatid']);
2✔
447
            $atts = $r->getPublic($u);
2✔
448
            $icon = Utils::presdef('icon', $atts, USERLOGO);
2✔
449

450
            // Get the other user's name (the sender)
451
            if (isset($atts['user1']) && $atts['user1']['id'] == $chat['senderid']) {
2✔
452
                $sendername = $atts['user1']['displayname'];
×
453
            } elseif (isset($atts['user2']) && $atts['user2']['id'] == $chat['senderid']) {
2✔
454
                $sendername = $atts['user2']['displayname'];
2✔
455
            } else {
456
                $sendername = 'Someone';
×
457
            }
458

459
            $message = Utils::decodeEmojis($chat['message']);
2✔
460
            $messagePreview = strlen($message) > 50 ? (substr($message, 0, 50) . "...") : $message;
2✔
461
            $message = strlen($message) > 256 ? (substr($message, 0, 256) . "...") : $message;
2✔
462

463
            error_log("Notify push chat #{$chat['chatid']} $email for $userid message {$chat['id']}: $messagePreview");
2✔
464

465
            // Get channel configuration for this category
466
            $categoryConfig = self::CATEGORIES[self::CATEGORY_CHAT_MESSAGE];
2✔
467

468
            $payload = [
2✔
469
                'badge' => count($chats),
2✔
470
                'count' => count($chats),
2✔
471
                'chatcount' => count($chats),
2✔
472
                'notifcount' => 0,
2✔
473
                'title' => $sendername,
2✔
474
                'message' => $message,
2✔
475
                'chatids' => [(string)$chat['chatid']],  // Array with single chat ID for implode compatibility
2✔
476
                'chatid' => (string)$chat['chatid'],  // Individual chat ID for this message
2✔
477
                'messageid' => (string)$chat['id'],    // Message ID for uniqueness
2✔
478
                // Use chatid for notId so new messages replace old ones for the same chat, preventing notification flooding
479
                // even when sending per-message notifications. Each chat gets one notification slot that updates with latest message.
480
                'notId' => (int)$chat['chatid'],
2✔
481
                'timestamp' => strtotime($chat['date']), // Unix timestamp for sorting
2✔
482
                'content-available' => 1,
2✔
483
                'image' => $icon,
2✔
484
                'modtools' => $modtools,
2✔
485
                'sound' => 'default',
2✔
486
                'route' => '/chats/' . $chat['chatid'],
2✔
487
                'category' => self::CATEGORY_CHAT_MESSAGE,
2✔
488
                'channel_id' => $categoryConfig['android_channel'],  // Required for Android to use correct channel
2✔
489
                'threadId' => 'chat_' . $chat['chatid']
2✔
490
            ];
2✔
491

492
            foreach ($notifs as $notif) {
2✔
493
                $this->queueSend($userid, $notif['type'], [], $notif['subscription'], $payload);
2✔
494
                $count++;
2✔
495
            }
496

497
            // Track the highest message ID and chats we've notified
498
            $lastMsgId = max($lastMsgId, $chat['id']);
2✔
499
            if (!in_array($chat['chatid'], $chatidsNotified)) {
2✔
500
                $chatidsNotified[] = $chat['chatid'];
2✔
501
            }
502
        }
503

504
        // Update lastmsgnotified for each chat we sent notifications for
505
        foreach ($chatidsNotified as $cid) {
3✔
506
            $this->dbhm->preExec("
2✔
507
                INSERT INTO chat_roster (chatid, userid, lastmsgnotified, date)
508
                VALUES (?, ?, ?, NOW())
509
                ON DUPLICATE KEY UPDATE lastmsgnotified = ?
510
            ", [$cid, $userid, $lastMsgId, $lastMsgId]);
2✔
511
        }
512

513
        return $count;
3✔
514
    }
515

516
    public function notify($userid, $modtools, $browserPush = FALSE, $chatid = NULL)
517
    {
518
        $count = 0;
608✔
519
        $u = User::get($this->dbhr, $this->dbhm, $userid);
608✔
520
        $proceedpush = TRUE; // $u->notifsOn(User::NOTIFS_PUSH);
608✔
521
        #error_log("Notify $userid, push on $proceedpush MT $modtools browserPush $browserPush chatid $chatid");
522

523
        if ($browserPush) {
608✔
524
            $notifs = $this->dbhr->preQuery("SELECT * FROM users_push_notifications WHERE userid = ? AND `type` = ?;", [
×
525
                $userid,
×
526
                PushNotifications::PUSH_BROWSER_PUSH
×
527
            ]);
×
528
        } else {
529
            $notifs = $this->dbhr->preQuery("SELECT * FROM users_push_notifications WHERE userid = ? AND apptype = ?;", [
608✔
530
                $userid,
608✔
531
                $modtools ? PushNotifications::APPTYPE_MODTOOLS : PushNotifications::APPTYPE_USER
608✔
532
            ]);
608✔
533
        }
534

535
        // Send individual per-message notifications (new rich format with action buttons)
536
        // This handles chat messages with rich formatting
537
        if (!$modtools) {
608✔
538
            $appNotifs = array_filter($notifs, function($n) {
608✔
539
                return $n['type'] === PushNotifications::PUSH_FCM_ANDROID || $n['type'] === PushNotifications::PUSH_FCM_IOS;
3✔
540
            });
608✔
541

542
            if (count($appNotifs) > 0) {
608✔
543
                $individualCount = $this->notifyIndividualMessages($userid, $appNotifs, $modtools, $chatid);
3✔
544
                if ($individualCount > 0) {
3✔
545
                    // We sent chat message notifications, so we're done
546
                    return $individualCount;
2✔
547
                }
548
                // No new chat messages to notify, but we've already checked using lastmsgnotified.
549
                // If we're in a chat-specific context (chatid provided), remove app notifications
550
                // from $notifs so the legacy path (which uses lastmsgseen via getNotificationPayload)
551
                // doesn't re-send notifications for messages that were already notified but not yet
552
                // viewed by the user.
553
                // When chatid is NOT provided, we keep FCM in the legacy path to handle non-chat
554
                // notifications like TYPE_EXHORT.
555
                if ($chatid) {
3✔
556
                    $notifs = array_filter($notifs, function($n) {
1✔
557
                        return $n['type'] !== PushNotifications::PUSH_FCM_ANDROID && $n['type'] !== PushNotifications::PUSH_FCM_IOS;
1✔
558
                    });
1✔
559
                }
560
            }
561
        }
562

563
        foreach ($notifs as $notif) {
608✔
564
            #error_log("Consider notif {$notif['id']} proceed $proceedpush type {$notif['type']}");
565
            if ($proceedpush && in_array($notif['type'],
3✔
566
                    [PushNotifications::PUSH_FIREFOX, PushNotifications::PUSH_GOOGLE, PushNotifications::PUSH_BROWSER_PUSH]) ||
3✔
567
                in_array($notif['type'],
3✔
568
                        [PushNotifications::PUSH_FCM_ANDROID, PushNotifications::PUSH_FCM_IOS])) {
3✔
569
                #error_log("Send user $userid {$notif['subscription']} type {$notif['type']} for modtools $modtools");
570
                $payload = NULL;
3✔
571
                $params = [];
3✔
572

573
                list ($total, $chatcount, $notifscount, $title, $message, $chatids, $route, $category, $threadId, $image) = $u->getNotificationPayload($modtools);
3✔
574

575
                if ($title || $modtools || $total === 0) {
3✔
576
                    $message = ($total === 0) ? "" : $message;
3✔
577
                    if (is_null($message)) $message = "";
3✔
578

579
                    # badge and/or count are used by the app, possibly when it isn't running, to set the home screen badge.
580
                    $basePayload = [
3✔
581
                        'badge' => $total,
3✔
582
                        'count' => $total,
3✔
583
                        'chatcount' => $chatcount,
3✔
584
                        'notifcount' => $notifscount,
3✔
585
                        'title' => $title,
3✔
586
                        'message' => $message,
3✔
587
                        'chatids' => $chatids,
3✔
588
                        'content-available' => $total > 0,
3✔
589
                        'image' => $image ? $image : ($modtools ? "www/images/modtools_logo.png" : "www/images/user_logo.png"),
3✔
590
                        'modtools' => $modtools,
3✔
591
                        'sound' => 'default',
3✔
592
                        'route' => $route,
3✔
593
                        'threadId' => $threadId
3✔
594
                    ];
3✔
595

596
                    switch ($notif['type']) {
3✔
597
                        case PushNotifications::PUSH_GOOGLE:
UNCOV
598
                        {
×
UNCOV
599
                            $params = [
×
UNCOV
600
                                'GCM' => GOOGLE_PUSH_KEY
×
UNCOV
601
                            ];
×
UNCOV
602
                            break;
×
UNCOV
603
                        }
×
604
                    }
605

606
                    $payload = $basePayload;
3✔
607
                    if ($category) {
3✔
608
                        $payload['category'] = $category;
2✔
609
                        $categoryConfig = self::CATEGORIES[$category];
2✔
610
                        $payload['channel_id'] = $categoryConfig['android_channel'];
2✔
611
                    }
612
                    $this->queueSend($userid, $notif['type'], $params, $notif['subscription'], $payload);
3✔
613
                    $count++;
3✔
614
                }
615
            }
616
        }
617

618
        return ($count);
608✔
619
    }
620

621
    public function notifyGroupMods($groupid)
622
    {
623
        $count = 0;
105✔
624
        $mods = $this->dbhr->preQuery("SELECT DISTINCT userid FROM memberships WHERE groupid = ? AND role IN ('Owner', 'Moderator');",
105✔
625
            [$groupid]);
105✔
626

627
        foreach ($mods as $mod) {
105✔
628
            $u = User::get($this->dbhr, $this->dbhm, $mod['userid']);
31✔
629
            $settings = $u->getGroupSettings($groupid);
31✔
630

631
            if (!array_key_exists('pushnotify', $settings) || $settings['pushnotify']) {
31✔
632
                #error_log("Notify {$mod['userid']} for $groupid notify " . Utils::presdef('pushnotify', $settings, TRUE) . " settings " . var_export($settings, TRUE));
633
                $count += $this->notify($mod['userid'], TRUE);
31✔
634
            }
635
        }
636

637
        return ($count);
105✔
638
    }
639

640
    public function pokeGroupMods($groupid, $data)
641
    {
642
        $count = 0;
31✔
643
        $mods = $this->dbhr->preQuery("SELECT userid FROM memberships WHERE groupid = ? AND role IN ('Owner', 'Moderator');",
31✔
644
            [$groupid]);
31✔
645

646
        foreach ($mods as $mod) {
31✔
647
            $this->poke($mod['userid'], $data, TRUE);
18✔
648
            $count++;
18✔
649
        }
650

651
        return ($count);
31✔
652
    }
653

654
    public function fsockopen($host, $port, &$errno, &$errstr)
655
    {
656
        $fp = @fsockopen($host, $port, $errno, $errstr);
×
657
        return ($fp);
×
658
    }
659

660
    public function fputs($fp, $str)
661
    {
662
        return (fputs($fp, $str));
×
663
    }
664

665
    public function poke($userid, $data, $modtools)
666
    {
667
        # We background this as it hits another server, so it may be slow (especially if that server is sick).
668
        try {
669
            $this->uthook();
123✔
670

671
            if (!$this->pheanstalk) {
123✔
672
                $this->pheanstalk = Pheanstalk::create(PHEANSTALK_SERVER);
123✔
673
            }
674

675
            $str = json_encode(array(
123✔
676
                'type' => 'poke',
123✔
677
                'queued' => microtime(TRUE),
123✔
678
                'groupid' => $userid,
123✔
679
                'data' => $data,
123✔
680
                'modtools' => $modtools,
123✔
681
                'ttr' => Utils::PHEANSTALK_TTR
123✔
682
            ));
123✔
683

684
            $this->pheanstalk->put($str);
123✔
685
            $ret = TRUE;
123✔
686
        } catch (\Exception $e) {
1✔
687
            error_log("poke Beanstalk exception " . $e->getMessage());
1✔
688
            $this->pheanstalk = NULL;
1✔
689
            $ret = FALSE;
1✔
690
        }
691

692
        return ($ret);
123✔
693
    }
694

695
    public function executePoke($userid, $data, $modtools)
696
    {
697
        # This kicks a user who is online at the moment with an outstanding long poll.
698
        Utils::filterResult($data);
1✔
699

700
        # We want to POST to notify.  We can speed this up using a persistent socket.
701
        $service_uri = "/publish?id=$userid";
1✔
702

703
        $topdata = array(
1✔
704
            'text' => $data,
1✔
705
            'channel' => $userid,
1✔
706
            'modtools' => $modtools,
1✔
707
            'id' => 1
1✔
708
        );
1✔
709

710
        $vars = json_encode($topdata);
1✔
711

712
        $header = "Host: " . CHAT_HOST . "\r\n";
1✔
713
        $header .= "User-Agent: Iznik Notify\r\n";
1✔
714
        $header .= "Content-Type: application/json\r\n";
1✔
715
        $header .= "Content-Length: " . strlen($vars) . "\r\n";
1✔
716
        $header .= "Connection: close\r\n\r\n";
1✔
717

718
        # Currently we don't do anything with these.  We used to send them to an nchan instance,
719
        # but that was no longer reliable and the clients don't use them.
720
        if (CHAT_HOST && FALSE) {
1✔
721
            try {
722
                #error_log("Connect to " . CHAT_HOST . " port " . CHAT_PORT);
723
                $fp = $this->fsockopen('ssl://' . CHAT_HOST, CHAT_PORT, $errno, $errstr, 2);
×
724

725
                if ($fp) {
×
726
                    if (!$this->fputs($fp, "POST $service_uri  HTTP/1.1\r\n")) {
×
727
                        # This can happen if the socket is broken.  Just close it ready for next time.
728
                        fclose($fp);
×
729
                        error_log("Failed to post");
×
730
                    } else {
731
                        fputs($fp, $header . $vars);
×
732
                        $server_response = fread($fp, 512);
×
733
                        fclose($fp);
×
734
                        #error_log("Rsp on $service_uri $server_response");
735
                    }
736
                }
737
            } catch (\Exception $e) {
×
738
                error_log("Failed to notify");
×
739
            }
740
        }
741
    }
742
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc