Passed
Pull Request — master (#778)
by
unknown
08:24
created

WebSocketDriver::getSessionQr()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 43
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 31
c 1
b 0
f 0
dl 0
loc 43
rs 8.4906
cc 7
nc 8
nop 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\Drivers;
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\Settings;
19
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...
20
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...
21
use OCP\Http\Client\IClient;
22
use OCP\Http\Client\IClientService;
23
use OCP\IAppConfig;
24
use OCP\IConfig;
25
use Psr\Log\LoggerInterface;
26
use Symfony\Component\Console\Cursor;
27
use Symfony\Component\Console\Helper\QuestionHelper;
28
use Symfony\Component\Console\Input\InputInterface;
29
use Symfony\Component\Console\Output\OutputInterface;
30
use Symfony\Component\Console\Question\Question;
31
32
/**
33
 * Driver WebSocket para WhatsApp via WhatsApp Web
34
 * Mantém compatibilidade com configurações existentes
35
 */
36
class WebSocketDriver implements IWhatsAppDriver {
37
	private string $instanceId;
38
	private IClient $client;
39
	private string $lazyBaseUrl = '';
40
41
	public function __construct(
42
		private IAppConfig $appConfig,
43
		private IConfig $config,
44
		private IClientService $clientService,
45
		private LoggerInterface $logger,
46
	) {
47
		$this->instanceId = $this->config->getSystemValue('instanceid');
48
		$this->client = $this->clientService->newClient();
49
	}
50
51
	public function send(string $identifier, string $message, array $extra = []): void {
52
		$this->logger->debug("sending whatsapp message to $identifier, message: $message");
53
54
		$response = $this->getSessionStatus();
55
		if ($response !== 'CONNECTED') {
56
			throw new MessageTransmissionException('WhatsApp session is not connected. Current status: ' . $response);
57
		}
58
59
		$chatId = $this->getChatIdFromPhoneNumber($identifier);
60
61
		try {
62
			$this->client->post($this->getBaseUrl() . '/client/sendMessage/' . $this->instanceId, [
63
				'json' => [
64
					'chatId' => $chatId,
65
					'contentType' => 'string',
66
					'content' => $message,
67
					'options' => [],
68
				],
69
			]);
70
		} catch (\Exception $e) {
71
			$this->logger->error('Could not send WhatsApp message', [
72
				'identifier' => $identifier,
73
				'exception' => $e,
74
			]);
75
			throw new MessageTransmissionException();
76
		}
77
78
		$this->logger->debug("whatsapp message to chat $identifier sent");
79
	}
80
81
	public function getSettings(): Settings {
82
		return new Settings(
83
			name: 'WhatsApp (WebSocket)',
84
			allowMarkdown: true,
85
			fields: [
86
				new FieldDefinition(
87
					field: 'base_url',
88
					prompt: 'Base URL to your WhatsApp API endpoint:',
89
				),
90
			],
91
		);
92
	}
93
94
	public function validateConfig(): void {
95
		try {
96
			$status = $this->getSessionStatus();
97
			if ($status === 'not_connected') {
98
				throw new ConfigurationException('WhatsApp WebSocket session is not connected');
99
			}
100
		} catch (\Exception $e) {
101
			throw new ConfigurationException('Failed to validate WebSocket configuration: ' . $e->getMessage());
102
		}
103
	}
104
105
	public function isConfigComplete(): bool {
106
		return (bool)$this->getBaseUrl();
107
	}
108
109
	public function cliConfigure(InputInterface $input, OutputInterface $output): int {
110
		$helper = new QuestionHelper();
111
		$baseUrlQuestion = new Question($this->getSettings()->fields[0]->prompt . ' ');
112
		$this->lazyBaseUrl = $helper->ask($input, $output, $baseUrlQuestion);
113
		$this->lazyBaseUrl = rtrim($this->lazyBaseUrl, '/');
114
115
		try {
116
			if ($this->getSessionQr($output) === 1) {
117
				return 1;
118
			}
119
		} catch (\Exception $e) {
120
			$output->writeln('<error>' . $e->getMessage() . '</error>');
121
		}
122
123
		$this->setBaseUrl($this->lazyBaseUrl);
124
125
		return 0;
126
	}
127
128
	public static function detectDriver(array $storedConfig): ?string {
129
		// Este driver é detectado quando temos base_url (indicando WebSocket)
130
		if (!empty($storedConfig['base_url'])) {
131
			return self::class;
132
		}
133
		return null;
134
	}
135
136
	/**
137
	 * @throws ConfigurationException
138
	 */
139
	public function getBaseUrl(): string {
140
		if ($this->lazyBaseUrl !== '') {
141
			return $this->lazyBaseUrl;
142
		}
143
144
		$this->lazyBaseUrl = $this->appConfig->getValueString('twofactor_gateway', 'whatsapp_base_url', '');
145
		if ($this->lazyBaseUrl === '') {
146
			throw new ConfigurationException('WhatsApp base URL not configured');
147
		}
148
149
		return $this->lazyBaseUrl;
150
	}
151
152
	private function setBaseUrl(string $baseUrl): void {
153
		$this->appConfig->setValueString('twofactor_gateway', 'whatsapp_base_url', $baseUrl);
154
	}
155
156
	private function getChatIdFromPhoneNumber(string $phoneNumber): string {
157
		try {
158
			$response = $this->client->post($this->getBaseUrl() . '/client/getNumberId/' . $this->instanceId, [
159
				'json' => [
160
					'number' => preg_replace('/\D/', '', $phoneNumber),
161
				],
162
			]);
163
			$json = $response->getBody();
164
			$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

164
			$data = json_decode(/** @scrutinizer ignore-type */ $json, true);
Loading history...
165
			if (empty($data['result'])) {
166
				throw new MessageTransmissionException('The phone number is not registered on WhatsApp.');
167
			}
168
			return $data['result']['_serialized'];
169
		} catch (ServerException $e) {
170
			$content = $e->getResponse()?->getBody()?->getContents();
171
			if ($content === null) {
172
				throw new MessageTransmissionException('Unknown error');
173
			}
174
			$errorMessage = json_decode($content, true)['error'] ?? 'Unknown error';
175
			throw new MessageTransmissionException($errorMessage);
176
		}
177
	}
178
179
	private function getSessionQr(OutputInterface $output): int {
180
		$renderer = new PlainTextRenderer(margin: 3);
181
		$writer = new Writer($renderer);
182
		$cursor = new Cursor($output);
183
184
		if ($this->startSession() === 2) {
185
			$output->writeln('<info>Session already connected, no need to scan QR code.</info>');
186
			return 0;
187
		}
188
189
		$last = null;
190
		while (true) {
191
			$response = $this->client->get($this->getBaseUrl() . '/session/qr/' . $this->instanceId);
192
			$json = $response->getBody();
193
			$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

193
			$data = json_decode(/** @scrutinizer ignore-type */ $json, true);
Loading history...
194
			if ($data['success'] === false) {
195
				if ($data['message'] === 'qr code not ready or already scanned') {
196
					$output->writeln('<error>Session not connected yet, waiting...</error>');
197
					sleep(2);
198
					continue;
199
				}
200
				$output->writeln('<error>' . $data['message'] . '</error>');
201
				return 1;
202
			}
203
			$qrCodeContent = $data['qr'];
204
205
			if ($qrCodeContent !== $last) {
206
				$last = $qrCodeContent;
207
				$cursor->clearScreen();
208
				$cursor->moveToPosition(1, 1);
209
210
				$output->write($writer->writeString($qrCodeContent));
211
				$output->writeln('');
212
				$output->writeln('<info>Please confirm on your phone.</info>');
213
				$output->writeln('Press Ctrl+C to exit');
214
			}
215
216
			sleep(1);
217
			if ($this->startSession() === 2) {
218
				return 0;
219
			}
220
		}
221
		return 0;
222
	}
223
224
	private function getSessionStatus(): string {
225
		$endpoint = $this->getBaseUrl() . '/session/status/' . $this->instanceId;
226
227
		try {
228
			$response = $this->client->get($endpoint);
229
			$body = (string)$response->getBody();
230
			$responseData = json_decode($body, true);
231
232
			if (!is_array($responseData)) {
233
				return 'not_connected';
234
			}
235
236
			if (($responseData['success'] ?? null) === false) {
237
				$msg = $responseData['message'] ?? '';
238
				return in_array($msg, ['session_not_found', 'session_not_connected'], true)
239
					? $msg
240
					: 'not_connected';
241
			}
242
243
			return (string)($responseData['state'] ?? 'not_connected');
244
		} catch (ClientException $e) {
245
			return 'not_connected';
246
		} catch (RequestException $e) {
247
			$this->logger->info('Could not connect to ' . $endpoint, ['exception' => $e]);
248
			throw new \Exception('Could not connect to the WhatsApp API. Please check the URL.', 1);
249
		}
250
	}
251
252
	/**
253
	 * @return int 0 = not connected, 1 = started, 2 = connected
254
	 */
255
	private function startSession(): int {
256
		$status = $this->getSessionStatus();
257
		return match ($status) {
258
			'CONNECTED' => 2,
259
			'session_not_connected' => 0,
260
			'session_not_found' => $this->getSessionStart(),
261
			default => 0,
262
		};
263
	}
264
265
	private function getSessionStart(): int {
266
		$endpoint = $this->getBaseUrl() . '/session/start/' . $this->instanceId;
267
		try {
268
			$this->client->get($endpoint);
269
		} catch (ClientException $e) {
270
			return 1;
271
		} catch (RequestException $e) {
272
			$this->logger->info('Could not connect to ' . $endpoint, [
273
				'exception' => $e,
274
			]);
275
			throw new \Exception('Could not connect to the WhatsApp API. Please check the URL.', 1);
276
		}
277
		return 0;
278
	}
279
}
280