Gateway   A
last analyzed

Complexity

Total Complexity 31

Size/Duplication

Total Lines 216
Duplicated Lines 0 %

Test Coverage

Coverage 10.87%

Importance

Changes 2
Bugs 1 Features 1
Metric Value
wmc 31
eloc 130
c 2
b 1
f 1
dl 0
loc 216
ccs 15
cts 138
cp 0.1087
rs 9.92

10 Methods

Rating   Name   Duplication   Size   Complexity  
A send() 0 29 3
A cliConfigure() 0 18 3
A getSessionStatus() 0 25 6
A startSession() 0 7 1
A getChatIdFromPhoneNumber() 0 20 4
A getBaseUrl() 0 8 2
A getSessionStart() 0 13 3
B getSessionQr() 0 43 7
A __construct() 0 10 1
A createSettings() 0 9 1
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7
 * SPDX-License-Identifier: AGPL-3.0-or-later
8
 */
9
10
namespace OCA\TwoFactorGateway\Provider\Channel\WhatsApp;
11
12
use GuzzleHttp\Exception\ClientException;
0 ignored issues
show
Bug introduced by
The type GuzzleHttp\Exception\ClientException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
13
use GuzzleHttp\Exception\RequestException;
0 ignored issues
show
Bug introduced by
The type GuzzleHttp\Exception\RequestException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use GuzzleHttp\Exception\ServerException;
0 ignored issues
show
Bug introduced by
The type GuzzleHttp\Exception\ServerException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
15
use OCA\TwoFactorGateway\Exception\ConfigurationException;
16
use OCA\TwoFactorGateway\Exception\MessageTransmissionException;
17
use OCA\TwoFactorGateway\Provider\FieldDefinition;
18
use OCA\TwoFactorGateway\Provider\Gateway\AGateway;
19
use OCA\TwoFactorGateway\Provider\Settings;
20
use OCA\TwoFactorGateway\Vendor\BaconQrCode\Renderer\PlainTextRenderer;
0 ignored issues
show
Bug introduced by
The type OCA\TwoFactorGateway\Ven...derer\PlainTextRenderer was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
21
use OCA\TwoFactorGateway\Vendor\BaconQrCode\Writer;
0 ignored issues
show
Bug introduced by
The type OCA\TwoFactorGateway\Vendor\BaconQrCode\Writer was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
22
use OCP\Http\Client\IClient;
23
use OCP\Http\Client\IClientService;
24
use OCP\IAppConfig;
25
use OCP\IConfig;
26
use OCP\IL10N;
27
use Psr\Log\LoggerInterface;
28
use Symfony\Component\Console\Cursor;
29
use Symfony\Component\Console\Helper\QuestionHelper;
30
use Symfony\Component\Console\Input\InputInterface;
31
use Symfony\Component\Console\Output\OutputInterface;
32
use Symfony\Component\Console\Question\Question;
33
34
/**
35
 * @method string getBaseUrl()
36
 * @method static setBaseUrl(string $baseUrl)
37
 */
38
class Gateway extends AGateway {
39
	private string $instanceId;
40
	private IClient $client;
41
	private string $lazyBaseUrl = '';
42 1
	public function __construct(
43
		public IAppConfig $appConfig,
44
		private IConfig $config,
45
		private IClientService $clientService,
46
		private LoggerInterface $logger,
47
		private IL10N $l10n,
48
	) {
49 1
		parent::__construct($appConfig);
50 1
		$this->instanceId = $this->config->getSystemValue('instanceid');
51 1
		$this->client = $this->clientService->newClient();
52
	}
53
54 1
	#[\Override]
55
	public function createSettings(): Settings {
56 1
		return new Settings(
57 1
			name: 'WhatsApp',
58 1
			allowMarkdown: true,
59 1
			fields: [
60 1
				new FieldDefinition(
61 1
					field: 'base_url',
62 1
					prompt: 'Base URL to your WhatsApp API endpoint:',
63 1
				),
64 1
			],
65 1
		);
66
	}
67
68
	#[\Override]
69
	public function send(string $identifier, string $message, array $extra = []): void {
70
		$this->logger->debug("sending whatsapp message to $identifier, message: $message");
71
72
		$response = $this->getSessionStatus();
73
		if ($response !== 'CONNECTED') {
74
			throw new MessageTransmissionException('WhatsApp session is not connected. Current status: ' . $response);
75
		}
76
77
		$chatId = $this->getChatIdFromPhoneNumber($identifier);
78
79
		try {
80
			$response = $this->client->post($this->getBaseUrl() . '/client/sendMessage/' . $this->instanceId, [
0 ignored issues
show
Unused Code introduced by
The assignment to $response is dead and can be removed.
Loading history...
81
				'json' => [
82
					'chatId' => $chatId,
83
					'contentType' => 'string',
84
					'content' => $message,
85
					'options' => [],
86
				],
87
			]);
88
		} catch (\Exception $e) {
89
			$this->logger->error('Could not send WhatsApp message', [
90
				'identifier' => $identifier,
91
				'exception' => $e,
92
			]);
93
			throw new MessageTransmissionException();
94
		}
95
96
		$this->logger->debug("whatsapp message to chat $identifier sent");
97
	}
98
99
	#[\Override]
100
	public function cliConfigure(InputInterface $input, OutputInterface $output): int {
101
		$helper = new QuestionHelper();
102
		$baseUrlQuestion = new Question($this->getSettings()->fields[0]->prompt . ' ');
103
		$this->lazyBaseUrl = $helper->ask($input, $output, $baseUrlQuestion);
104
		$this->lazyBaseUrl = rtrim($this->lazyBaseUrl, '/');
105
106
		try {
107
			if ($this->getSessionQr($output) === 1) {
108
				return 1;
109
			}
110
		} catch (\Exception $e) {
111
			$output->writeln('<error>' . $e->getMessage() . '</error>');
112
		}
113
114
		$this->setBaseUrl($this->lazyBaseUrl);
0 ignored issues
show
Bug introduced by
It seems like $this->lazyBaseUrl can also be of type OCA\TwoFactorGateway\Pro...hannel\WhatsApp\Gateway; however, parameter $baseUrl of OCA\TwoFactorGateway\Pro...p\Gateway::setBaseUrl() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

114
		$this->setBaseUrl(/** @scrutinizer ignore-type */ $this->lazyBaseUrl);
Loading history...
115
116
		return 0;
117
	}
118
119
	private function getChatIdFromPhoneNumber(string $phoneNumber): string {
120
		try {
121
			$response = $this->client->post($this->getBaseUrl() . '/client/getNumberId/' . $this->instanceId, [
122
				'json' => [
123
					'number' => preg_replace('/\D/', '', $phoneNumber),
124
				],
125
			]);
126
			$json = $response->getBody();
127
			$data = json_decode($json, true);
0 ignored issues
show
Bug introduced by
It seems like $json can also be of type null and resource; however, parameter $json of json_decode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

127
			$data = json_decode(/** @scrutinizer ignore-type */ $json, true);
Loading history...
128
			if (empty($data['result'])) {
129
				throw new MessageTransmissionException('The phone number is not registered on WhatsApp.');
130
			}
131
			return $data['result']['_serialized'];
132
		} catch (ServerException $e) {
133
			$content = $e->getResponse()?->getBody()?->getContents();
134
			if ($content === null) {
135
				throw new MessageTransmissionException('Unknown error');
136
			}
137
			$errorMessage = json_decode($content, true)['error'] ?? 'Unknown error';
138
			throw new MessageTransmissionException($errorMessage);
139
		}
140
	}
141
142
	/**
143
	 * @throws ConfigurationException
144
	 */
145
	public function getBaseUrl(): string {
146
		if ($this->lazyBaseUrl !== '') {
147
			return $this->lazyBaseUrl;
148
		}
149
150
		/** @var string */
151
		$this->lazyBaseUrl = $this->__call(__FUNCTION__, []);
0 ignored issues
show
Bug introduced by
The property lazyBaseUrl does not exist on string.
Loading history...
152
		return $this->lazyBaseUrl;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->lazyBaseUrl returns the type OCA\TwoFactorGateway\Pro...hannel\WhatsApp\Gateway which is incompatible with the type-hinted return string.
Loading history...
153
	}
154
155
	private function getSessionQr(OutputInterface $output): int {
156
		$renderer = new PlainTextRenderer(margin: 3);
157
		$writer = new Writer($renderer);
158
		$cursor = new Cursor($output);
159
160
		if ($this->startSession() === 2) {
161
			$output->writeln('<info>Session already connected, no need to scan QR code.</info>');
162
			return 0;
163
		}
164
165
		$last = null;
166
		while (true) {
167
			$response = $this->client->get($this->getBaseUrl() . '/session/qr/' . $this->instanceId);
168
			$json = $response->getBody();
169
			$data = json_decode($json, true);
0 ignored issues
show
Bug introduced by
It seems like $json can also be of type null and resource; however, parameter $json of json_decode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

169
			$data = json_decode(/** @scrutinizer ignore-type */ $json, true);
Loading history...
170
			if ($data['success'] === false) {
171
				if ($data['message'] === 'qr code not ready or already scanned') {
172
					$output->writeln('<error>Session not connected yet, waiting...</error>');
173
					sleep(2);
174
					continue;
175
				}
176
				$output->writeln('<error>' . $data['message'] . '</error>');
177
				return 1;
178
			}
179
			$qrCodeContent = $data['qr'];
180
181
			if ($qrCodeContent !== $last) {
182
				$last = $qrCodeContent;
183
				$cursor->clearScreen();
184
				$cursor->moveToPosition(1, 1);
185
186
				$output->write($writer->writeString($qrCodeContent));
187
				$output->writeln('');
188
				$output->writeln('<info>Please confirm on your phone.</info>');
189
				$output->writeln('Press Ctrl+C to exit');
190
			}
191
192
			sleep(1);
193
			if ($this->startSession() === 2) {
194
				return 0;
195
			}
196
		}
197
		return 0;
198
	}
199
200
	private function getSessionStatus(): string {
201
		$endpoint = $this->getBaseUrl() . '/session/status/' . $this->instanceId;
202
203
		try {
204
			$response = $this->client->get($endpoint);
205
			$body = (string)$response->getBody();
206
			$responseData = json_decode($body, true);
207
208
			if (!is_array($responseData)) {
209
				return 'not_connected';
210
			}
211
212
			if (($responseData['success'] ?? null) === false) {
213
				$msg = $responseData['message'] ?? '';
214
				return in_array($msg, ['session_not_found', 'session_not_connected'], true)
215
					? $msg
216
					: 'not_connected';
217
			}
218
219
			return (string)($responseData['state'] ?? 'not_connected');
220
		} catch (ClientException $e) {
221
			return 'not_connected';
222
		} catch (RequestException $e) {
223
			$this->logger->info('Could not connect to ' . $endpoint, ['exception' => $e]);
224
			throw new \Exception('Could not connect to the WhatsApp API. Please check the URL.', 1);
225
		}
226
	}
227
228
	/**
229
	 * @return integer 0 = not connected, 1 = started, 2 = connected
230
	 */
231
	private function startSession(): int {
232
		$status = $this->getSessionStatus();
233
		return match ($status) {
234
			'CONNECTED' => 2,
235
			'session_not_connected' => 0,
236
			'session_not_found' => $this->getSessionStart(),
237
			default => 0,
238
		};
239
	}
240
241
	private function getSessionStart(): int {
242
		$endpoint = $this->getBaseUrl() . '/session/start/' . $this->instanceId;
243
		try {
244
			$this->client->get($endpoint);
245
		} catch (ClientException $e) {
246
			return 1;
247
		} catch (RequestException $e) {
248
			$this->logger->info('Could not connect to ' . $endpoint, [
249
				'exception' => $e,
250
			]);
251
			throw new \Exception('Could not connect to the WhatsApp API. Please check the URL.', 1);
252
		}
253
		return 0;
254
	}
255
}
256