Files
2026-08-21 14:40:27 -07:00

135 lines
3.7 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Talk;
use Exception;
use OCP\Http\Client\IClientService;
use OCP\IURLGenerator;
use OCA\EducAI\Service\SettingsService;
use Psr\Log\LoggerInterface;
/**
* Response sink that posts through the signed Talk bot HTTP API.
*
* This is the legacy webhook transport and also the only way to deliver
* out-of-context replies (rate-limit queue, test rooms) where no native
* BotInvokeEvent is in flight. Every send is an HMAC-signed request back into
* the same Nextcloud instance, so it should not be used for the normal
* native-event path.
*/
class HttpReplySink implements ResponseSink {
private IClientService $clientService;
private SettingsService $settingsService;
private IURLGenerator $urlGenerator;
private LoggerInterface $logger;
private string $roomToken = '';
public function __construct(
IClientService $clientService,
SettingsService $settingsService,
IURLGenerator $urlGenerator,
LoggerInterface $logger
) {
$this->clientService = $clientService;
$this->settingsService = $settingsService;
$this->urlGenerator = $urlGenerator;
$this->logger = $logger;
}
public function bindRoom(string $roomToken): void {
$this->roomToken = $roomToken;
}
public function send(string $message, int|bool $replyTo = false, bool $silent = false): bool {
try {
if (trim($message) === '') {
$this->logger->debug('Skipping empty message - not sending to Talk');
return true; // Return true to not trigger error handling.
}
$secret = $this->settingsService->getWebhookSecret();
if (empty($secret)) {
$this->logger->error('Cannot send reply: webhook secret not configured');
return false;
}
$replyToId = is_int($replyTo) ? $replyTo : 0;
// Get Nextcloud base URL
$baseUrl = $this->urlGenerator->getAbsoluteURL('');
$endpoint = $baseUrl . 'ocs/v2.php/apps/spreed/api/v1/bot/' . $this->roomToken . '/message';
// Prepare request body
$requestBody = [
'message' => $message,
];
if ($replyToId > 0) {
$requestBody['replyTo'] = $replyToId;
}
// Add unique reference ID
$random = bin2hex(random_bytes(32));
$requestBody['referenceId'] = sha1($random);
// Convert to JSON
$jsonBody = json_encode($requestBody);
// Create signature (HMAC of random + message)
$hash = hash_hmac('sha256', $random . $message, $secret);
$this->logger->debug('Sending reply to Talk', [
'endpoint' => $endpoint,
'room_token' => $this->roomToken,
'message_length' => strlen($message),
'reply_to' => $replyToId,
]);
$client = $this->clientService->newClient();
$response = $client->post($endpoint, [
'headers' => [
'Content-Type' => 'application/json',
'OCS-APIRequest' => 'true',
'X-Nextcloud-Talk-Bot-Random' => $random,
'X-Nextcloud-Talk-Bot-Signature' => $hash,
],
'body' => $jsonBody,
'timeout' => 10,
]);
$statusCode = $response->getStatusCode();
if ($statusCode >= 200 && $statusCode < 300) {
$this->logger->info('Successfully sent reply to Talk', [
'room_token' => $this->roomToken,
]);
return true;
}
$this->logger->warning('Unexpected status code from Talk API', [
'status_code' => $statusCode,
'response_body_length' => strlen((string)$response->getBody()),
]);
return false;
} catch (Exception $e) {
$this->logger->error('Failed to send reply to Talk: ' . $e->getMessage(), [
'exception' => $e,
'room_token' => $this->roomToken,
]);
return false;
}
}
public function react(string $emoji): void {
// The signed bot API supports reactions, but the current pipeline does
// not emit reactions. Kept as a no-op to satisfy the abstraction.
}
public function supportsStreaming(): bool {
return true;
}
}