Passed
Pull Request — master (#686)
by Vitor
04:21
created

Gateway::send()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 29
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 2
Bugs 1 Features 1
Metric Value
eloc 19
c 2
b 1
f 1
dl 0
loc 29
ccs 0
cts 21
cp 0
rs 9.6333
cc 3
nc 3
nop 3
crap 12
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
	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
		parent::__construct($appConfig);
50
		$this->instanceId = $this->config->getSystemValue('instanceid');
51
		$this->client = $this->clientService->newClient();
52
	}
53
54
	public function createSettings(): Settings {
55
		return new Settings(
56
			name: 'WhatsApp',
57
			allowMarkdown: true,
58
			fields: [
59
				new FieldDefinition(
60
					field: 'base_url',
61
					prompt: 'Base URL to your WhatsApp API endpoint:',
62
				),
63
			],
64
		);
65
	}
66
67
	#[\Override]
68
	public function send(string $identifier, string $message, array $extra = []): void {
69
		$this->logger->debug("sending whatsapp message to $identifier, message: $message");
70
71
		$response = $this->getSessionStatus();
72
		if ($response !== 'CONNECTED') {
73
			throw new MessageTransmissionException('WhatsApp session is not connected. Current status: ' . $response);
74
		}
75
76
		$chatId = $this->getChatIdFromPhoneNumber($identifier);
77
78
		try {
79
			$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...
80
				'json' => [
81
					'chatId' => $chatId,
82
					'contentType' => 'string',
83
					'content' => $message,
84
					'options' => [],
85
				],
86
			]);
87
		} catch (\Exception $e) {
88
			$this->logger->error('Could not send WhatsApp message', [
89
				'identifier' => $identifier,
90
				'exception' => $e,
91
			]);
92
			throw new MessageTransmissionException();
93
		}
94
95
		$this->logger->debug("whatsapp message to chat $identifier sent");
96
	}
97
98
	#[\Override]
99
	public function cliConfigure(InputInterface $input, OutputInterface $output): int {
100
		$helper = new QuestionHelper();
101
		$baseUrlQuestion = new Question($this->getSettings()->fields[0]->prompt . ' ');
102
		$this->lazyBaseUrl = $helper->ask($input, $output, $baseUrlQuestion);
103
		$this->lazyBaseUrl = rtrim($this->lazyBaseUrl, '/');
104
105
		try {
106
			if ($this->getSessionQr($output) === 1) {
107
				return 1;
108
			}
109
		} catch (\Exception $e) {
110
			$output->writeln('<error>' . $e->getMessage() . '</error>');
111
		}
112
113
		$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

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

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

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