Gateway::startSession()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 6
dl 0
loc 7
ccs 0
cts 7
cp 0
rs 10
c 1
b 0
f 1
cc 1
nc 1
nop 0
crap 2
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\Gateway\AGateway;
18
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...
19
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...
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 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
 * @method string getBaseUrl()
34
 * @method static setBaseUrl(string $baseUrl)
35
 */
36
class Gateway extends AGateway {
37
	public const SCHEMA = [
38
		'name' => 'WhatsApp',
39
		'allow_markdown' => true,
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(string $identifier, string $message, array $extra = []): void {
61
		$this->logger->debug("sending whatsapp message to $identifier, message: $message");
62
63
		$response = $this->getSessionStatus();
64
		if ($response !== 'CONNECTED') {
65
			throw new MessageTransmissionException('WhatsApp session is not connected. Current status: ' . $response);
66
		}
67
68
		$chatId = $this->getChatIdFromPhoneNumber($identifier);
69
70
		try {
71
			$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...
72
				'json' => [
73
					'chatId' => $chatId,
74
					'contentType' => 'string',
75
					'content' => $message,
76
					'options' => [],
77
				],
78
			]);
79
		} catch (\Exception $e) {
80
			$this->logger->error('Could not send WhatsApp message', [
81
				'identifier' => $identifier,
82
				'exception' => $e,
83
			]);
84
			throw new MessageTransmissionException();
85
		}
86
87
		$this->logger->debug("whatsapp message to chat $identifier sent");
88
	}
89
90
	#[\Override]
91
	public function cliConfigure(InputInterface $input, OutputInterface $output): int {
92
		$helper = new QuestionHelper();
93
		$baseUrlQuestion = new Question(self::SCHEMA['fields'][0]['prompt'] . ' ');
94
		$this->lazyBaseUrl = $helper->ask($input, $output, $baseUrlQuestion);
95
		$this->lazyBaseUrl = rtrim($this->lazyBaseUrl, '/');
96
97
		try {
98
			if ($this->getSessionQr($output) === 1) {
99
				return 1;
100
			}
101
		} catch (\Exception $e) {
102
			$output->writeln('<error>' . $e->getMessage() . '</error>');
103
		}
104
105
		$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

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

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

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