Passed
Pull Request — master (#640)
by Vitor
03:36
created

Gateway::getSessionStatus()   A

Complexity

Conditions 6
Paths 21

Size

Total Lines 25
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 18
dl 0
loc 25
rs 9.0444
c 1
b 0
f 1
cc 6
nc 21
nop 0
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 BaconQrCode\Renderer\PlainTextRenderer;
13
use BaconQrCode\Writer;
14
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...
15
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...
16
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...
17
use OCA\TwoFactorGateway\Exception\ConfigurationException;
18
use OCA\TwoFactorGateway\Exception\MessageTransmissionException;
19
use OCA\TwoFactorGateway\Provider\Gateway\AGateway;
20
use OCP\Http\Client\IClient;
21
use OCP\Http\Client\IClientService;
22
use OCP\IAppConfig;
23
use OCP\IConfig;
24
use OCP\IL10N;
25
use OCP\IUser;
26
use Psr\Log\LoggerInterface;
27
use Symfony\Component\Console\Cursor;
28
use Symfony\Component\Console\Helper\QuestionHelper;
29
use Symfony\Component\Console\Input\InputInterface;
30
use Symfony\Component\Console\Output\OutputInterface;
31
use Symfony\Component\Console\Question\Question;
32
33
/**
34
 * @method string getBaseUrl()
35
 * @method static setBaseUrl(string $baseUrl)
36
 */
37
class Gateway extends AGateway {
38
	public const SCHEMA = [
39
		'name' => 'WhatsApp',
40
		'fields' => [
41
			['field' => 'base_url', 'prompt' => 'Base URL to your WhatsApp API endpoint:'],
42
		],
43
	];
44
	private string $instanceId;
45
	private IClient $client;
46
	private string $lazyBaseUrl = '';
47
	public function __construct(
48
		public IAppConfig $appConfig,
49
		private IConfig $config,
50
		private IClientService $clientService,
51
		private LoggerInterface $logger,
52
		private IL10N $l10n,
53
	) {
54
		parent::__construct($appConfig);
55
		$this->instanceId = $this->config->getSystemValue('instanceid');
56
		$this->client = $this->clientService->newClient();
57
	}
58
59
	#[\Override]
60
	public function send(IUser $user, string $identifier, string $message, array $extra = []): void {
61
		$message = $this->l10n->t('`%s` is your Nextcloud verification code.', [$extra['code']]);
62
		$this->logger->debug("sending whatsapp message to $identifier, message: $message");
63
64
		$response = $this->getSessionStatus();
65
		if ($response !== 'CONNECTED') {
66
			throw new MessageTransmissionException('WhatsApp session is not connected. Current status: ' . $response);
67
		}
68
69
		$chatId = $this->getChatIdFromPhoneNumber($identifier);
70
71
		try {
72
			$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...
73
				'json' => [
74
					'chatId' => $chatId,
75
					'contentType' => 'string',
76
					'content' => $message,
77
					'options' => [],
78
				],
79
			]);
80
		} catch (\Exception $e) {
81
			$this->logger->error('Could not send WhatsApp message', [
82
				'identifier' => $identifier,
83
				'exception' => $e,
84
			]);
85
			throw new MessageTransmissionException();
86
		}
87
88
		$this->logger->debug("whatsapp message to chat $identifier sent");
89
	}
90
91
	#[\Override]
92
	public function cliConfigure(InputInterface $input, OutputInterface $output): int {
93
		$helper = new QuestionHelper();
94
		$baseUrlQuestion = new Question(self::SCHEMA['fields'][0]['prompt'] . ' ');
95
		$this->lazyBaseUrl = $helper->ask($input, $output, $baseUrlQuestion);
96
		$this->lazyBaseUrl = rtrim($this->lazyBaseUrl, '/');
97
98
		try {
99
			if ($this->getSessionQr($output) === 1) {
100
				return 1;
101
			}
102
		} catch (\Exception $e) {
103
			$output->writeln('<error>' . $e->getMessage() . '</error>');
104
		}
105
106
		$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

106
		$this->setBaseUrl(/** @scrutinizer ignore-type */ $this->lazyBaseUrl);
Loading history...
107
108
		return 0;
109
	}
110
111
	private function getChatIdFromPhoneNumber(string $phoneNumber): string {
112
		try {
113
			$response = $this->client->post($this->getBaseUrl() . '/client/getNumberId/' . $this->instanceId, [
114
				'json' => [
115
					'number' => preg_replace('/\D/', '', $phoneNumber),
116
				],
117
			]);
118
			$json = $response->getBody();
119
			$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

119
			$data = json_decode(/** @scrutinizer ignore-type */ $json, true);
Loading history...
120
			if (empty($data['result'])) {
121
				throw new MessageTransmissionException('The phone number is not registered on WhatsApp.');
122
			}
123
			return $data['result']['_serialized'];
124
		} catch (ServerException $e) {
125
			$content = $e->getResponse()?->getBody()?->getContents();
126
			if ($content === null) {
127
				throw new MessageTransmissionException('Unknown error');
128
			}
129
			$errorMessage = json_decode($content, true)['error'] ?? 'Unknown error';
130
			throw new MessageTransmissionException($errorMessage);
131
		}
132
	}
133
134
	/**
135
	 * @throws ConfigurationException
136
	 */
137
	public function getBaseUrl(): string {
138
		if ($this->lazyBaseUrl !== '') {
139
			return $this->lazyBaseUrl;
140
		}
141
142
		/** @var string */
143
		$this->lazyBaseUrl = $this->__call(__FUNCTION__, []);
0 ignored issues
show
Bug introduced by
The property lazyBaseUrl does not exist on string.
Loading history...
144
		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...
145
	}
146
147
	private function getSessionQr(OutputInterface $output): int {
148
		$renderer = new PlainTextRenderer(margin: 3);
149
		$writer = new Writer($renderer);
150
		$cursor = new Cursor($output);
151
152
		if ($this->startSession() === 2) {
153
			$output->writeln('<info>Session already connected, no need to scan QR code.</info>');
154
			return 0;
155
		}
156
157
		$last = null;
158
		while (true) {
159
			$response = $this->client->get($this->getBaseUrl() . '/session/qr/' . $this->instanceId);
160
			$json = $response->getBody();
161
			$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

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