diff --git a/.github/workflows/integration-mariadb.yml b/.github/workflows/integration-mariadb.yml index a1bd12d2df8..f49e34173b7 100644 --- a/.github/workflows/integration-mariadb.yml +++ b/.github/workflows/integration-mariadb.yml @@ -56,7 +56,6 @@ jobs: php-versions: ['8.2'] server-versions: ['stable29'] guests-versions: ['stable29'] - call-summary-bot-versions: ['main'] notifications-versions: ['stable29'] services: @@ -86,13 +85,6 @@ jobs: with: path: apps/${{ env.APP_NAME }} - - name: Checkout call_summary_bot app - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - repository: nextcloud/call_summary_bot - path: apps/call_summary_bot - ref: ${{ matrix.call-summary-bot-versions }} - - name: Checkout guests app uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: @@ -140,7 +132,6 @@ jobs: ./occ config:system:set debug --value=true --type=boolean ./occ config:system:set hashing_default_password --value=true --type=boolean ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable --force call_summary_bot ./occ app:enable --force guests ./occ app:enable --force notifications diff --git a/.github/workflows/integration-mysql.yml b/.github/workflows/integration-mysql.yml index a05d0f1fdb8..637241ad07d 100644 --- a/.github/workflows/integration-mysql.yml +++ b/.github/workflows/integration-mysql.yml @@ -56,7 +56,6 @@ jobs: php-versions: ['8.2'] server-versions: ['stable29'] guests-versions: ['stable29'] - call-summary-bot-versions: ['main'] notifications-versions: ['stable29'] services: @@ -86,13 +85,6 @@ jobs: with: path: apps/${{ env.APP_NAME }} - - name: Checkout call_summary_bot app - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - repository: nextcloud/call_summary_bot - path: apps/call_summary_bot - ref: ${{ matrix.call-summary-bot-versions }} - - name: Checkout guests app uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: @@ -140,7 +132,6 @@ jobs: ./occ config:system:set debug --value=true --type=boolean ./occ config:system:set hashing_default_password --value=true --type=boolean ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable --force call_summary_bot ./occ app:enable --force guests ./occ app:enable --force notifications diff --git a/.github/workflows/integration-oci.yml b/.github/workflows/integration-oci.yml index 82bff3b022d..19be36f12e9 100644 --- a/.github/workflows/integration-oci.yml +++ b/.github/workflows/integration-oci.yml @@ -56,7 +56,6 @@ jobs: php-versions: ['8.2'] server-versions: ['stable29'] guests-versions: ['stable29'] - call-summary-bot-versions: ['main'] notifications-versions: ['stable29'] services: @@ -98,13 +97,6 @@ jobs: with: path: apps/${{ env.APP_NAME }} - - name: Checkout call_summary_bot app - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - repository: nextcloud/call_summary_bot - path: apps/call_summary_bot - ref: ${{ matrix.call-summary-bot-versions }} - - name: Checkout guests app uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: @@ -147,7 +139,6 @@ jobs: ./occ config:system:set debug --value=true --type=boolean ./occ config:system:set hashing_default_password --value=true --type=boolean ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable --force call_summary_bot ./occ app:enable --force guests ./occ app:enable --force notifications diff --git a/.github/workflows/integration-pgsql.yml b/.github/workflows/integration-pgsql.yml index 2734a77b02f..e4091def695 100644 --- a/.github/workflows/integration-pgsql.yml +++ b/.github/workflows/integration-pgsql.yml @@ -53,7 +53,6 @@ jobs: php-versions: ['8.3'] server-versions: ['stable29'] guests-versions: ['stable29'] - call-summary-bot-versions: ['main'] notifications-versions: ['stable29'] services: @@ -89,13 +88,6 @@ jobs: with: path: apps/${{ env.APP_NAME }} - - name: Checkout call_summary_bot app - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - repository: nextcloud/call_summary_bot - path: apps/call_summary_bot - ref: ${{ matrix.call-summary-bot-versions }} - - name: Checkout guests app uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: @@ -142,7 +134,6 @@ jobs: ./occ config:system:set memcache.local --value="\\OC\\Memcache\\APCu" ./occ config:system:set memcache.distributed --value="\\OC\\Memcache\\APCu" ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable --force call_summary_bot ./occ app:enable --force guests ./occ app:enable --force notifications diff --git a/.github/workflows/integration-sqlite.yml b/.github/workflows/integration-sqlite.yml index eeb99afd736..3e2002a6f14 100644 --- a/.github/workflows/integration-sqlite.yml +++ b/.github/workflows/integration-sqlite.yml @@ -56,7 +56,6 @@ jobs: php-versions: ['8.2'] server-versions: ['stable29'] guests-versions: ['stable29'] - call-summary-bot-versions: ['main'] notifications-versions: ['stable29'] steps: @@ -77,13 +76,6 @@ jobs: with: path: apps/${{ env.APP_NAME }} - - name: Checkout call_summary_bot app - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - repository: nextcloud/call_summary_bot - path: apps/call_summary_bot - ref: ${{ matrix.call-summary-bot-versions }} - - name: Checkout guests app uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: @@ -126,7 +118,6 @@ jobs: ./occ config:system:set debug --value=true --type=boolean ./occ config:system:set hashing_default_password --value=true --type=boolean ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable --force call_summary_bot ./occ app:enable --force guests ./occ app:enable --force notifications diff --git a/composer.lock b/composer.lock index 2ddbe57842d..36e5f017a86 100644 --- a/composer.lock +++ b/composer.lock @@ -258,12 +258,12 @@ "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "5d14cd5c2d92046ce268033a8e420f4acf054403" + "reference": "a0e83a946dbc06986094e7e9ccb68fcb05de473a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/5d14cd5c2d92046ce268033a8e420f4acf054403", - "reference": "5d14cd5c2d92046ce268033a8e420f4acf054403", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/a0e83a946dbc06986094e7e9ccb68fcb05de473a", + "reference": "a0e83a946dbc06986094e7e9ccb68fcb05de473a", "shasum": "" }, "require": { @@ -294,7 +294,7 @@ "issues": "https://github.com/nextcloud-deps/ocp/issues", "source": "https://github.com/nextcloud-deps/ocp/tree/stable29" }, - "time": "2025-01-08T00:43:58+00:00" + "time": "2025-01-21T00:42:15+00:00" }, { "name": "psr/clock", diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 58e62631689..2e37147ed8c 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -188,6 +188,7 @@ public function setUp() { self::$lastNotifications = []; self::$phoneNumberToActorId = []; self::$modifiedSince = []; + self::$botNameToId = []; $this->createdUsers = []; $this->createdGroups = []; diff --git a/tests/integration/features/chat-1/bots.feature b/tests/integration/features/chat-1/bots.feature index 2d784f32197..ba58d329919 100644 --- a/tests/integration/features/chat-1/bots.feature +++ b/tests/integration/features/chat-1/bots.feature @@ -2,32 +2,32 @@ Feature: chat/bots Background: Given user "participant1" exists - Scenario: Installing the call summary bot + Scenario: Installing the Webhook Demo bot Given invoking occ with "talk:bot:list" Then the command was successful And the command output is empty - Given invoking occ with "app:disable call_summary_bot" + Given invoking occ with "app:disable talk_webhook_demo" And the command was successful - And invoking occ with "app:enable call_summary_bot" + And invoking occ with "app:enable talk_webhook_demo" And the command was successful When invoking occ with "talk:bot:list" Then the command was successful - And the command output contains the text "Call summary" + And the command output contains the text "Webhook Demo" And read bot ids from OCC - And set state no-setup for bot "Call summary" via OCC + And set state no-setup for bot "Webhook Demo" via OCC | feature | | webhook | | response | - Scenario: Simple call summary bot run + Scenario: Simple Webhook Demo bot run # Populate default options again - And invoking occ with "app:disable call_summary_bot" + And invoking occ with "app:disable talk_webhook_demo" And the command was successful - And invoking occ with "app:enable call_summary_bot" + And invoking occ with "app:enable talk_webhook_demo" And the command was successful And invoking occ with "talk:bot:list" And the command was successful - And the command output contains the text "Call summary" + And the command output contains the text "Webhook Demo" # Set up in room Given invoking occ with "talk:bot:list room-name:room" @@ -37,13 +37,13 @@ Feature: chat/bots | roomType | 2 | | roomName | room | And read bot ids from OCC - And setup bot "Call summary" for room "room" via OCC + And setup bot "Webhook Demo" for room "room" via OCC Given invoking occ with "talk:bot:list room-name:room" And the command was successful - And the command output contains the text "Call summary" + And the command output contains the text "Webhook Demo" - # Call summary - Given the following call_summary_bot app config is set + # Webhook Demo + Given the following talk_webhook_demo app config is set | min-length | -1 | And user "participant1" sends message "- [ ] Before call" to room "room" with 201 And wait for 2 seconds @@ -52,15 +52,15 @@ Feature: chat/bots | flags | 1 | And user "participant1" sends message "- [ ] Task 1" to room "room" with 201 And user "participant1" sends message "- [ ] Task 2\n- [ ] Task 3" to room "room" with 201 - And set state enabled for bot "Call summary" via OCC + And set state enabled for bot "Webhook Demo" via OCC | feature | | webhook | And user "participant1" sends message "- [ ] Received but no reaction permission" to room "room" with 201 - And set state enabled for bot "Call summary" via OCC + And set state enabled for bot "Webhook Demo" via OCC | feature | | none | And user "participant1" sends message "- [ ] Not received due to permission" to room "room" with 201 - And set state enabled for bot "Call summary" via OCC + And set state enabled for bot "Webhook Demo" via OCC | feature | | webhook | | response | @@ -75,7 +75,7 @@ Feature: chat/bots Then user "participant1" leaves room "room" with 200 (v4) Then user "participant1" sees the following messages in room "room" with 200 | room | actorType | actorId | actorDisplayName | message | messageParameters | - | room | bots | BOT(Call summary) | Call summary (Bot) | # Call summary - room\n\n{DATE}\n\n## Attendees\n- participant1-displayname\n\n## Tasks\n- [ ] Task 1\n- [ ] Task 2\n- [ ] Task 3\n- [ ] Received but no reaction permission | [] | + | room | bots | BOT(Webhook Demo) | Webhook Demo (Bot) | # Call summary - room\n\n{DATE}\n\n## Attendees\n- participant1-displayname\n\n## Tasks\n- [ ] Task 1\n- [ ] Task 2\n- [ ] Task 3\n- [ ] Received but no reaction permission | [] | | room | users | participant1 | participant1-displayname | - [ ] Not received due to permission | [] | | room | users | participant1 | participant1-displayname | - [ ] Received but no reaction permission | [] | | room | users | participant1 | participant1-displayname | - [ ] Task 2\n- [ ] Task 3 | [] | @@ -85,10 +85,10 @@ Feature: chat/bots | actorType | actorId | actorDisplayName | reaction | Then user "participant1" retrieve reactions "๐Ÿ‘" of message "- [ ] Task 1" in room "room" with 200 | actorType | actorId | actorDisplayName | reaction | - | bots | BOT(Call summary) | Call summary (Bot) | ๐Ÿ‘ | + | bots | BOT(Webhook Demo) | Webhook Demo (Bot) | ๐Ÿ‘ | Then user "participant1" retrieve reactions "๐Ÿ‘" of message "- [ ] Task 2\n- [ ] Task 3" in room "room" with 200 | actorType | actorId | actorDisplayName | reaction | - | bots | BOT(Call summary) | Call summary (Bot) | ๐Ÿ‘ | + | bots | BOT(Webhook Demo) | Webhook Demo (Bot) | ๐Ÿ‘ | Then user "participant1" retrieve reactions "๐Ÿ‘" of message "- [ ] Received but no reaction permission" in room "room" with 200 | actorType | actorId | actorDisplayName | reaction | Then user "participant1" retrieve reactions "๐Ÿ‘" of message "- [ ] Not received due to permission" in room "room" with 200 @@ -96,36 +96,36 @@ Feature: chat/bots # Different states bot # Already enabled - And user "participant1" sets up bot "Call summary" for room "room" with 200 (v1) + And user "participant1" sets up bot "Webhook Demo" for room "room" with 200 (v1) Given invoking occ with "talk:bot:list room-name:room" And the command was successful - And the command output contains the text "Call summary" + And the command output contains the text "Webhook Demo" # Disabling - And user "participant1" removes bot "Call summary" for room "room" with 200 (v1) + And user "participant1" removes bot "Webhook Demo" for room "room" with 200 (v1) Given invoking occ with "talk:bot:list room-name:room" And the command was successful And the command output is empty # Enabling - And user "participant1" sets up bot "Call summary" for room "room" with 201 (v1) + And user "participant1" sets up bot "Webhook Demo" for room "room" with 201 (v1) Given invoking occ with "talk:bot:list room-name:room" And the command was successful - And the command output contains the text "Call summary" + And the command output contains the text "Webhook Demo" # No-setup - And set state no-setup for bot "Call summary" via OCC + And set state no-setup for bot "Webhook Demo" via OCC ## Failed removing - And user "participant1" removes bot "Call summary" for room "room" with 400 (v1) + And user "participant1" removes bot "Webhook Demo" for room "room" with 400 (v1) Given invoking occ with "talk:bot:list room-name:room" And the command was successful - And the command output contains the text "Call summary" + And the command output contains the text "Webhook Demo" ## Failed adding - And remove bot "Call summary" for room "room" via OCC + And remove bot "Webhook Demo" for room "room" via OCC Given invoking occ with "talk:bot:list room-name:room" And the command was successful And the command output is empty - And user "participant1" sets up bot "Call summary" for room "room" with 400 (v1) + And user "participant1" sets up bot "Webhook Demo" for room "room" with 400 (v1) Given invoking occ with "talk:bot:list room-name:room" And the command was successful And the command output is empty @@ -356,4 +356,3 @@ Feature: chat/bots When invoking occ with "talk:bot:uninstall --url=https://example.tld" Then the command failed with exit code 1 And the command output contains the text "Bot not found" - diff --git a/tests/integration/run.sh b/tests/integration/run.sh index d17538127cc..b27729619e3 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -5,7 +5,6 @@ PROCESS_ID=$$ APP_NAME=spreed NOTIFICATIONS_BRANCH="master" GUESTS_BRANCH="master" -CSB_BRANCH="main" APP_INTEGRATION_DIR=$PWD ROOT_DIR=${APP_INTEGRATION_DIR}/../../../.. @@ -63,23 +62,24 @@ echo -e "\033[0;36m# Setting up apps\033[0m" echo -e "\033[0;36m#\033[0m" cp -R ./spreedcheats ../../../spreedcheats ${ROOT_DIR}/occ app:getpath spreedcheats +cp -R ./talk_webhook_demo ../../../talk_webhook_demo +${ROOT_DIR}/occ app:getpath talk_webhook_demo # Add apps to the parent directory of "spreed" (unless they are # already there or in "apps"). ${ROOT_DIR}/occ app:getpath notifications || (cd ../../../ && git clone --depth 1 --branch ${NOTIFICATIONS_BRANCH} https://github.com/nextcloud/notifications) ${ROOT_DIR}/occ app:getpath guests || (cd ../../../ && git clone --depth 1 --branch ${GUESTS_BRANCH} https://github.com/nextcloud/guests) -${ROOT_DIR}/occ app:getpath call_summary_bot || (cd ../../../ && git clone --depth 1 --branch ${CSB_BRANCH} https://github.com/nextcloud/call_summary_bot) ${ROOT_DIR}/occ app:enable spreed || exit 1 ${ROOT_DIR}/occ app:enable --force spreedcheats || exit 1 +${ROOT_DIR}/occ app:enable --force talk_webhook_demo || exit 1 ${ROOT_DIR}/occ app:enable --force notifications || exit 1 ${ROOT_DIR}/occ app:enable --force guests || exit 1 -${ROOT_DIR}/occ app:enable --force call_summary_bot || exit 1 ${ROOT_DIR}/occ app:list | grep spreed +${ROOT_DIR}/occ app:list | grep talk_webhook_demo ${ROOT_DIR}/occ app:list | grep notifications ${ROOT_DIR}/occ app:list | grep guests -${ROOT_DIR}/occ app:list | grep call_summary_bot echo '' echo -e "\033[0;36m#\033[0m" @@ -125,11 +125,13 @@ echo -e "\033[0;36m#\033[0m" echo -e "\033[0;36m# Reverting configuration changes and disabling spreedcheats\033[0m" echo -e "\033[0;36m#\033[0m" ${ROOT_DIR}/occ app:disable spreedcheats +${ROOT_DIR}/occ app:disable talk_webhook_demo ${ROOT_DIR}/occ config:system:set overwrite.cli.url --value $OVERWRITE_CLI_URL if [[ "$SKELETON_DIR" ]]; then ${ROOT_DIR}/occ config:system:set skeletondirectory --value "$SKELETON_DIR" fi rm -rf ../../../spreedcheats +rm -rf ../../../talk_webhook_demo wait $PHPPID1 wait $PHPPID2 diff --git a/tests/integration/talk_webhook_demo/appinfo/info.xml b/tests/integration/talk_webhook_demo/appinfo/info.xml new file mode 100644 index 00000000000..74add6ce48f --- /dev/null +++ b/tests/integration/talk_webhook_demo/appinfo/info.xml @@ -0,0 +1,33 @@ + + + + talk_webhook_demo + Talk Webhook demo + + + + 1.0.0 + agpl + + Joas Schilling + TalkWebhookDemo + workflow + https://github.com/nextcloud/spreed/issues + + + + + + + + OCA\TalkWebhookDemo\Migration\InstallBot + + + OCA\TalkWebhookDemo\Migration\UninstallBot + + + diff --git a/tests/integration/talk_webhook_demo/appinfo/routes.php b/tests/integration/talk_webhook_demo/appinfo/routes.php new file mode 100644 index 00000000000..2698efe9c40 --- /dev/null +++ b/tests/integration/talk_webhook_demo/appinfo/routes.php @@ -0,0 +1,14 @@ + [ + /** @see \OCA\TalkWebhookDemo\Controller\BotController::receiveWebhook() */ + ['name' => 'Bot#receiveWebhook', 'url' => '/api/v1/bot/{lang}', 'verb' => 'POST'], + ], +]; diff --git a/tests/integration/talk_webhook_demo/lib/Controller/BotController.php b/tests/integration/talk_webhook_demo/lib/Controller/BotController.php new file mode 100644 index 00000000000..ead15fd9a0a --- /dev/null +++ b/tests/integration/talk_webhook_demo/lib/Controller/BotController.php @@ -0,0 +1,304 @@ +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 (!$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()); + } + } +} diff --git a/tests/integration/talk_webhook_demo/lib/Migration/InstallBot.php b/tests/integration/talk_webhook_demo/lib/Migration/InstallBot.php new file mode 100644 index 00000000000..9621b2b30ef --- /dev/null +++ b/tests/integration/talk_webhook_demo/lib/Migration/InstallBot.php @@ -0,0 +1,38 @@ +warning('Talk not found, not installing bots'); + return; + } + + $backend = $this->url->getAbsoluteURL(''); + $this->service->installBot($backend); + } +} diff --git a/tests/integration/talk_webhook_demo/lib/Migration/UninstallBot.php b/tests/integration/talk_webhook_demo/lib/Migration/UninstallBot.php new file mode 100644 index 00000000000..db4f01fbe29 --- /dev/null +++ b/tests/integration/talk_webhook_demo/lib/Migration/UninstallBot.php @@ -0,0 +1,48 @@ +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); + } + } + } +} diff --git a/tests/integration/talk_webhook_demo/lib/Migration/Version1000Date20230719061613.php b/tests/integration/talk_webhook_demo/lib/Migration/Version1000Date20230719061613.php new file mode 100644 index 00000000000..40d13681e75 --- /dev/null +++ b/tests/integration/talk_webhook_demo/lib/Migration/Version1000Date20230719061613.php @@ -0,0 +1,76 @@ +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 { + } +} diff --git a/tests/integration/talk_webhook_demo/lib/Model/Bot.php b/tests/integration/talk_webhook_demo/lib/Model/Bot.php new file mode 100644 index 00000000000..adee141172c --- /dev/null +++ b/tests/integration/talk_webhook_demo/lib/Model/Bot.php @@ -0,0 +1,23 @@ +addType('server', 'string'); + $this->addType('token', 'string'); + $this->addType('type', 'string'); + $this->addType('details', 'string'); + } +} diff --git a/tests/integration/talk_webhook_demo/lib/Model/LogEntryMapper.php b/tests/integration/talk_webhook_demo/lib/Model/LogEntryMapper.php new file mode 100644 index 00000000000..ddcbe517f4b --- /dev/null +++ b/tests/integration/talk_webhook_demo/lib/Model/LogEntryMapper.php @@ -0,0 +1,60 @@ + findEntities(IQueryBuilder $query) + * @template-extends QBMapper + */ +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(); + } +} diff --git a/tests/integration/talk_webhook_demo/lib/Service/BotService.php b/tests/integration/talk_webhook_demo/lib/Service/BotService.php new file mode 100644 index 00000000000..c4cef05cd42 --- /dev/null +++ b/tests/integration/talk_webhook_demo/lib/Service/BotService.php @@ -0,0 +1,101 @@ +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) { + } + } +} diff --git a/tests/integration/talk_webhook_demo/lib/Service/SummaryService.php b/tests/integration/talk_webhook_demo/lib/Service/SummaryService.php new file mode 100644 index 00000000000..6b556e36377 --- /dev/null +++ b/tests/integration/talk_webhook_demo/lib/Service/SummaryService.php @@ -0,0 +1,319 @@ + $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}')); + } +}