Skip to content

Commit ca0598c

Browse files
authored
feat: add notification when quota exceeded (#254)
* feat: add notification when quota exceeded Signed-off-by: Lukas Schaefer <[email protected]> * ratelimit notification sending and add test Signed-off-by: Lukas Schaefer <[email protected]> * use early return Signed-off-by: Lukas Schaefer <[email protected]> * chore: use ocp server get Signed-off-by: Lukas Schaefer <[email protected]> --------- Signed-off-by: Lukas Schaefer <[email protected]>
1 parent 5d46c3d commit ca0598c

File tree

5 files changed

+249
-21
lines changed

5 files changed

+249
-21
lines changed

lib/AppInfo/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace OCA\OpenAi\AppInfo;
99

1010
use OCA\OpenAi\Capabilities;
11+
use OCA\OpenAi\Notification\Notifier;
1112
use OCA\OpenAi\OldProcessing\Translation\TranslationProvider as OldTranslationProvider;
1213
use OCA\OpenAi\TaskProcessing\AudioToAudioChatProvider;
1314
use OCA\OpenAi\TaskProcessing\AudioToTextProvider;
@@ -152,6 +153,7 @@ public function register(IRegistrationContext $context): void {
152153
}
153154

154155
$context->registerCapability(Capabilities::class);
156+
$context->registerNotifierService(Notifier::class);
155157
}
156158

157159
public function boot(IBootContext $context): void {

lib/Notification/Notifier.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\OpenAi\Notification;
9+
10+
use InvalidArgumentException;
11+
use OCA\OpenAi\AppInfo\Application;
12+
use OCP\IURLGenerator;
13+
use OCP\L10N\IFactory;
14+
use OCP\Notification\IAction;
15+
use OCP\Notification\INotification;
16+
17+
use OCP\Notification\INotifier;
18+
19+
class Notifier implements INotifier {
20+
21+
public function __construct(
22+
private IFactory $factory,
23+
private IURLGenerator $url,
24+
) {
25+
}
26+
27+
public function getID(): string {
28+
return Application::APP_ID;
29+
}
30+
31+
public function getName(): string {
32+
return $this->factory->get(Application::APP_ID)->t('OpenAI Integration');
33+
}
34+
35+
public function prepare(INotification $notification, string $languageCode): INotification {
36+
if ($notification->getApp() !== Application::APP_ID) {
37+
// Not my app => throw
38+
throw new InvalidArgumentException();
39+
}
40+
if ($notification->getSubject() !== 'quota_exceeded') {
41+
// Not a valid subject => throw
42+
throw new InvalidArgumentException();
43+
}
44+
45+
$l = $this->factory->get(Application::APP_ID, $languageCode);
46+
47+
$params = $notification->getSubjectParameters();
48+
49+
$subject = $l->t('Quota exceeded');
50+
$content = '';
51+
switch ($params['type']) {
52+
case Application::QUOTA_TYPE_TEXT:
53+
$content = $l->t('Text generation quota exceeded');
54+
break;
55+
case Application::QUOTA_TYPE_IMAGE:
56+
$content = $l->t('Image generation quota exceeded');
57+
break;
58+
case Application::QUOTA_TYPE_TRANSCRIPTION:
59+
$content = $l->t('Audio transcription quota exceeded');
60+
break;
61+
case Application::QUOTA_TYPE_SPEECH:
62+
$content = $l->t('Speech generation quota exceeded');
63+
break;
64+
}
65+
66+
$link = $this->url->getWebroot() . '/settings/user/ai';
67+
$iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'));
68+
69+
$notification
70+
->setParsedSubject($subject)
71+
->setParsedMessage($content)
72+
->setLink($link)
73+
->setIcon($iconUrl);
74+
75+
$actionLabel = $params['actionLabel'] ?? $l->t('View quota');
76+
$action = $notification->createAction();
77+
$action->setLabel($actionLabel)
78+
->setParsedLabel($actionLabel)
79+
->setLink($notification->getLink(), IAction::TYPE_WEB)
80+
->setPrimary(true);
81+
82+
$notification->addParsedAction($action);
83+
84+
return $notification;
85+
}
86+
}

lib/Service/OpenAiAPIService.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
namespace OCA\OpenAi\Service;
99

10+
use DateTime;
1011
use Exception;
1112
use GuzzleHttp\Exception\ClientException;
1213
use GuzzleHttp\Exception\ServerException;
@@ -25,6 +26,7 @@
2526
use OCP\ICacheFactory;
2627
use OCP\IL10N;
2728
use OCP\Lock\LockedException;
29+
use OCP\Notification\IManager as INotificationManager;
2830
use OCP\TaskProcessing\ShapeEnumValue;
2931
use Psr\Log\LoggerInterface;
3032
use RuntimeException;
@@ -46,6 +48,7 @@ public function __construct(
4648
private ICacheFactory $cacheFactory,
4749
private QuotaUsageMapper $quotaUsageMapper,
4850
private OpenAiSettingsService $openAiSettingsService,
51+
private INotificationManager $notificationManager,
4952
IClientService $clientService,
5053
) {
5154
$this->client = $clientService->newClient();
@@ -252,8 +255,21 @@ public function isQuotaExceeded(?string $userId, int $type): bool {
252255
$this->logger->warning('Could not retrieve quota usage for user: ' . $userId . ' and quota type: ' . $type . '. Error: ' . $e->getMessage());
253256
throw new Exception('Could not retrieve quota usage.', Http::STATUS_INTERNAL_SERVER_ERROR);
254257
}
255-
256-
return $quotaUsage >= $quota;
258+
if ($quotaUsage < $quota) {
259+
return false;
260+
}
261+
$cache = $this->cacheFactory->createLocal(Application::APP_ID);
262+
if ($cache->get('quota_exceeded_' . $userId . '_' . $type) === null) {
263+
$notification = $this->notificationManager->createNotification();
264+
$notification->setApp(Application::APP_ID)
265+
->setUser($userId)
266+
->setDateTime(new DateTime())
267+
->setObject('quota_exceeded', (string)$type)
268+
->setSubject('quota_exceeded', ['type' => $type]);
269+
$this->notificationManager->notify($notification);
270+
$cache->set('quota_exceeded_' . $userId . '_' . $type, true, 3600);
271+
}
272+
return true;
257273
}
258274

259275
/**

tests/unit/Providers/OpenAiProviderTest.php

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,32 +56,33 @@ public static function setUpBeforeClass(): void {
5656
parent::setUpBeforeClass();
5757
$backend = new Dummy();
5858
$backend->createUser(self::TEST_USER1, self::TEST_USER1);
59-
\OC::$server->get(\OCP\IUserManager::class)->registerBackend($backend);
59+
\OCP\Server::get(\OCP\IUserManager::class)->registerBackend($backend);
6060
}
6161

6262
protected function setUp(): void {
6363
parent::setUp();
6464

6565
$this->loginAsUser(self::TEST_USER1);
6666

67-
$this->openAiSettingsService = \OC::$server->get(OpenAiSettingsService::class);
67+
$this->openAiSettingsService = \OCP\Server::get(OpenAiSettingsService::class);
6868

69-
$this->chunkService = \OC::$server->get(ChunkService::class);
69+
$this->chunkService = \OCP\Server::get(ChunkService::class);
7070

71-
$this->quotaUsageMapper = \OC::$server->get(QuotaUsageMapper::class);
71+
$this->quotaUsageMapper = \OCP\Server::get(QuotaUsageMapper::class);
7272

7373
// We'll hijack the client service and subsequently iClient to return a mock response from the OpenAI API
7474
$clientService = $this->createMock(IClientService::class);
7575
$this->iClient = $this->createMock(IClient::class);
7676
$clientService->method('newClient')->willReturn($this->iClient);
7777

7878
$this->openAiApiService = new OpenAiAPIService(
79-
\OC::$server->get(\Psr\Log\LoggerInterface::class),
79+
\OCP\Server::get(\Psr\Log\LoggerInterface::class),
8080
$this->createMock(\OCP\IL10N::class),
81-
\OC::$server->get(IAppConfig::class),
82-
\OC::$server->get(ICacheFactory::class),
83-
\OC::$server->get(QuotaUsageMapper::class),
81+
\OCP\Server::get(IAppConfig::class),
82+
\OCP\Server::get(ICacheFactory::class),
83+
\OCP\Server::get(QuotaUsageMapper::class),
8484
$this->openAiSettingsService,
85+
$this->createMock(\OCP\Notification\IManager::class),
8586
$clientService,
8687
);
8788

@@ -90,7 +91,7 @@ protected function setUp(): void {
9091

9192
public static function tearDownAfterClass(): void {
9293
// Delete quota usage for test user
93-
$quotaUsageMapper = \OC::$server->get(QuotaUsageMapper::class);
94+
$quotaUsageMapper = \OCP\Server::get(QuotaUsageMapper::class);
9495
try {
9596
$quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1);
9697
} catch (\OCP\Db\Exception|\RuntimeException|\Exception|\Throwable $e) {
@@ -99,15 +100,15 @@ public static function tearDownAfterClass(): void {
99100

100101
$backend = new \Test\Util\User\Dummy();
101102
$backend->deleteUser(self::TEST_USER1);
102-
\OC::$server->get(\OCP\IUserManager::class)->removeBackend($backend);
103+
\OCP\Server::get(\OCP\IUserManager::class)->removeBackend($backend);
103104

104105
parent::tearDownAfterClass();
105106
}
106107

107108
public function testFreePromptProvider(): void {
108109
$freePromptProvider = new TextToTextProvider(
109110
$this->openAiApiService,
110-
\OC::$server->get(IAppConfig::class),
111+
\OCP\Server::get(IAppConfig::class),
111112
$this->openAiSettingsService,
112113
$this->createMock(\OCP\IL10N::class),
113114
self::TEST_USER1,
@@ -170,7 +171,7 @@ public function testFreePromptProvider(): void {
170171
public function testEmojiProvider(): void {
171172
$emojiProvider = new EmojiProvider(
172173
$this->openAiApiService,
173-
\OC::$server->get(IAppConfig::class),
174+
\OCP\Server::get(IAppConfig::class),
174175
$this->openAiSettingsService,
175176
$this->createMock(\OCP\IL10N::class),
176177
self::TEST_USER1,
@@ -235,7 +236,7 @@ public function testEmojiProvider(): void {
235236
public function testHeadlineProvider(): void {
236237
$headlineProvider = new HeadlineProvider(
237238
$this->openAiApiService,
238-
\OC::$server->get(IAppConfig::class),
239+
\OCP\Server::get(IAppConfig::class),
239240
$this->openAiSettingsService,
240241
$this->createMock(\OCP\IL10N::class),
241242
self::TEST_USER1,
@@ -299,7 +300,7 @@ public function testHeadlineProvider(): void {
299300
public function testChangeToneProvider(): void {
300301
$changeToneProvider = new ChangeToneProvider(
301302
$this->openAiApiService,
302-
\OC::$server->get(IAppConfig::class),
303+
\OCP\Server::get(IAppConfig::class),
303304
$this->openAiSettingsService,
304305
$this->createMock(\OCP\IL10N::class),
305306
$this->chunkService,
@@ -366,7 +367,7 @@ public function testChangeToneProvider(): void {
366367
public function testSummaryProvider(): void {
367368
$summaryProvider = new SummaryProvider(
368369
$this->openAiApiService,
369-
\OC::$server->get(IAppConfig::class),
370+
\OCP\Server::get(IAppConfig::class),
370371
$this->openAiSettingsService,
371372
$this->createMock(\OCP\IL10N::class),
372373
$this->chunkService,
@@ -435,7 +436,7 @@ public function testSummaryProvider(): void {
435436
public function testProofreadProvider(): void {
436437
$proofreadProvider = new ProofreadProvider(
437438
$this->openAiApiService,
438-
\OC::$server->get(IAppConfig::class),
439+
\OCP\Server::get(IAppConfig::class),
439440
$this->openAiSettingsService,
440441
$this->createMock(\OCP\IL10N::class),
441442
$this->chunkService,
@@ -503,10 +504,10 @@ public function testProofreadProvider(): void {
503504
public function testTranslationProvider(): void {
504505
$translationProvider = new TranslateProvider(
505506
$this->openAiApiService,
506-
\OC::$server->get(IAppConfig::class),
507+
\OCP\Server::get(IAppConfig::class),
507508
$this->openAiSettingsService,
508509
$this->createMock(\OCP\IL10N::class),
509-
\OC::$server->get(\OCP\L10N\IFactory::class),
510+
\OCP\Server::get(\OCP\L10N\IFactory::class),
510511
$this->createMock(\OCP\ICacheFactory::class),
511512
$this->createMock(\Psr\Log\LoggerInterface::class),
512513
$this->chunkService,
@@ -576,7 +577,7 @@ public function testTextToSpeechProvider(): void {
576577
$this->openAiApiService,
577578
$this->createMock(\OCP\IL10N::class),
578579
$this->createMock(\Psr\Log\LoggerInterface::class),
579-
\OC::$server->get(IAppConfig::class),
580+
\OCP\Server::get(IAppConfig::class),
580581
self::TEST_USER1,
581582
);
582583

0 commit comments

Comments
 (0)