UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled
Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06). Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>talk_webhook_demo</id>
|
||||
<name>Talk Webhook demo</name>
|
||||
<summary><![CDATA[Copy of old "Call summary bot"]]></summary>
|
||||
<description><![CDATA[Version of the call summary bot before it was migrated to the new events]]></description>
|
||||
|
||||
<version>22.0.0</version>
|
||||
<licence>agpl</licence>
|
||||
|
||||
<author>Joas Schilling</author>
|
||||
<namespace>TalkWebhookDemo</namespace>
|
||||
<category>workflow</category>
|
||||
<bugs>https://github.com/nextcloud/spreed/issues</bugs>
|
||||
|
||||
<dependencies>
|
||||
<nextcloud min-version="32" max-version="32" />
|
||||
</dependencies>
|
||||
|
||||
<repair-steps>
|
||||
<install>
|
||||
<step>OCA\TalkWebhookDemo\Migration\InstallBot</step>
|
||||
</install>
|
||||
<uninstall>
|
||||
<step>OCA\TalkWebhookDemo\Migration\UninstallBot</step>
|
||||
</uninstall>
|
||||
</repair-steps>
|
||||
</info>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
return [
|
||||
'ocs' => [
|
||||
/** @see \OCA\TalkWebhookDemo\Controller\BotController::receiveWebhook() */
|
||||
['name' => 'Bot#receiveWebhook', 'url' => '/api/v1/bot/{lang}', 'verb' => 'POST'],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Controller;
|
||||
|
||||
use OCA\TalkWebhookDemo\Model\Bot;
|
||||
use OCA\TalkWebhookDemo\Model\LogEntry;
|
||||
use OCA\TalkWebhookDemo\Model\LogEntryMapper;
|
||||
use OCA\TalkWebhookDemo\Service\SummaryService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
|
||||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\ICertificateManager;
|
||||
use OCP\IConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\L10N\IFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class BotController extends OCSController {
|
||||
|
||||
protected bool $legacySecret = false;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
protected IClientService $clientService,
|
||||
protected ITimeFactory $timeFactory,
|
||||
protected IFactory $l10nFactory,
|
||||
protected LogEntryMapper $logEntryMapper,
|
||||
protected SummaryService $summaryService,
|
||||
protected IConfig $config,
|
||||
protected LoggerInterface $logger,
|
||||
protected ICertificateManager $certificateManager,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the body of the POST request
|
||||
*/
|
||||
protected function getInputStream(): string {
|
||||
return file_get_contents('php://input');
|
||||
}
|
||||
|
||||
#[BruteForceProtection(action: 'webhook')]
|
||||
#[PublicPage]
|
||||
public function receiveWebhook(string $lang): DataResponse {
|
||||
if (!in_array($lang, Bot::SUPPORTED_LANGUAGES, true)) {
|
||||
$this->logger->warning('Request for unsupported language was sent');
|
||||
$response = new DataResponse([], Http::STATUS_BAD_REQUEST);
|
||||
$response->throttle(['action' => 'webhook']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
$signature = $this->request->getHeader('X_NEXTCLOUD_TALK_SIGNATURE');
|
||||
$random = $this->request->getHeader('X_NEXTCLOUD_TALK_RANDOM');
|
||||
$server = rtrim($this->request->getHeader('X_NEXTCLOUD_TALK_BACKEND'), '/') . '/';
|
||||
|
||||
$secretData = $this->config->getAppValue('talk_webhook_demo', 'secret_' . sha1($server));
|
||||
if ($secretData === '') {
|
||||
$this->logger->warning('No matching secret found for server: ' . $server);
|
||||
$response = new DataResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
$response->throttle(['action' => 'webhook']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = json_decode($secretData, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
$this->logger->error('Could not json_decode config');
|
||||
return new DataResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$body = $this->getInputStream();
|
||||
$secret = $config['secret'] . str_replace('_', '', $lang);
|
||||
$generatedDigest = hash_hmac('sha256', $random . $body, $secret);
|
||||
|
||||
if (!hash_equals($generatedDigest, strtolower($signature))) {
|
||||
$generatedLegacyDigest = hash_hmac('sha256', $random . $body, $config['secret']);
|
||||
if (!hash_equals($generatedLegacyDigest, strtolower($signature))) {
|
||||
$this->logger->warning('Message signature could not be verified');
|
||||
$response = new DataResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
$response->throttle(['action' => 'webhook']);
|
||||
return $response;
|
||||
}
|
||||
// Installed before final release, when the secret was not unique
|
||||
$secret = $config['secret'];
|
||||
$this->legacySecret = true;
|
||||
}
|
||||
|
||||
$this->logger->debug($body);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if ($data['type'] === 'Create' && $data['object']['name'] === 'message') {
|
||||
$messageData = json_decode($data['object']['content'], true);
|
||||
$message = $messageData['message'];
|
||||
|
||||
if (str_starts_with($message, '/thread')) {
|
||||
[, $title, $message] = explode(' ', $message, 3);
|
||||
$body = [
|
||||
'message' => $message,
|
||||
'referenceId' => sha1($random),
|
||||
'threadTitle' => $title,
|
||||
];
|
||||
$this->sendResponse($server, $secret, $body, $data);
|
||||
return new DataResponse();
|
||||
}
|
||||
|
||||
if (!$this->logEntryMapper->hasActiveCall($server, $data['target']['id'])) {
|
||||
$agendaDetected = $this->summaryService->readAgendaFromMessage($message, $messageData, $server, $data);
|
||||
|
||||
if ($agendaDetected) {
|
||||
// React with thumbs up as we detected an agenda item
|
||||
$this->sendReaction($server, $secret, $data);
|
||||
}
|
||||
return new DataResponse();
|
||||
}
|
||||
|
||||
$taskDetected = $this->summaryService->readTasksFromMessage($message, $messageData, $server, $data);
|
||||
|
||||
if ($taskDetected) {
|
||||
// React with thumbs up as we detected a task
|
||||
$this->sendReaction($server, $secret, $data);
|
||||
// Sample: $this->removeReaction($server, $secret, $data);
|
||||
}
|
||||
} elseif ($data['type'] === 'Activity') {
|
||||
if ($data['object']['name'] === 'call_joined' || $data['object']['name'] === 'call_started') {
|
||||
if ($data['object']['name'] === 'call_started') {
|
||||
$this->postAgenda($server, $secret, $random, $data, $lang);
|
||||
|
||||
$logEntry = new LogEntry();
|
||||
$logEntry->setServer($server);
|
||||
$logEntry->setToken($data['target']['id']);
|
||||
$logEntry->setType(LogEntry::TYPE_START);
|
||||
$logEntry->setDetails((string)$this->timeFactory->now()->getTimestamp());
|
||||
$this->logEntryMapper->insert($logEntry);
|
||||
|
||||
$logEntry = new LogEntry();
|
||||
$logEntry->setServer($server);
|
||||
$logEntry->setToken($data['target']['id']);
|
||||
$logEntry->setType(LogEntry::TYPE_ELEVATOR);
|
||||
$logEntry->setDetails((string)$data['object']['id']);
|
||||
$this->logEntryMapper->insert($logEntry);
|
||||
}
|
||||
|
||||
$logEntry = new LogEntry();
|
||||
$logEntry->setServer($server);
|
||||
$logEntry->setToken($data['target']['id']);
|
||||
$logEntry->setType(LogEntry::TYPE_ATTENDEE);
|
||||
|
||||
$displayName = $data['actor']['name'];
|
||||
if (str_starts_with($data['actor']['id'], 'guests/') || str_starts_with($data['actor']['id'], 'emails/')) {
|
||||
if ($displayName === '') {
|
||||
return new DataResponse();
|
||||
}
|
||||
$l = $this->l10nFactory->get('talk_webhook_demo', $lang);
|
||||
$displayName = $l->t('%s (guest)', $displayName);
|
||||
} elseif (str_starts_with($data['actor']['id'], 'federated_users/')) {
|
||||
$cloudIdServer = explode('@', $data['actor']['id']);
|
||||
$displayName .= ' (' . array_pop($cloudIdServer) . ')';
|
||||
}
|
||||
|
||||
$logEntry->setDetails($displayName);
|
||||
if ($logEntry->getDetails()) {
|
||||
// Only store when not empty
|
||||
$this->logEntryMapper->insert($logEntry);
|
||||
}
|
||||
} elseif ($data['object']['name'] === 'call_ended' || $data['object']['name'] === 'call_ended_everyone') {
|
||||
$summary = $this->summaryService->summarize($server, $data['target']['id'], $data['target']['name'], $lang);
|
||||
if ($summary !== null) {
|
||||
$body = [
|
||||
'message' => $summary['summary'],
|
||||
'referenceId' => sha1($random),
|
||||
];
|
||||
|
||||
if (!empty($summary['elevator'])) {
|
||||
$body['replyTo'] = $summary['elevator'];
|
||||
}
|
||||
|
||||
// Generate and post summary
|
||||
$this->sendResponse($server, $secret, $body, $data);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new DataResponse();
|
||||
}
|
||||
|
||||
protected function postAgenda(string $server, string $secret, string $random, array $data, string $lang): void {
|
||||
$agenda = $this->summaryService->agenda($server, $data['target']['id'], $lang);
|
||||
if ($agenda !== null) {
|
||||
$body = [
|
||||
'message' => $agenda,
|
||||
'referenceId' => sha1($random),
|
||||
];
|
||||
|
||||
// Generate and post summary
|
||||
$this->sendResponse($server, $secret, $body, $data);
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendResponse(string $server, string $secret, array $body, array $data): void {
|
||||
$jsonBody = json_encode($body, JSON_THROW_ON_ERROR);
|
||||
|
||||
$random = bin2hex(random_bytes(32));
|
||||
$hash = hash_hmac('sha256', $random . $body['message'], $secret);
|
||||
$this->logger->debug('Reply: Random ' . $random);
|
||||
$this->logger->debug('Reply: Hash ' . $hash);
|
||||
|
||||
try {
|
||||
$options = [
|
||||
'headers' => [
|
||||
'OCS-APIRequest' => 'true',
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'X-Nextcloud-Talk-Bot-Random' => $random,
|
||||
'X-Nextcloud-Talk-Bot-Signature' => $hash,
|
||||
'User-Agent' => 'nextcloud-call-summary-bot/1.0',
|
||||
],
|
||||
'body' => $jsonBody,
|
||||
'verify' => $this->certificateManager->getAbsoluteBundlePath(),
|
||||
'nextcloud' => [
|
||||
'allow_local_address' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$client = $this->clientService->newClient();
|
||||
$response = $client->post(rtrim($server, '/') . '/ocs/v2.php/apps/spreed/api/v1/bot/' . $data['target']['id'] . '/message', $options);
|
||||
$this->logger->info('Response: ' . $response->getBody());
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->info($exception::class . ': ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendReaction(string $server, string $secret, array $data): void {
|
||||
$body = [
|
||||
'reaction' => '👍',
|
||||
];
|
||||
$jsonBody = json_encode($body, JSON_THROW_ON_ERROR);
|
||||
|
||||
$random = bin2hex(random_bytes(32));
|
||||
$hash = hash_hmac('sha256', $random . $body['reaction'], $secret);
|
||||
$this->logger->debug('Reaction: Random ' . $random);
|
||||
$this->logger->debug('Reaction: Hash ' . $hash);
|
||||
|
||||
try {
|
||||
$options = [
|
||||
'headers' => [
|
||||
'OCS-APIRequest' => 'true',
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'X-Nextcloud-Talk-Bot-Random' => $random,
|
||||
'X-Nextcloud-Talk-Bot-Signature' => $hash,
|
||||
'User-Agent' => 'nextcloud-call-summary-bot/1.0',
|
||||
],
|
||||
'body' => $jsonBody,
|
||||
'verify' => $this->certificateManager->getAbsoluteBundlePath(),
|
||||
'nextcloud' => [
|
||||
'allow_local_address' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$client = $this->clientService->newClient();
|
||||
$response = $client->post(rtrim($server, '/') . '/ocs/v2.php/apps/spreed/api/v1/bot/' . $data['target']['id'] . '/reaction/' . $data['object']['id'], $options);
|
||||
$this->logger->info('Response: ' . $response->getBody());
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->info($exception::class . ': ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function removeReaction(string $server, string $secret, array $data): void {
|
||||
$body = [
|
||||
'reaction' => '👍',
|
||||
];
|
||||
$jsonBody = json_encode($body, JSON_THROW_ON_ERROR);
|
||||
|
||||
$random = bin2hex(random_bytes(32));
|
||||
$hash = hash_hmac('sha256', $random . $body['reaction'], $secret);
|
||||
$this->logger->debug('RemoveReaction: Random ' . $random);
|
||||
$this->logger->debug('RemoveReaction: Hash ' . $hash);
|
||||
|
||||
try {
|
||||
$options = [
|
||||
'headers' => [
|
||||
'OCS-APIRequest' => 'true',
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'X-Nextcloud-Talk-Bot-Random' => $random,
|
||||
'X-Nextcloud-Talk-Bot-Signature' => $hash,
|
||||
'User-Agent' => 'nextcloud-call-summary-bot/1.0',
|
||||
],
|
||||
'body' => $jsonBody,
|
||||
'verify' => $this->certificateManager->getAbsoluteBundlePath(),
|
||||
'nextcloud' => [
|
||||
'allow_local_address' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$client = $this->clientService->newClient();
|
||||
$response = $client->delete(rtrim($server, '/') . '/ocs/v2.php/apps/spreed/api/v1/bot/' . $data['target']['id'] . '/reaction/' . $data['object']['id'], $options);
|
||||
$this->logger->info('Response: ' . $response->getBody());
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->info($exception::class . ': ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Migration;
|
||||
|
||||
use OCA\Talk\Events\BotInstallEvent;
|
||||
use OCA\TalkWebhookDemo\Service\BotService;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\IRepairStep;
|
||||
|
||||
class InstallBot implements IRepairStep {
|
||||
public function __construct(
|
||||
protected IURLGenerator $url,
|
||||
protected BotService $service,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return 'Install as Talk bot';
|
||||
}
|
||||
|
||||
public function run(IOutput $output): void {
|
||||
if (!class_exists(BotInstallEvent::class)) {
|
||||
$output->warning('Talk not found, not installing bots');
|
||||
return;
|
||||
}
|
||||
|
||||
$backend = $this->url->getAbsoluteURL('');
|
||||
$this->service->installBot($backend);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Migration;
|
||||
|
||||
use OCA\Talk\Events\BotUninstallEvent;
|
||||
use OCA\TalkWebhookDemo\Service\BotService;
|
||||
use OCP\IConfig;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\IRepairStep;
|
||||
|
||||
class UninstallBot implements IRepairStep {
|
||||
public function __construct(
|
||||
protected IConfig $config,
|
||||
protected IURLGenerator $url,
|
||||
protected BotService $service,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return 'Uninstall Talk bots';
|
||||
}
|
||||
|
||||
public function run(IOutput $output): void {
|
||||
if (!class_exists(BotUninstallEvent::class)) {
|
||||
$output->warning('Talk not found, not removing the bots');
|
||||
return;
|
||||
}
|
||||
|
||||
$backend = $this->url->getAbsoluteURL('');
|
||||
$id = sha1($backend);
|
||||
|
||||
$secretData = $this->config->getAppValue('talk_webhook_demo', 'secret_' . $id);
|
||||
if ($secretData) {
|
||||
$secretArray = json_decode($secretData, true, 512, JSON_THROW_ON_ERROR);
|
||||
if ($secretArray['secret']) {
|
||||
$this->service->uninstallBot($secretArray['secret'], $backend);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version1000Date20230719061613 extends SimpleMigrationStep {
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return null|ISchemaWrapper
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
if (!$schema->hasTable('twd_log_entries')) {
|
||||
$table = $schema->createTable('twd_log_entries');
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'length' => 11,
|
||||
]);
|
||||
|
||||
$table->addColumn('server', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 512,
|
||||
]);
|
||||
$table->addColumn('token', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
]);
|
||||
|
||||
$table->addColumn('type', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 32,
|
||||
]);
|
||||
$table->addColumn('details', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['server', 'token'], 'twd_log_entry_origin');
|
||||
return $schema;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Model;
|
||||
|
||||
class Bot {
|
||||
public const SUPPORTED_LANGUAGES = [
|
||||
'en',
|
||||
//'de',
|
||||
//'es',
|
||||
//'fr',
|
||||
//'ar',
|
||||
//'pt_BR',
|
||||
//'tr',
|
||||
//'zh_CN',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Model;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method void setServer(string $server)
|
||||
* @method string getServer()
|
||||
* @method void setToken(string $token)
|
||||
* @method string getToken()
|
||||
* @method void setType(string $type)
|
||||
* @method string getType()
|
||||
* @method void setDetails(?string $details)
|
||||
* @method string|null getDetails()
|
||||
*/
|
||||
class LogEntry extends Entity {
|
||||
public const TYPE_ATTENDEE = 'attendee';
|
||||
public const TYPE_START = 'start';
|
||||
public const TYPE_ELEVATOR = 'elevator';
|
||||
public const TYPE_TODO = 'todo';
|
||||
public const TYPE_SOLVED = 'solved';
|
||||
public const TYPE_NOTE = 'note';
|
||||
public const TYPE_REPORT = 'report';
|
||||
public const TYPE_DECISION = 'decision';
|
||||
public const TYPE_AGENDA = 'agenda';
|
||||
|
||||
/** @var string */
|
||||
protected $server;
|
||||
|
||||
/** @var string */
|
||||
protected $token;
|
||||
|
||||
/** @var string */
|
||||
protected $type;
|
||||
|
||||
/** @var ?string */
|
||||
protected $details;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('server', 'string');
|
||||
$this->addType('token', 'string');
|
||||
$this->addType('type', 'string');
|
||||
$this->addType('details', 'string');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Model;
|
||||
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @method LogEntry mapRowToEntity(array $row)
|
||||
* @method LogEntry findEntity(IQueryBuilder $query)
|
||||
* @method list<LogEntry> findEntities(IQueryBuilder $query)
|
||||
* @template-extends QBMapper<LogEntry>
|
||||
*/
|
||||
class LogEntryMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'twd_log_entries', LogEntry::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LogEntry[]
|
||||
*/
|
||||
public function findByConversation(string $server, string $token): array {
|
||||
$query = $this->db->getQueryBuilder();
|
||||
$query->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($query->expr()->eq('server', $query->createNamedParameter($server)))
|
||||
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token)));
|
||||
return $this->findEntities($query);
|
||||
}
|
||||
|
||||
public function hasActiveCall(string $server, string $token): bool {
|
||||
$query = $this->db->getQueryBuilder();
|
||||
$query->select($query->expr()->literal(1))
|
||||
->from($this->getTableName())
|
||||
->where($query->expr()->eq('server', $query->createNamedParameter($server)))
|
||||
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token)))
|
||||
->andWhere($query->expr()->eq('type', $query->createNamedParameter(LogEntry::TYPE_ATTENDEE)))
|
||||
->setMaxResults(1);
|
||||
$result = $query->executeQuery();
|
||||
$hasAttendee = (bool)$result->fetchOne();
|
||||
$result->closeCursor();
|
||||
|
||||
return $hasAttendee;
|
||||
}
|
||||
|
||||
public function deleteByConversation(string $server, string $token): void {
|
||||
$query = $this->db->getQueryBuilder();
|
||||
$query->delete($this->getTableName())
|
||||
->where($query->expr()->eq('server', $query->createNamedParameter($server)))
|
||||
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token)));
|
||||
$query->executeStatement();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Service;
|
||||
|
||||
use OCA\Talk\Events\BotInstallEvent;
|
||||
use OCA\Talk\Events\BotUninstallEvent;
|
||||
use OCA\TalkWebhookDemo\Model\Bot;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\IConfig;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\L10N\IFactory;
|
||||
use OCP\Security\ISecureRandom;
|
||||
|
||||
class BotService {
|
||||
public function __construct(
|
||||
protected IConfig $config,
|
||||
protected IURLGenerator $url,
|
||||
protected IEventDispatcher $dispatcher,
|
||||
protected IFactory $l10nFactory,
|
||||
protected ISecureRandom $random,
|
||||
) {
|
||||
}
|
||||
|
||||
public function installBot(string $backend): void {
|
||||
$id = sha1($backend);
|
||||
|
||||
$secretData = $this->config->getAppValue('talk_webhook_demo', 'secret_' . $id);
|
||||
if ($secretData) {
|
||||
$secretArray = json_decode($secretData, true, 512, JSON_THROW_ON_ERROR);
|
||||
$secret = $secretArray['secret'] ?? $this->random->generate(64, ISecureRandom::CHAR_HUMAN_READABLE);
|
||||
} else {
|
||||
$secret = $this->random->generate(64, ISecureRandom::CHAR_HUMAN_READABLE);
|
||||
}
|
||||
foreach (Bot::SUPPORTED_LANGUAGES as $lang) {
|
||||
$this->installLanguage($secret, $lang);
|
||||
}
|
||||
|
||||
$this->config->setAppValue('talk_webhook_demo', 'secret_' . $id, json_encode([
|
||||
'id' => $id,
|
||||
'secret' => $secret,
|
||||
'backend' => $backend,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
protected function installLanguage(string $secret, string $lang): void {
|
||||
$libL10n = $this->l10nFactory->get('lib', $lang);
|
||||
$langName = $libL10n->t('__language_name__');
|
||||
if ($langName === '__language_name__') {
|
||||
$langName = $lang === 'en' ? 'British English' : $lang;
|
||||
}
|
||||
|
||||
$l = $this->l10nFactory->get('talk_webhook_demo', $lang);
|
||||
|
||||
$event = new BotInstallEvent(
|
||||
$l->t('Webhook Demo'),
|
||||
$secret . str_replace('_', '', $lang),
|
||||
$this->url->linkToOCSRouteAbsolute('talk_webhook_demo.Bot.receiveWebhook', ['lang' => $lang]),
|
||||
$l->t('Call summary (%s)', $langName) . ' - ' . $l->t('The call summary bot posts an overview message after the call listing all participants and outlining tasks'),
|
||||
);
|
||||
try {
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
public function uninstallBot(string $secret, string $backend): void {
|
||||
foreach (Bot::SUPPORTED_LANGUAGES as $lang) {
|
||||
$this->uninstallLanguage($secret, $backend, $lang);
|
||||
}
|
||||
}
|
||||
|
||||
protected function uninstallLanguage(string $secret, string $backend, string $lang): void {
|
||||
$absoluteUrl = $this->url->getAbsoluteURL('');
|
||||
$backendUrl = rtrim($backend, '/') . '/' . substr($this->url->linkToOCSRouteAbsolute('talk_webhook_demo.Bot.receiveWebhook', ['lang' => $lang]), strlen($absoluteUrl));
|
||||
|
||||
$event = new BotUninstallEvent(
|
||||
$secret . str_replace('_', '', $lang),
|
||||
$backendUrl,
|
||||
);
|
||||
try {
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Also remove legacy secret bots
|
||||
$event = new BotUninstallEvent(
|
||||
$secret,
|
||||
$backendUrl,
|
||||
);
|
||||
try {
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\TalkWebhookDemo\Service;
|
||||
|
||||
use OCA\TalkWebhookDemo\Model\LogEntry;
|
||||
use OCA\TalkWebhookDemo\Model\LogEntryMapper;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDateTimeFormatter;
|
||||
use OCP\IL10N;
|
||||
use OCP\L10N\IFactory;
|
||||
|
||||
class SummaryService {
|
||||
public const LIST_PATTERN = '/^[-*]\s(\[[ x]])[^\S\n]*/mi';
|
||||
public const TODO_UNSOLVED_PATTERN = '/^[-*]\s\[ ][^\S\n]*/mi';
|
||||
public const TODO_SOLVED_PATTERN = '/^[-*]\s\[x][^\S\n]*/mi';
|
||||
|
||||
public const SUMMARY_PATTERN = '/(?:^[-*]\s|^)(to[\s-]?do|solved|task|note|report|decision)s?\s*:/mi';
|
||||
public const TODO_PATTERN = '/^(to[\s-]?do|task)$/i';
|
||||
public const SOLVED_PATTERN = '/^solved$/i';
|
||||
public const NOTE_PATTERN = '/^note$/i';
|
||||
public const REPORT_PATTERN = '/^report$/i';
|
||||
public const DECISION_PATTERN = '/^decision$/i';
|
||||
public const AGENDA_PATTERN = '/(^[-*]\s|^)(agenda|top|topic)\s*:/mi';
|
||||
|
||||
public function __construct(
|
||||
protected IConfig $config,
|
||||
protected LogEntryMapper $logEntryMapper,
|
||||
protected ITimeFactory $timeFactory,
|
||||
protected IDateTimeFormatter $dateTimeFormatter,
|
||||
protected IFactory $l10nFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
public function readTasksFromMessage(string $message, array $messageData, string $server, array $data): bool {
|
||||
$endOfFirstLine = strpos($message, "\n") ?: -1;
|
||||
$firstLowerLine = strtolower(substr($message, 0, $endOfFirstLine));
|
||||
|
||||
if (!preg_match(self::LIST_PATTERN, $firstLowerLine)
|
||||
&& !preg_match(self::SUMMARY_PATTERN, $firstLowerLine)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$placeholders = $replacements = [];
|
||||
foreach ($messageData['parameters'] as $placeholder => $parameter) {
|
||||
$placeholders[] = '{' . $placeholder . '}';
|
||||
if ($parameter['type'] === 'user') {
|
||||
if (str_contains($parameter['id'], ' ') || str_contains($parameter['id'], '/')) {
|
||||
$replacements[] = '@"' . $parameter['id'] . '"';
|
||||
} else {
|
||||
$replacements[] = '@' . $parameter['id'];
|
||||
}
|
||||
} elseif ($parameter['type'] === 'call') {
|
||||
$replacements[] = '@all';
|
||||
} elseif ($parameter['type'] === 'guest') {
|
||||
$replacements[] = '@' . $parameter['name'];
|
||||
} else {
|
||||
$replacements[] = $parameter['name'];
|
||||
}
|
||||
}
|
||||
|
||||
$parsedMessage = str_replace($placeholders, $replacements, $message);
|
||||
$parsedMessage = preg_replace(self::TODO_SOLVED_PATTERN, '- solved: ', $parsedMessage);
|
||||
$parsedMessage = preg_replace(self::TODO_UNSOLVED_PATTERN, '- todo: ', $parsedMessage);
|
||||
|
||||
if (preg_match(self::SUMMARY_PATTERN, $parsedMessage)) {
|
||||
$todos = preg_split(self::SUMMARY_PATTERN, $parsedMessage, flags: PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
|
||||
$nextEntry = null;
|
||||
foreach ($todos as $todo) {
|
||||
if (preg_match(self::TODO_PATTERN, $todo)) {
|
||||
$nextEntry = LogEntry::TYPE_TODO;
|
||||
} elseif (preg_match(self::SOLVED_PATTERN, $todo)) {
|
||||
$nextEntry = LogEntry::TYPE_SOLVED;
|
||||
} elseif (preg_match(self::SOLVED_PATTERN, $todo)) {
|
||||
$nextEntry = LogEntry::TYPE_SOLVED;
|
||||
} elseif (preg_match(self::NOTE_PATTERN, $todo)) {
|
||||
$nextEntry = LogEntry::TYPE_NOTE;
|
||||
} elseif (preg_match(self::REPORT_PATTERN, $todo)) {
|
||||
$nextEntry = LogEntry::TYPE_REPORT;
|
||||
} elseif (preg_match(self::DECISION_PATTERN, $todo)) {
|
||||
$nextEntry = LogEntry::TYPE_DECISION;
|
||||
} elseif ($nextEntry !== null) {
|
||||
$todoText = trim($todo);
|
||||
if ($todoText) {
|
||||
// Only store when not empty
|
||||
$this->saveTask($server, $data['target']['id'], $todoText, $nextEntry);
|
||||
}
|
||||
$nextEntry = null;
|
||||
}
|
||||
}
|
||||
|
||||
// React with thumbs up as we detected a task
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function readAgendaFromMessage(string $message, array $messageData, string $server, array $data): bool {
|
||||
$endOfFirstLine = strpos($message, "\n") ?: -1;
|
||||
$firstLowerLine = strtolower(substr($message, 0, $endOfFirstLine));
|
||||
|
||||
if (!preg_match(self::AGENDA_PATTERN, $firstLowerLine)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$placeholders = $replacements = [];
|
||||
foreach ($messageData['parameters'] as $placeholder => $parameter) {
|
||||
$placeholders[] = '{' . $placeholder . '}';
|
||||
if ($parameter['type'] === 'user') {
|
||||
if (str_contains($parameter['id'], ' ') || str_contains($parameter['id'], '/')) {
|
||||
$replacements[] = '@"' . $parameter['id'] . '"';
|
||||
} else {
|
||||
$replacements[] = '@' . $parameter['id'];
|
||||
}
|
||||
} elseif ($parameter['type'] === 'call') {
|
||||
$replacements[] = '@all';
|
||||
} elseif ($parameter['type'] === 'guest') {
|
||||
$replacements[] = '@' . $parameter['name'];
|
||||
} else {
|
||||
$replacements[] = $parameter['name'];
|
||||
}
|
||||
}
|
||||
|
||||
$parsedMessage = str_replace($placeholders, $replacements, $message);
|
||||
$agendas = preg_split(self::AGENDA_PATTERN, $parsedMessage, flags: PREG_SPLIT_NO_EMPTY);
|
||||
foreach ($agendas as $agenda) {
|
||||
$agendaText = trim($agenda);
|
||||
if ($agendaText) {
|
||||
// Only store when not empty
|
||||
$this->saveTask($server, $data['target']['id'], $agendaText, LogEntry::TYPE_AGENDA);
|
||||
}
|
||||
}
|
||||
|
||||
// React with thumbs up as we detected a task
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function saveTask(string $server, string $token, string $text, string $type): void {
|
||||
$logEntry = new LogEntry();
|
||||
$logEntry->setServer($server);
|
||||
$logEntry->setToken($token);
|
||||
$logEntry->setType($type);
|
||||
$logEntry->setDetails($text);
|
||||
$this->logEntryMapper->insert($logEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $server
|
||||
* @param string $token
|
||||
* @param string $roomName
|
||||
* @param string $lang
|
||||
* @return array{summary: string, elevator: ?int}|null
|
||||
*/
|
||||
public function summarize(string $server, string $token, string $roomName, string $lang = 'en'): ?array {
|
||||
$logEntries = $this->logEntryMapper->findByConversation($server, $token);
|
||||
$this->logEntryMapper->deleteByConversation($server, $token);
|
||||
|
||||
$libL10N = $this->l10nFactory->get('lib', $lang);
|
||||
$l = $this->l10nFactory->get('talk_webhook_demo', $lang);
|
||||
|
||||
$endDateTime = $this->timeFactory->now();
|
||||
$endTimestamp = $endDateTime->getTimestamp();
|
||||
$startTimestamp = $endTimestamp;
|
||||
|
||||
$attendees = $todos = $solved = $notes = $decisions = $reports = [];
|
||||
$elevator = null;
|
||||
|
||||
foreach ($logEntries as $logEntry) {
|
||||
if ($logEntry->getType() === LogEntry::TYPE_START) {
|
||||
$time = (int)$logEntry->getDetails();
|
||||
if ($startTimestamp > $time) {
|
||||
$startTimestamp = $time;
|
||||
}
|
||||
} elseif ($logEntry->getType() === LogEntry::TYPE_ATTENDEE) {
|
||||
$attendees[] = $logEntry->getDetails();
|
||||
} elseif ($logEntry->getType() === LogEntry::TYPE_TODO) {
|
||||
$todos[] = $logEntry->getDetails();
|
||||
} elseif ($logEntry->getType() === LogEntry::TYPE_SOLVED) {
|
||||
$solved[] = $logEntry->getDetails();
|
||||
} elseif ($logEntry->getType() === LogEntry::TYPE_NOTE) {
|
||||
$notes[] = $logEntry->getDetails();
|
||||
} elseif ($logEntry->getType() === LogEntry::TYPE_DECISION) {
|
||||
$decisions[] = $logEntry->getDetails();
|
||||
} elseif ($logEntry->getType() === LogEntry::TYPE_REPORT) {
|
||||
$reports[] = $logEntry->getDetails();
|
||||
} elseif ($logEntry->getType() === LogEntry::TYPE_ELEVATOR) {
|
||||
$elevator = (int)$logEntry->getDetails();
|
||||
}
|
||||
}
|
||||
|
||||
if (($endTimestamp - $startTimestamp) < (int)$this->config->getAppValue('talk_webhook_demo', 'min-length', '60')) {
|
||||
// No call summary for short calls
|
||||
return null;
|
||||
}
|
||||
|
||||
$attendees = array_unique($attendees);
|
||||
sort($attendees);
|
||||
|
||||
$systemDefault = $this->config->getSystemValueString('default_timezone', 'UTC');
|
||||
$timezoneString = $this->config->getAppValue('talk_webhook_demo', 'timezone', $systemDefault);
|
||||
$timezone = null;
|
||||
if ($timezoneString !== 'UTC') {
|
||||
try {
|
||||
$timezone = new \DateTimeZone($timezoneString);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
$startDate = $this->dateTimeFormatter->formatDate($startTimestamp, 'full', $timezone, $libL10N);
|
||||
$startTime = $this->dateTimeFormatter->formatTime($startTimestamp, 'short', $timezone, $libL10N);
|
||||
$endTime = $this->dateTimeFormatter->formatTime($endTimestamp, 'short', $timezone, $libL10N);
|
||||
|
||||
$summary = '# ' . $this->getTitle($l, $roomName) . "\n\n";
|
||||
$summary .= $startDate . ' · ' . $startTime . ' – ' . $endTime;
|
||||
if ($timezone !== null) {
|
||||
$summary .= ' (' . $timezone->getName() . ")\n";
|
||||
} else {
|
||||
$summary .= ' (' . $endDateTime->getTimezone()->getName() . ")\n";
|
||||
}
|
||||
|
||||
$summary .= "\n";
|
||||
$summary .= '## ' . $l->t('Attendees') . "\n";
|
||||
foreach ($attendees as $attendee) {
|
||||
$summary .= '- ' . $attendee . "\n";
|
||||
}
|
||||
|
||||
if (!empty($todos) || !empty($solved)) {
|
||||
$summary .= "\n";
|
||||
$summary .= '## ' . $l->t('Tasks') . "\n";
|
||||
foreach ($solved as $todo) {
|
||||
$summary .= '- [x] ' . $todo . "\n";
|
||||
}
|
||||
foreach ($todos as $todo) {
|
||||
$summary .= '- [ ] ' . $todo . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($notes)) {
|
||||
$summary .= "\n";
|
||||
$summary .= '## ' . $l->t('Notes') . "\n";
|
||||
foreach ($notes as $note) {
|
||||
$summary .= '- ' . $note . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($reports)) {
|
||||
$summary .= "\n";
|
||||
$summary .= '## ' . $l->t('Reports') . "\n";
|
||||
foreach ($reports as $report) {
|
||||
$summary .= '- ' . $report . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($decisions)) {
|
||||
$summary .= "\n";
|
||||
$summary .= '## ' . $l->t('Decisions') . "\n";
|
||||
foreach ($decisions as $decision) {
|
||||
$summary .= '- ' . $decision . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return ['summary' => $summary, 'elevator' => $elevator];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $server
|
||||
* @param string $token
|
||||
* @param string $lang
|
||||
* @return ?string
|
||||
*/
|
||||
public function agenda(string $server, string $token, string $lang = 'en'): ?string {
|
||||
$logEntries = $this->logEntryMapper->findByConversation($server, $token);
|
||||
$this->logEntryMapper->deleteByConversation($server, $token);
|
||||
|
||||
|
||||
$agenda = [];
|
||||
foreach ($logEntries as $logEntry) {
|
||||
if ($logEntry->getType() === LogEntry::TYPE_AGENDA) {
|
||||
$agenda[] = $logEntry->getDetails();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($agenda)) {
|
||||
return null;
|
||||
}
|
||||
$agenda = array_unique($agenda);
|
||||
|
||||
$l = $this->l10nFactory->get('talk_webhook_demo', $lang);
|
||||
$summary = '# ' . $l->t('Agenda') . "\n\n";
|
||||
foreach ($agenda as $item) {
|
||||
$summary .= '- [ ] ' . $item . "\n";
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
protected function getTitle(IL10N $l, string $roomName): string {
|
||||
try {
|
||||
$data = json_decode($roomName, true, flags: JSON_THROW_ON_ERROR);
|
||||
if (is_array($data) && count($data) === 2 && isset($data[0]) && is_string($data[0]) && isset($data[1]) && is_string($data[1])) {
|
||||
// Seems like the room name is a JSON map with the 2 user IDs of a 1-1 conversation,
|
||||
// so we don't add it to the title to avoid things like:
|
||||
// `Call summary - ["2991c735-4f9e-46e2-a107-7569dd19fdf8","42e6a9c2-a833-43f6-ab47-6b7004094912"]`
|
||||
return $l->t('Call summary');
|
||||
}
|
||||
} catch (\JsonException) {
|
||||
}
|
||||
|
||||
return str_replace('{title}', $roomName, $l->t('Call summary - {title}'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user