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

Freegle / iznik-server / #2469

30 Nov 2025 09:48AM UTC coverage: 90.08% (-0.3%) from 90.386%
#2469

push

php-coveralls

edwh
Skip testMessageIsochrones - requires external Mapbox API

26478 of 29394 relevant lines covered (90.08%)

31.7 hits per line

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

76.75
/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;
651✔
90
        $this->dbhm = $dbhm;
651✔
91
        $this->log = new Log($dbhr, $dbhm);
651✔
92

93
        if (file_exists('/etc/firebase.json')) {
651✔
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)) {
13✔
122
            $modtools = Session::modtools();
2✔
123
        }
124

125
        $rc = NULL;
13✔
126

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

134
        return ($rc);
13✔
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);
123✔
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();
1✔
155

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

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

171
            $id = $this->pheanstalk->put($str);
1✔
172
        } catch (\Exception $e) {
1✔
173
            error_log("Beanstalk exception " . $e->getMessage());
1✔
174
            $this->pheanstalk = NULL;
1✔
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
                            $hasChannelId = !empty($data['channel_id']);
2✔
232

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

237
                                # NEW notifications (with channel_id): Data-only, app creates notification
238
                                if ($hasChannelId) {
1✔
239
                                    # Add category to data so app can add action buttons
240
                                    $data['category'] = $category;
2✔
241
                                    # Update the message with the modified data
242
                                    $message = CloudMessage::fromArray([
2✔
243
                                        'token' => $endpoint,
2✔
244
                                        'data' => $data
2✔
245
                                    ]);
2✔
246
                                }
2✔
247
                                # LEGACY notifications (no channel_id): Use androidConfig notification for auto-display
248
                                else {
2✔
249
                                    $androidConfig['notification'] = [
2✔
250
                                        'channel_id' => $categoryConfig['android_channel']
2✔
251
                                    ];
2✔
252

253
                                    # Add thread tag for notification grouping
254
                                    $threadId = Utils::presdef('threadId', $payload, NULL);
2✔
255
                                    if ($threadId) {
1✔
256
                                        $androidConfig['notification']['tag'] = $threadId;
2✔
257
                                    }
2✔
258

259
                                    # Add image for rich notifications
260
                                    $image = Utils::presdef('image', $payload, NULL);
2✔
261
                                    if ($image && strpos($image, 'http') === 0) {
1✔
262
                                        $androidConfig['notification']['image'] = $image;
2✔
263
                                    }
2✔
264
                                }
2✔
265
                            }
2✔
266

267
                            $message = $message->withAndroidConfig($androidConfig);
2✔
268
                        } else {
2✔
269
                            # For IOS and browser push notifications.
270
                            $ios = [
2✔
271
                                'token' => $endpoint,
2✔
272
                                'data' => $data
2✔
273
                            ];
2✔
274

275
                            if (!empty($payload['title'])) {   // Don't set notification if clearing
2✔
276
                                $iostitle = $payload['title'];
2✔
277
                                $iosbody = $payload['message'];
2✔
278

279
                                if (empty($iosbody)) {  // older iOS only shows body and doesn't show if body empty
2✔
280
                                    $iosbody = $iostitle;
2✔
281
                                    $iostitle = ' ';
2✔
282
                                }
2✔
283

284
                                $ios['notification'] = [
2✔
285
                                    'title' => $iostitle,
2✔
286
                                    'body' => $iosbody,
2✔
287
                                ];
2✔
288

289
                                if ($notiftype == PushNotifications::PUSH_BROWSER_PUSH) {
2✔
290
                                    $ios['notification']['webpush'] = [
2✔
291
                                        'fcm_options' => [
2✔
292
                                            'link' => $payload['route']
2✔
293
                                        ]
2✔
294
                                    ];
2✔
295
                                }
2✔
296
                            }
2✔
297

298
                            #error_log("ios is " . var_export($ios, TRUE));
299
                            $message = CloudMessage::fromArray($ios);
2✔
300

301
                            $aps = [
2✔
302
                                'badge' => $payload['count'],
2✔
303
                                'sound' => "default"
2✔
304
                            ];
2✔
305

306
                            # Add category, interruption-level and thread-id if category is set (iOS 15+)
307
                            $category = Utils::presdef('category', $payload, NULL);
2✔
308
                            if ($category && isset(self::CATEGORIES[$category])) {
2✔
309
                                $categoryConfig = self::CATEGORIES[$category];
2✔
310
                                # Category is required for iOS action buttons to work
311
                                $aps['category'] = $category;
2✔
312
                                $aps['interruption-level'] = $categoryConfig['ios_interruption'];
2✔
313

314
                                # Add thread-id for notification grouping
315
                                $threadId = Utils::presdef('threadId', $payload, NULL);
2✔
316
                                if ($threadId) {
1✔
317
                                    $aps['thread-id'] = $threadId;
2✔
318
                                }
2✔
319

320
                                # Add channel_id to data so the app can filter on it
321
                                $data['channel_id'] = $categoryConfig['android_channel'];
2✔
322
                                $ios['data'] = $data;
2✔
323
                                $message = CloudMessage::fromArray($ios);
2✔
324
                            }
2✔
325

326
                            # For iOS, add image via mutable-content (requires Notification Service Extension)
327
                            $image = Utils::presdef('image', $payload, NULL);
2✔
328
                            if ($image && strpos($image, 'http') === 0) {
2✔
329
                                $aps['mutable-content'] = 1;
2✔
330
                                # Image URL goes in data for the Service Extension to fetch
331
                                $data['imageUrl'] = $image;
2✔
332
                                $ios['data'] = $data;
2✔
333
                                $message = CloudMessage::fromArray($ios);
2✔
334
                            }
2✔
335

336
                            $params = [
2✔
337
                                'headers' => [
2✔
338
                                    'apns-priority' => '10',
2✔
339
                                ],
2✔
340
                                'payload' => [
2✔
341
                                    'aps' => $aps
2✔
342
                                ],
2✔
343
                            ];
2✔
344

345
                            #error_log("Send params " . var_export($params, TRUE));
346
                            #error_log("Send payload " . var_export($ios, TRUE));
347
                            $message = $message->withApnsConfig($params);
2✔
348
                        }
2✔
349

350
                        try {
2✔
351
                            if ($this->messaging) {
2✔
352
                                $this->messaging->validate($message);
2✔
353
                            }
2✔
354
                        } catch (InvalidMessage $e) {
×
355
                            # We might not want to remove the subscription.  Check the nature of the error
356
                            # and (for now) record unknown ones to check.
357
                            $error = $e->errors()['error'];
2✔
358
                            file_put_contents('/tmp/fcmerrors', date(DATE_RFC2822) . ': ' . $userid . ' - ' . $endpoint . ' - ' . var_export($error, TRUE) . "\r\n", FILE_APPEND);
2✔
359
                            error_log("FCM InvalidMessage " . var_export($error, TRUE));
2✔
360
                            $errorCode = 'CODE NOT FOUND';
2✔
361
                            if (array_key_exists('errorCode', $error['details'][0])) {
×
362
                                $errorCode = $error['details'][0]['errorCode'];
2✔
363
                            }
2✔
364
                            error_log("FCM errorCode " . $errorCode);
2✔
365

366
                            if ($errorCode == 'UNREGISTERED') {
×
367
                                # We do want to remove the subscription in this case.
368
                                throw new \Exception($errorCode);
×
369
                            }
2✔
370

371
                            foreach ($error['details'] as $detail) {
×
372
                                if (array_key_exists('fieldViolations', $detail)) {
×
373
                                    if ($detail['fieldViolations'][0]['description'] == 'Invalid registration token') {
×
374
                                        # We do want to remove the subscription in this case.
375
                                        throw new \Exception($detail['fieldViolations'][0]['description']);
×
376
                                    }
2✔
377
                                    if ($detail['fieldViolations'][0]['description'] == 'The registration token is not a valid FCM registration token') {
×
378
                                        # We do want to remove the subscription in this case.
379
                                        throw new \Exception($detail['fieldViolations'][0]['description']);
×
380
                                    }
2✔
381
                                }
2✔
382
                            }
2✔
383

384
                            $rc = TRUE; // Problem is ignored and subscription/token NOT removed: eyeball logs to check
2✔
385
                            break;
2✔
386
                        }
2✔
387

388
                        if ($this->messaging) {
2✔
389
                            $ret = $this->messaging->send($message);
2✔
390
                            error_log("FCM send " . var_export($ret, TRUE));
2✔
391
                        }
2✔
392

393
                        $rc = TRUE;
2✔
394
                        break;
2✔
395
                    }
2✔
396
                case PushNotifications::PUSH_GOOGLE:
397
                case PushNotifications::PUSH_FIREFOX:
398
                    $params = $params ? $params : [];
2✔
399
                    $webPush = new WebPush($params);
2✔
400
                    $subscription = Subscription::create([
2✔
401
                                                           "endpoint" => $endpoint,
2✔
402
                                                       ]);
2✔
403
                    #error_log("Send params " . var_export($params, TRUE) . " " . ($payload['count'] > 0) . "," . (!is_null($payload['title'])));
404
                    if (($payload && ($payload['count'] > 0) && (!is_null($payload['title'])))) {
2✔
405
                        $rc = $webPush->queueNotification($subscription, $payload['title']);
1✔
406
                    } else
407
                        $rc = TRUE;
2✔
408
                    break;
2✔
409
            }
410

411
            #error_log("Returned " . var_export($rc, TRUE) . " for $userid type $notiftype $endpoint payload " . var_export($payload, TRUE));
412
            $rc = $this->uthook($rc);
3✔
413
        } catch (\Exception $e) {
3✔
414
            $rc = ['exception' => $e->getMessage()];
3✔
415
            #error_log("push exc " . var_export($e, TRUE));
416
            #error_log("push exc " . $e->getMessage());
417
            error_log("Push exception {$rc['exception']}");
3✔
418
        }
419

420
        if ($rc !== TRUE) {
3✔
421
            error_log("Push Notification to $userid failed with " . var_export($rc, TRUE));
3✔
422
            $this->dbhm->preExec("DELETE FROM users_push_notifications WHERE userid = ? AND subscription = ?;", [$userid, $endpoint]);
3✔
423
        } else {
424
            # Don't log - lots of these.
425
            $this->dbhm->preExec("UPDATE users_push_notifications SET lastsent = NOW() WHERE userid = ? AND subscription = ?;", [$userid, $endpoint], FALSE);
2✔
426
        }
427

428
        return $rc;
3✔
429
    }
430

431
    private function notifyIndividualMessages($userid, $notifs, $modtools, $chatid = NULL) {
432
        // Send individual per-message notifications for admin users (new rich format)
433
        // If $chatid is specified, only send notifications for that specific chat
434
        $count = 0;
2✔
435
        $u = User::get($this->dbhr, $this->dbhm, $userid);
2✔
436
        $email = $u->getEmailPreferred();
2✔
437

438
        // Get unread chat messages for this user that haven't been notified yet
439
        $chatFilter = $chatid ? "AND cm.chatid = ?" : "";
2✔
440
        $params = [$userid, $userid, $userid, $userid];
2✔
441
        if ($chatid) {
2✔
442
            $params[] = $chatid;
1✔
443
        }
444

445
        // Only check lastmsgnotified, not lastmsgseen - we want to send notifications even if the app
446
        // has marked messages as "seen" (which can happen due to background polling or incorrect client logic)
447
        $chats = $this->dbhr->preQuery("
2✔
448
            SELECT cm.id, cm.chatid, cm.userid as senderid, cm.message, cm.date
449
            FROM chat_messages cm
450
            INNER JOIN chat_rooms cr ON cm.chatid = cr.id
451
            LEFT JOIN chat_roster roster ON roster.chatid = cm.chatid AND roster.userid = ?
452
            WHERE (cr.user1 = ? OR cr.user2 = ?)
453
            AND cm.userid != ?
454
            AND cm.reviewrequired = 0
455
            AND cm.reviewrejected = 0
456
            AND cm.id > COALESCE(roster.lastmsgnotified, 0)
457
            $chatFilter
2✔
458
            ORDER BY cm.date ASC
459
            LIMIT 20
460
        ", $params);
2✔
461

462
        $lastMsgId = 0;
×
463
        $chatidsNotified = [];
×
464

465
        foreach ($chats as $chat) {
×
466
            // Get chat room info including icon
467
            $r = new ChatRoom($this->dbhr, $this->dbhm, $chat['chatid']);
×
468
            $atts = $r->getPublic($u);
×
469
            $icon = Utils::presdef('icon', $atts, USERLOGO);
×
470

471
            // Get the other user's name (the sender)
472
            if (isset($atts['user1']) && $atts['user1']['id'] == $chat['senderid']) {
×
473
                $sendername = $atts['user1']['displayname'];
×
474
            } elseif (isset($atts['user2']) && $atts['user2']['id'] == $chat['senderid']) {
×
475
                $sendername = $atts['user2']['displayname'];
×
476
            } else {
477
                $sendername = 'Someone';
×
478
            }
479

480
            $message = Utils::decodeEmojis($chat['message']);
×
481
            $messagePreview = strlen($message) > 50 ? (substr($message, 0, 50) . "...") : $message;
×
482
            $message = strlen($message) > 256 ? (substr($message, 0, 256) . "...") : $message;
×
483

484
            error_log("Notify push chat #{$chat['chatid']} $email for $userid message {$chat['id']}: $messagePreview");
×
485

486
            // Get channel configuration for this category
487
            $categoryConfig = self::CATEGORIES[self::CATEGORY_CHAT_MESSAGE];
×
488

489
            $payload = [
×
490
                'badge' => count($chats),
×
491
                'count' => count($chats),
×
492
                'chatcount' => count($chats),
×
493
                'notifcount' => 0,
×
494
                'title' => $sendername,
×
495
                'message' => $message,
×
496
                'chatids' => [(string)$chat['chatid']],  // Array with single chat ID for implode compatibility
×
497
                'chatid' => (string)$chat['chatid'],  // Individual chat ID for this message
×
498
                'messageid' => (string)$chat['id'],    // Message ID for uniqueness
×
499
                // Use chatid for notId so new messages replace old ones for the same chat, preventing notification flooding
500
                // even when sending per-message notifications. Each chat gets one notification slot that updates with latest message.
501
                'notId' => (int)$chat['chatid'],
×
502
                'timestamp' => strtotime($chat['date']), // Unix timestamp for sorting
×
503
                'content-available' => 1,
×
504
                'image' => $icon,
×
505
                'modtools' => $modtools,
×
506
                'sound' => 'default',
×
507
                'route' => '/chats/' . $chat['chatid'],
×
508
                'category' => self::CATEGORY_CHAT_MESSAGE,
×
509
                'channel_id' => $categoryConfig['android_channel'],  // Required for Android to use correct channel
×
510
                'threadId' => 'chat_' . $chat['chatid']
×
511
            ];
×
512

513
            foreach ($notifs as $notif) {
×
514
                $this->queueSend($userid, $notif['type'], [], $notif['subscription'], $payload);
×
515
                $count++;
×
516
            }
517

518
            // Track the highest message ID and chats we've notified
519
            $lastMsgId = max($lastMsgId, $chat['id']);
×
520
            if (!in_array($chat['chatid'], $chatidsNotified)) {
×
521
                $chatidsNotified[] = $chat['chatid'];
×
522
            }
523
        }
524

525
        // Update lastmsgnotified for each chat we sent notifications for
526
        foreach ($chatidsNotified as $cid) {
×
527
            $this->dbhm->preExec("
×
528
                INSERT INTO chat_roster (chatid, userid, lastmsgnotified, date)
529
                VALUES (?, ?, ?, NOW())
530
                ON DUPLICATE KEY UPDATE lastmsgnotified = ?
531
            ", [$cid, $userid, $lastMsgId, $lastMsgId]);
×
532
        }
533

534
        return $count;
×
535
    }
536

537
    public function notify($userid, $modtools, $browserPush = FALSE, $chatid = NULL)
538
    {
539
        $count = 0;
607✔
540
        $u = User::get($this->dbhr, $this->dbhm, $userid);
607✔
541
        $proceedpush = TRUE; // $u->notifsOn(User::NOTIFS_PUSH);
607✔
542
        $proceedapp = $u->notifsOn(User::NOTIFS_APP);
607✔
543
        #error_log("Notify $userid, push on $proceedpush app on $proceedapp MT $modtools browserPush $browserPush chatid $chatid");
544

545
        if ($browserPush) {
607✔
546
            $notifs = $this->dbhr->preQuery("SELECT * FROM users_push_notifications WHERE userid = ? AND `type` = ?;", [
×
547
                $userid,
×
548
                PushNotifications::PUSH_BROWSER_PUSH
×
549
            ]);
×
550
        } else {
551
            $notifs = $this->dbhr->preQuery("SELECT * FROM users_push_notifications WHERE userid = ? AND apptype = ?;", [
607✔
552
                $userid,
607✔
553
                $modtools ? PushNotifications::APPTYPE_MODTOOLS : PushNotifications::APPTYPE_USER
607✔
554
            ]);
607✔
555
        }
556

557
        // Send individual per-message notifications (new rich format with action buttons)
558
        // This handles chat messages with rich formatting
559
        if (!$modtools && $proceedapp) {
607✔
560
            $appNotifs = array_filter($notifs, function($n) {
607✔
561
                return $n['type'] === PushNotifications::PUSH_FCM_ANDROID || $n['type'] === PushNotifications::PUSH_FCM_IOS;
2✔
562
            });
607✔
563

564
            if (count($appNotifs) > 0) {
607✔
565
                $individualCount = $this->notifyIndividualMessages($userid, $appNotifs, $modtools, $chatid);
2✔
566
                if ($individualCount > 0) {
×
567
                    // We sent chat message notifications, so we're done
568
                    return $individualCount;
×
569
                }
570
                // If no chat messages were found, fall through to legacy notification path
571
                // to handle users_notifications (TYPE_EXHORT, etc.)
572
            }
573
        }
574

575
        foreach ($notifs as $notif) {
607✔
576
            #error_log("Consider notif {$notif['id']} proceed $proceedpush type {$notif['type']}");
577
            if ($proceedpush && in_array($notif['type'],
1✔
578
                    [PushNotifications::PUSH_FIREFOX, PushNotifications::PUSH_GOOGLE, PushNotifications::PUSH_BROWSER_PUSH]) ||
1✔
579
                ($proceedapp && in_array($notif['type'],
1✔
580
                        [PushNotifications::PUSH_FCM_ANDROID, PushNotifications::PUSH_FCM_IOS]))) {
1✔
581
                #error_log("Send user $userid {$notif['subscription']} type {$notif['type']} for modtools $modtools");
582
                $payload = NULL;
1✔
583
                $params = [];
1✔
584

585
                list ($total, $chatcount, $notifscount, $title, $message, $chatids, $route, $category, $threadId, $image) = $u->getNotificationPayload($modtools);
1✔
586

587
                if ($title || $modtools || $total === 0) {
1✔
588
                    $message = ($total === 0) ? "" : $message;
1✔
589
                    if (is_null($message)) $message = "";
1✔
590

591
                    # badge and/or count are used by the app, possibly when it isn't running, to set the home screen badge.
592
                    $basePayload = [
1✔
593
                        'badge' => $total,
1✔
594
                        'count' => $total,
1✔
595
                        'chatcount' => $chatcount,
1✔
596
                        'notifcount' => $notifscount,
1✔
597
                        'title' => $title,
1✔
598
                        'message' => $message,
1✔
599
                        'chatids' => $chatids,
1✔
600
                        'content-available' => $total > 0,
1✔
601
                        'image' => $image ? $image : ($modtools ? "www/images/modtools_logo.png" : "www/images/user_logo.png"),
1✔
602
                        'modtools' => $modtools,
1✔
603
                        'sound' => 'default',
1✔
604
                        'route' => $route,
1✔
605
                        'threadId' => $threadId
1✔
606
                    ];
1✔
607

608
                    switch ($notif['type']) {
1✔
609
                        case PushNotifications::PUSH_GOOGLE:
610
                        {
×
611
                            $params = [
×
612
                                'GCM' => GOOGLE_PUSH_KEY
×
613
                            ];
×
614
                            break;
×
615
                        }
×
616
                    }
617

618
                    # For mobile apps (Android/iOS), send TWO notifications:
619
                    # 1. Legacy notification (no channel_id) - for old app versions
620
                    # 2. New notification (with channel_id) - for new app versions
621
                    # Each app version filters to only process one type, so no duplicates shown.
622
                    $isAppNotification = in_array($notif['type'], [PushNotifications::PUSH_FCM_ANDROID, PushNotifications::PUSH_FCM_IOS]);
1✔
623

624
                    if ($isAppNotification && $category) {
1✔
625
                        # Send legacy notification first (no category/channel_id)
626
                        $legacyPayload = $basePayload;
×
627
                        $this->queueSend($userid, $notif['type'], $params, $notif['subscription'], $legacyPayload);
×
628
                        $count++;
×
629

630
                        # Send new notification (with category/channel_id)
631
                        $newPayload = $basePayload;
×
632
                        $newPayload['category'] = $category;
×
633
                        $this->queueSend($userid, $notif['type'], $params, $notif['subscription'], $newPayload);
×
634
                        $count++;
×
635
                    } else {
636
                        # Browser push or no category - send single notification
637
                        $payload = $basePayload;
1✔
638
                        if ($category) {
1✔
639
                            $payload['category'] = $category;
×
640
                        }
641
                        $this->queueSend($userid, $notif['type'], $params, $notif['subscription'], $payload);
1✔
642
                        $count++;
1✔
643
                    }
644
                }
645
            }
646
        }
647

648
        return ($count);
607✔
649
    }
650

651
    public function notifyGroupMods($groupid)
652
    {
653
        $count = 0;
105✔
654
        $mods = $this->dbhr->preQuery("SELECT DISTINCT userid FROM memberships WHERE groupid = ? AND role IN ('Owner', 'Moderator');",
105✔
655
            [$groupid]);
105✔
656

657
        foreach ($mods as $mod) {
105✔
658
            $u = User::get($this->dbhr, $this->dbhm, $mod['userid']);
31✔
659
            $settings = $u->getGroupSettings($groupid);
31✔
660

661
            if (!array_key_exists('pushnotify', $settings) || $settings['pushnotify']) {
31✔
662
                #error_log("Notify {$mod['userid']} for $groupid notify " . Utils::presdef('pushnotify', $settings, TRUE) . " settings " . var_export($settings, TRUE));
663
                $count += $this->notify($mod['userid'], TRUE);
31✔
664
            }
665
        }
666

667
        return ($count);
105✔
668
    }
669

670
    public function pokeGroupMods($groupid, $data)
671
    {
672
        $count = 0;
31✔
673
        $mods = $this->dbhr->preQuery("SELECT userid FROM memberships WHERE groupid = ? AND role IN ('Owner', 'Moderator');",
31✔
674
            [$groupid]);
31✔
675

676
        foreach ($mods as $mod) {
31✔
677
            $this->poke($mod['userid'], $data, TRUE);
18✔
678
            $count++;
18✔
679
        }
680

681
        return ($count);
31✔
682
    }
683

684
    public function fsockopen($host, $port, &$errno, &$errstr)
685
    {
686
        $fp = @fsockopen($host, $port, $errno, $errstr);
×
687
        return ($fp);
×
688
    }
689

690
    public function fputs($fp, $str)
691
    {
692
        return (fputs($fp, $str));
×
693
    }
694

695
    public function poke($userid, $data, $modtools)
696
    {
697
        # We background this as it hits another server, so it may be slow (especially if that server is sick).
698
        try {
699
            $this->uthook();
122✔
700

701
            if (!$this->pheanstalk) {
122✔
702
                $this->pheanstalk = Pheanstalk::create(PHEANSTALK_SERVER);
122✔
703
            }
704

705
            $str = json_encode(array(
122✔
706
                'type' => 'poke',
122✔
707
                'queued' => microtime(TRUE),
122✔
708
                'groupid' => $userid,
122✔
709
                'data' => $data,
122✔
710
                'modtools' => $modtools,
122✔
711
                'ttr' => Utils::PHEANSTALK_TTR
122✔
712
            ));
122✔
713

714
            $this->pheanstalk->put($str);
122✔
715
            $ret = TRUE;
122✔
716
        } catch (\Exception $e) {
1✔
717
            error_log("poke Beanstalk exception " . $e->getMessage());
1✔
718
            $this->pheanstalk = NULL;
1✔
719
            $ret = FALSE;
1✔
720
        }
721

722
        return ($ret);
122✔
723
    }
724

725
    public function executePoke($userid, $data, $modtools)
726
    {
727
        # This kicks a user who is online at the moment with an outstanding long poll.
728
        Utils::filterResult($data);
1✔
729

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

733
        $topdata = array(
1✔
734
            'text' => $data,
1✔
735
            'channel' => $userid,
1✔
736
            'modtools' => $modtools,
1✔
737
            'id' => 1
1✔
738
        );
1✔
739

740
        $vars = json_encode($topdata);
1✔
741

742
        $header = "Host: " . CHAT_HOST . "\r\n";
1✔
743
        $header .= "User-Agent: Iznik Notify\r\n";
1✔
744
        $header .= "Content-Type: application/json\r\n";
1✔
745
        $header .= "Content-Length: " . strlen($vars) . "\r\n";
1✔
746
        $header .= "Connection: close\r\n\r\n";
1✔
747

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

755
                if ($fp) {
×
756
                    if (!$this->fputs($fp, "POST $service_uri  HTTP/1.1\r\n")) {
×
757
                        # This can happen if the socket is broken.  Just close it ready for next time.
758
                        fclose($fp);
×
759
                        error_log("Failed to post");
×
760
                    } else {
761
                        fputs($fp, $header . $vars);
×
762
                        $server_response = fread($fp, 512);
×
763
                        fclose($fp);
×
764
                        #error_log("Rsp on $service_uri $server_response");
765
                    }
766
                }
767
            } catch (\Exception $e) {
×
768
                error_log("Failed to notify");
×
769
            }
770
        }
771
    }
772
}
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