From 89d44777c0897378745b811fb672597f9be12855 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Sat, 26 Aug 2023 14:51:25 +0300 Subject: [PATCH] Talk Bot API (#97) Full Talk Bot API --------- Signed-off-by: Alexander Piskun --- .github/workflows/analysis-coverage.yml | 62 +++-- .run/TalkBot.run.xml | 29 ++ CHANGELOG.md | 6 + README.md | 27 +- docs/NextcloudTalkBot.rst | 4 + docs/conf.py | 1 + docs/index.rst | 1 + docs/reference/Talk.rst | 11 + docs/reference/TalkBot.rst | 17 ++ docs/reference/index.rst | 1 + examples/as_app/skeleton/Dockerfile | 2 +- examples/as_app/talk_bot/Dockerfile | 10 + examples/as_app/talk_bot/Makefile | 49 ++++ examples/as_app/talk_bot/appinfo/info.xml | 38 +++ examples/as_app/talk_bot/requirements.txt | 1 + examples/as_app/talk_bot/src/main.py | 78 ++++++ examples/as_app/to_gif/Dockerfile | 2 +- nc_py_api/_misc.py | 6 +- nc_py_api/_version.py | 2 +- nc_py_api/ex_app/__init__.py | 2 +- nc_py_api/ex_app/defs.py | 2 + nc_py_api/ex_app/integration_fastapi.py | 35 ++- nc_py_api/nextcloud.py | 40 ++- nc_py_api/talk.py | 312 +++++++++++++++++++++- nc_py_api/talk_bot.py | 215 +++++++++++++++ scripts/dev_register.sh | 2 +- tests/_talk_bot.py | 57 ++++ tests/_tests_at_the_end.py | 33 +++ tests/gfixture.py | 11 +- tests/gfixture_set_env.py | 10 + tests/talk_test.py | 111 +++++++- tests/zz_last_test.py | 37 --- 32 files changed, 1111 insertions(+), 103 deletions(-) create mode 100644 .run/TalkBot.run.xml create mode 100644 docs/NextcloudTalkBot.rst create mode 100644 docs/reference/TalkBot.rst create mode 100644 examples/as_app/talk_bot/Dockerfile create mode 100644 examples/as_app/talk_bot/Makefile create mode 100644 examples/as_app/talk_bot/appinfo/info.xml create mode 100644 examples/as_app/talk_bot/requirements.txt create mode 100644 examples/as_app/talk_bot/src/main.py create mode 100644 nc_py_api/talk_bot.py create mode 100644 tests/_talk_bot.py create mode 100644 tests/_tests_at_the_end.py create mode 100644 tests/gfixture_set_env.py delete mode 100644 tests/zz_last_test.py diff --git a/.github/workflows/analysis-coverage.yml b/.github/workflows/analysis-coverage.yml index e2b07534..c0af78e3 100644 --- a/.github/workflows/analysis-coverage.yml +++ b/.github/workflows/analysis-coverage.yml @@ -106,7 +106,6 @@ jobs: --admin-user admin --admin-pass ${{ env.NC_AUTH_PASS }} php occ config:system:set loglevel --value=1 --type=integer php occ config:system:set debug --value=true --type=boolean - php occ config:system:set allow_local_remote_servers --value true php -S localhost:8080 & - name: Checkout NcPyApi @@ -136,7 +135,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\", \"TALK_BOT\"]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -160,7 +159,14 @@ jobs: - name: Generate coverage report working-directory: nc_py_api - run: coverage run --data-file=.coverage.ci -m pytest && coverage combine && coverage xml && coverage html + run: | + coverage run --data-file=.coverage.talk_bot tests/_talk_bot.py & + echo $! > /tmp/_talk_bot.pid + coverage run --data-file=.coverage.ci -m pytest + kill -15 $(cat /tmp/_talk_bot.pid) + timeout 3m tail --pid=$(cat /tmp/_talk_bot.pid) -f /dev/null + coverage run --data-file=.coverage.at_the_end -m pytest tests/_tests_at_the_end.py + coverage combine && coverage xml && coverage html - name: HTML coverage to artifacts uses: actions/upload-artifact@v3 @@ -258,7 +264,6 @@ jobs: --admin-user admin --admin-pass ${{ env.NC_AUTH_PASS }} php occ config:system:set loglevel --value=1 php occ config:system:set debug --value=true --type=boolean - php occ config:system:set allow_local_remote_servers --value true php -S localhost:8080 & - name: Checkout NcPyApi @@ -290,7 +295,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\", \"TALK_BOT\"]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -314,7 +319,14 @@ jobs: - name: Generate coverage report working-directory: nc_py_api - run: coverage run --data-file=.coverage.ci -m pytest && coverage combine && coverage xml && coverage html + run: | + coverage run --data-file=.coverage.talk_bot tests/_talk_bot.py & + echo $! > /tmp/_talk_bot.pid + coverage run --data-file=.coverage.ci -m pytest + kill -15 $(cat /tmp/_talk_bot.pid) + timeout 3m tail --pid=$(cat /tmp/_talk_bot.pid) -f /dev/null + coverage run --data-file=.coverage.at_the_end -m pytest tests/_tests_at_the_end.py + coverage combine && coverage xml && coverage html - name: HTML coverage to artifacts uses: actions/upload-artifact@v3 @@ -399,7 +411,6 @@ jobs: --admin-user admin --admin-pass ${{ env.NC_AUTH_PASS }} php occ config:system:set loglevel --value=1 --type=integer php occ config:system:set debug --value=true --type=boolean - php occ config:system:set allow_local_remote_servers --value true php -S localhost:8080 & - name: Checkout NcPyApi @@ -429,14 +440,17 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\", \"TALK_BOT\"]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null - name: Generate coverage report working-directory: nc_py_api - run: coverage run --data-file=.coverage.ci -m pytest && coverage combine && coverage xml && coverage html + run: | + coverage run --data-file=.coverage.ci -m pytest + coverage run --data-file=.coverage.at_the_end -m pytest tests/_tests_at_the_end.py + coverage combine && coverage xml && coverage html env: SKIP_NC_CLIENT_TESTS: 1 @@ -519,7 +533,6 @@ jobs: --admin-user admin --admin-pass ${{ env.NC_AUTH_PASS }} ./occ config:system:set loglevel --value=0 --type=integer ./occ config:system:set debug --value=true --type=boolean - ./occ config:system:set allow_local_remote_servers --value true ./occ app:enable notifications php -S localhost:8080 & @@ -549,7 +562,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\", \"TALK_BOT\"]},\"port\":$APP_PORT,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -571,7 +584,14 @@ jobs: - name: Generate coverage report working-directory: nc_py_api - run: coverage run --data-file=.coverage.ci -m pytest && coverage combine && coverage xml && coverage html + run: | + coverage run --data-file=.coverage.talk_bot tests/_talk_bot.py & + echo $! > /tmp/_talk_bot.pid + coverage run --data-file=.coverage.ci -m pytest + kill -15 $(cat /tmp/_talk_bot.pid) + timeout 3m tail --pid=$(cat /tmp/_talk_bot.pid) -f /dev/null + coverage run --data-file=.coverage.at_the_end -m pytest tests/_tests_at_the_end.py + coverage combine && coverage xml && coverage html - name: HTML coverage to artifacts uses: actions/upload-artifact@v3 @@ -655,7 +675,6 @@ jobs: --admin-user admin --admin-pass ${{ env.NC_AUTH_PASS }} ./occ config:system:set loglevel --value=0 --type=integer ./occ config:system:set debug --value=true --type=boolean - ./occ config:system:set allow_local_remote_servers --value true ./occ app:enable notifications php -S localhost:8080 & @@ -685,7 +704,10 @@ jobs: - name: Generate coverage report working-directory: nc_py_api - run: coverage run -m pytest && coverage xml && coverage html + run: | + coverage run -m pytest + coverage run --data-file=.coverage.at_the_end -m pytest tests/_tests_at_the_end.py + coverage combine && coverage xml && coverage html env: SKIP_AE_TESTS: 1 NPA_NC_CERT: '' @@ -761,7 +783,6 @@ jobs: --admin-user admin --admin-pass ${{ env.NC_AUTH_PASS }} ./occ config:system:set loglevel --value=0 --type=integer ./occ config:system:set debug --value=true --type=boolean - ./occ config:system:set allow_local_remote_servers --value true ./occ app:enable notifications php -S localhost:8080 & @@ -795,7 +816,7 @@ jobs: cd .. php occ app_ecosystem_v2:daemon:register manual_install "Manual Install" manual-install 0 0 0 php occ app_ecosystem_v2:app:register $APP_ID manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"]},\"protocol\":\"http\",\"port\":$APP_PORT,\"system_app\":1}" \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\", \"TALK_BOT\"]},\"protocol\":\"http\",\"port\":$APP_PORT,\"system_app\":1}" \ -e --force-scopes kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -818,7 +839,14 @@ jobs: - name: Generate coverage report working-directory: nc_py_api - run: coverage run --data-file=.coverage.ci -m pytest && coverage combine && coverage xml && coverage html + run: | + coverage run --data-file=.coverage.talk_bot tests/_talk_bot.py & + echo $! > /tmp/_talk_bot.pid + coverage run --data-file=.coverage.ci -m pytest + kill -15 $(cat /tmp/_talk_bot.pid) + timeout 3m tail --pid=$(cat /tmp/_talk_bot.pid) -f /dev/null + coverage run --data-file=.coverage.at_the_end -m pytest tests/_tests_at_the_end.py + coverage combine && coverage xml && coverage html env: NPA_TIMEOUT: None NPA_TIMEOUT_DAV: None diff --git a/.run/TalkBot.run.xml b/.run/TalkBot.run.xml new file mode 100644 index 00000000..28e9eba1 --- /dev/null +++ b/.run/TalkBot.run.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index c371a9af..f8bd0bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.0.41 - 2023-08-25] + +### Added + +- Nextcloud Talk API for bots + example + ## [0.0.40 - 2023-08-22] ### Added diff --git a/README.md b/README.md index c2ba7443..1688d7cc 100644 --- a/README.md +++ b/README.md @@ -23,23 +23,25 @@ Python library that provides a robust and well-documented API that allows develo * **Easy**: Designed to be easy to use with excellent documentation. ### Capabilities -| **_Capability_** | Nextcloud 26 | Nextcloud 27 | Nextcloud 28 | -|------------------|:------------:|:------------:|:------------:| -| Filesystem* | ✅ | ✅ | ✅ | -| Shares | ✅ | ✅ | ✅ | -| Users & Groups | ✅ | ✅ | ✅ | -| User status | ✅ | ✅ | ✅ | -| Weather status | ✅ | ✅ | ✅ | -| Notifications | ✅ | ✅ | ✅ | -| Nextcloud Talk | ❌ | ❌ | ❌ | -| Text Provider** | ❌ | ❌ | ❌ | +| **_Capability_** | Nextcloud 26 | Nextcloud 27 | Nextcloud 28 | +|-------------------|:------------:|:------------:|:------------:| +| Filesystem* | ✅ | ✅ | ✅ | +| Shares | ✅ | ✅ | ✅ | +| Users & Groups | ✅ | ✅ | ✅ | +| User status | ✅ | ✅ | ✅ | +| Weather status | ✅ | ✅ | ✅ | +| Notifications | ✅ | ✅ | ✅ | +| Nextcloud Talk | ❌ | ❌ | ❌ | +| Talk Bot API** | N/A | ✅ | ✅ | +| Text Processing** | N/A | ❌ | ❌ | +| SpeechToText** | N/A | ❌ | ❌ | *missing `Trash bin` and `File version` support.
**available only for NextcloudApp ### Differences between the Nextcloud and NextcloudApp classes -The **Nextcloud** class functions as a standard NextCloud client, +The **Nextcloud** class functions as a standard Nextcloud client, enabling you to make API requests using a username and password. On the other hand, the **NextcloudApp** class is designed for creating applications for Nextcloud.
@@ -66,7 +68,8 @@ You can support us in several ways: - [Documentation](https://cloud-py-api.github.io/nc_py_api/) - [First steps](https://cloud-py-api.github.io/nc_py_api/FirstSteps.html) - [More APIs](https://cloud-py-api.github.io/nc_py_api/MoreAPIs.html) - - [Writing a simple Nextcloud application](https://cloud-py-api.github.io/nc_py_api/NextcloudApp.html) + - [Writing a simple Nextcloud Application](https://cloud-py-api.github.io/nc_py_api/NextcloudApp.html) + - [Using Nextcloud Talk Bot API in Application](https://cloud-py-api.github.io/nc_py_api/NextcloudTalkBot.html) - [Writing a Nextcloud System Application](https://cloud-py-api.github.io/nc_py_api/NextcloudSysApp.html) - [Examples](https://github.com/cloud-py-api/nc_py_api/tree/main/examples) - [Contribute](https://github.com/cloud-py-api/nc_py_api/blob/main/.github/CONTRIBUTING.md) diff --git a/docs/NextcloudTalkBot.rst b/docs/NextcloudTalkBot.rst new file mode 100644 index 00000000..6112e3b1 --- /dev/null +++ b/docs/NextcloudTalkBot.rst @@ -0,0 +1,4 @@ +Nextcloud Talk Bot API in Application +===================================== + +to-do diff --git a/docs/conf.py b/docs/conf.py index c29c1ac6..642ffcaf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,6 +51,7 @@ html_theme_options = { "display_version": True, + "logo_only": True, } # If true, `todos` produce output. Else they produce nothing. diff --git a/docs/index.rst b/docs/index.rst index 0e8e455d..879eae05 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,6 +31,7 @@ Have a great time with Python and Nextcloud! FirstSteps MoreAPIs NextcloudApp + NextcloudTalkBot NextcloudSysApp Options reference/index.rst diff --git a/docs/reference/Talk.rst b/docs/reference/Talk.rst index a3fa4604..b24593fa 100644 --- a/docs/reference/Talk.rst +++ b/docs/reference/Talk.rst @@ -5,6 +5,10 @@ Talk API :members: :inherited-members: +.. autoclass:: nc_py_api.talk.TalkMessage + :members: + :inherited-members: + .. autoclass:: nc_py_api.talk._TalkAPI :members: @@ -48,3 +52,10 @@ Talk API .. autoclass:: nc_py_api.talk.BreakoutRoomStatus :members: :undoc-members: + +.. autoclass:: nc_py_api.talk.BotInfo + :members: + :inherited-members: + +.. autoclass:: nc_py_api.talk.BotInfoBasic + :members: diff --git a/docs/reference/TalkBot.rst b/docs/reference/TalkBot.rst new file mode 100644 index 00000000..e5a9d0e7 --- /dev/null +++ b/docs/reference/TalkBot.rst @@ -0,0 +1,17 @@ +.. py:currentmodule:: nc_py_api + +Talk Bot API +------------ + +.. autoclass:: nc_py_api.talk_bot.TalkBotMessage + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk_bot.TalkBot + :members: + +.. autoclass:: nc_py_api.talk_bot.ObjectContent + :members: + :undoc-members: + +.. autofunction:: nc_py_api.talk_bot.get_bot_secret diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 44510f11..d97bc4ed 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -11,4 +11,5 @@ Reference ExApp Exceptions Talk + TalkBot Session diff --git a/examples/as_app/skeleton/Dockerfile b/examples/as_app/skeleton/Dockerfile index e4890205..53f2f95e 100644 --- a/examples/as_app/skeleton/Dockerfile +++ b/examples/as_app/skeleton/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.4-slim-bookworm +FROM python:3.11-slim-bookworm COPY requirements.txt / ADD /src/ /app/ diff --git a/examples/as_app/talk_bot/Dockerfile b/examples/as_app/talk_bot/Dockerfile new file mode 100644 index 00000000..e400717e --- /dev/null +++ b/examples/as_app/talk_bot/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-alpine + +COPY requirements.txt / +ADD /src/ /app/ + +RUN \ + python3 -m pip install -r requirements.txt && rm -rf ~/.cache && rm requirements.txt + +WORKDIR /app +ENTRYPOINT ["python3", "main.py"] diff --git a/examples/as_app/talk_bot/Makefile b/examples/as_app/talk_bot/Makefile new file mode 100644 index 00000000..a6e93517 --- /dev/null +++ b/examples/as_app/talk_bot/Makefile @@ -0,0 +1,49 @@ +.DEFAULT_GOAL := help + +.PHONY: help +help: + @echo "Welcome to TalkBot example. Please use \`make \` where is one of" + @echo " " + @echo " Next commands are only for dev environment with nextcloud-docker-dev!" + @echo " They should run from the host you are developing on(with activated venv) and not in the container with Nextcloud!" + @echo " " + @echo " build-push build image and upload to ghcr.io" + @echo " " + @echo " deploy deploy example to registered 'docker_dev'" + @echo " " + @echo " run28 install ToGif for Nextcloud 28" + @echo " run27 install ToGif for Nextcloud 27" + @echo " " + @echo " For development of this example use PyCharm run configurations. Development is always set for last Nextcloud." + @echo " First run 'TalkBot' and then 'make manual_register', after that you can use/debug/develop it and easy test." + @echo " " + @echo " manual_register perform registration of running 'TalkBot' into the 'manual_install' deploy daemon." + +.PHONY: build-push +build-push: + docker login ghcr.io + docker buildx build --push --platform linux/arm64/v8,linux/amd64 --tag ghcr.io/cloud-py-api/talk_bot:latest . + +.PHONY: deploy +deploy: + docker exec master-nextcloud-1 sudo -u www-data php occ app_ecosystem_v2:app:deploy talk_bot docker_dev \ + --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot/appinfo/info.xml + +.PHONY: run28 +run28: + docker exec master-nextcloud-1 sudo -u www-data php occ app_ecosystem_v2:app:unregister talk_bot --silent || true + docker exec master-nextcloud-1 sudo -u www-data php occ app_ecosystem_v2:app:register talk_bot docker_dev -e --force-scopes \ + --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot/appinfo/info.xml + +.PHONY: run27 +run27: + docker exec master-stable27-1 sudo -u www-data php occ app_ecosystem_v2:app:unregister talk_bot --silent || true + docker exec master-nextcloud-1 sudo -u www-data php occ app_ecosystem_v2:app:register talk_bot docker_dev -e --force-scopes \ + --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot/appinfo/info.xml + +.PHONY: manual_register +manual_register: + docker exec master-nextcloud-1 sudo -u www-data php occ app_ecosystem_v2:app:unregister talk_bot --silent || true + docker exec master-nextcloud-1 sudo -u www-data php occ app_ecosystem_v2:app:register talk_bot manual_install --json-info \ + "{\"appid\":\"talk_bot\",\"name\":\"TalkBot\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"host\":\"host.docker.internal\",\"port\":9032,\"scopes\":{\"required\":[\"TALK\", \"TALK_BOT\"],\"optional\":[]},\"protocol\":\"http\",\"system_app\":0}" \ + -e --force-scopes diff --git a/examples/as_app/talk_bot/appinfo/info.xml b/examples/as_app/talk_bot/appinfo/info.xml new file mode 100644 index 00000000..f7f6f659 --- /dev/null +++ b/examples/as_app/talk_bot/appinfo/info.xml @@ -0,0 +1,38 @@ + + + talk_bot + TalkBot + Nextcloud TalkBot Example + + + + 1.0.0 + MIT + Andrey Borysenko + Alexander Piskun + TalkBotExample + tools + https://github.com/cloud-py-api/nc_py_api + https://github.com/cloud-py-api/nc_py_api/issues + https://github.com/cloud-py-api/nc_py_api + + + + + + ghcr.io + cloud-py-api/talk_bot + latest + + + + TALK + TALK_BOT + + + + + http + 0 + + diff --git a/examples/as_app/talk_bot/requirements.txt b/examples/as_app/talk_bot/requirements.txt new file mode 100644 index 00000000..c3610034 --- /dev/null +++ b/examples/as_app/talk_bot/requirements.txt @@ -0,0 +1 @@ +nc_py_api[app]>=0.0.41 diff --git a/examples/as_app/talk_bot/src/main.py b/examples/as_app/talk_bot/src/main.py new file mode 100644 index 00000000..693a5fe2 --- /dev/null +++ b/examples/as_app/talk_bot/src/main.py @@ -0,0 +1,78 @@ +"""Example of an application(currency convertor) that uses Talk Bot APIs.""" + +import re +from typing import Annotated + +import requests +from fastapi import BackgroundTasks, Depends, FastAPI + +from nc_py_api import NextcloudApp, talk_bot +from nc_py_api.ex_app import run_app, set_handlers, talk_bot_app + +APP = FastAPI() +CURRENCY_BOT = talk_bot.TalkBot("/currency_talk_bot", "Currency convertor", "Usage: `@currency convert 100 EUR to USD`") + + +def convert_currency(amount, from_currency, to_currency): + base_url = "https://api.exchangerate-api.com/v4/latest/" + + # Fetch latest exchange rates + response = requests.get(base_url + from_currency) + data = response.json() + + if "rates" in data: + rates = data["rates"] + if from_currency == to_currency: + return amount + + if from_currency in rates and to_currency in rates: + conversion_rate = rates[to_currency] / rates[from_currency] + converted_amount = amount * conversion_rate + return converted_amount + else: + raise ValueError("Invalid currency!") + else: + raise ValueError("Unable to fetch exchange rates!") + + +def currency_talk_bot_process_request(message: talk_bot.TalkBotMessage): + try: + if message.object_name != "message": + return + r = re.search( + r"@currency\s(convert\s)?(\d*)\s(\w*)\sto\s(\w*)", message.object_content["message"], re.IGNORECASE + ) + if r is None: + return + converted_amount = convert_currency(int(r.group(2)), r.group(3), r.group(4)) + converted_amount = round(converted_amount, 2) + CURRENCY_BOT.send_message(f"{r.group(2)} {r.group(3)} is equal to {converted_amount} {r.group(4)}", message) + except Exception as e: + CURRENCY_BOT.send_message(f"Exception: {str(e)}", message) + + +@APP.post("/currency_talk_bot") +async def currency_talk_bot( + message: Annotated[talk_bot.TalkBotMessage, Depends(talk_bot_app)], + background_tasks: BackgroundTasks, +): + background_tasks.add_task(currency_talk_bot_process_request, message) + return requests.Response() + + +def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: + print(f"enabled={enabled}") + try: + CURRENCY_BOT.enabled_handler(enabled, nc) + except Exception as e: + return str(e) + return "" + + +@APP.on_event("startup") +def initialization(): + set_handlers(APP, enabled_handler) + + +if __name__ == "__main__": + run_app("main:APP", log_level="trace") diff --git a/examples/as_app/to_gif/Dockerfile b/examples/as_app/to_gif/Dockerfile index b3935935..d45d39b5 100644 --- a/examples/as_app/to_gif/Dockerfile +++ b/examples/as_app/to_gif/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.4-bookworm +FROM python:3.11-bookworm COPY requirements.txt / ADD /src/ /app/ diff --git a/nc_py_api/_misc.py b/nc_py_api/_misc.py index e5fd5f86..8b4d36fc 100644 --- a/nc_py_api/_misc.py +++ b/nc_py_api/_misc.py @@ -39,7 +39,11 @@ def __check_sub_capability(split_capabilities: list[str], srv_capabilities: dict capabilities_nesting = srv_capabilities for i, v in enumerate(split_capabilities): if i != 0 and i == n_split_capabilities - 1: - return bool(capabilities_nesting.get(v, False)) + return ( + bool(capabilities_nesting.get(v, False)) + if isinstance(capabilities_nesting, dict) + else bool(v in capabilities_nesting) + ) if v not in capabilities_nesting: return False capabilities_nesting = capabilities_nesting[v] diff --git a/nc_py_api/_version.py b/nc_py_api/_version.py index f44555f8..c310035f 100644 --- a/nc_py_api/_version.py +++ b/nc_py_api/_version.py @@ -1,3 +1,3 @@ """Version of nc_py_api.""" -__version__ = "0.0.40" +__version__ = "0.0.41.dev" diff --git a/nc_py_api/ex_app/__init__.py b/nc_py_api/ex_app/__init__.py index 0b174c56..0476bd8f 100644 --- a/nc_py_api/ex_app/__init__.py +++ b/nc_py_api/ex_app/__init__.py @@ -1,5 +1,5 @@ """All possible ExApp stuff for NextcloudApp that can be used.""" from .defs import ApiScope, LogLvl -from .integration_fastapi import nc_app, set_handlers +from .integration_fastapi import nc_app, set_handlers, talk_bot_app from .ui.files import UiActionFileInfo, UiFileActionHandlerInfo from .uvicorn_fastapi import run_app diff --git a/nc_py_api/ex_app/defs.py b/nc_py_api/ex_app/defs.py index b94cb220..5dfd5210 100644 --- a/nc_py_api/ex_app/defs.py +++ b/nc_py_api/ex_app/defs.py @@ -37,3 +37,5 @@ class ApiScope(enum.IntEnum): """Allows access to APIs that provide Weather status.""" TALK = 50 """Allows access to Talk API endpoints.""" + TALK_BOT = 60 + """Allows to register Talk Bots.""" diff --git a/nc_py_api/ex_app/integration_fastapi.py b/nc_py_api/ex_app/integration_fastapi.py index dccdc259..2d52f433 100644 --- a/nc_py_api/ex_app/integration_fastapi.py +++ b/nc_py_api/ex_app/integration_fastapi.py @@ -1,11 +1,15 @@ """FastAPI directly related stuff.""" -from typing import Annotated, Callable, Optional +import asyncio +import hashlib +import hmac +import json +import typing -from fastapi import Depends, FastAPI, HTTPException, Request, status -from fastapi.responses import JSONResponse +from fastapi import Depends, FastAPI, HTTPException, Request, responses, status from ..nextcloud import NextcloudApp +from ..talk_bot import TalkBotMessage, get_bot_secret def nc_app(request: Request) -> NextcloudApp: @@ -19,10 +23,25 @@ def nc_app(request: Request) -> NextcloudApp: return nextcloud_app +def talk_bot_app(request: Request) -> TalkBotMessage: + """Authentication handler for bot requests from Nextcloud Talk to the application.""" + body = asyncio.run(request.body()) + secret = get_bot_secret(request.url.components.path) + if not secret: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + hmac_sign = hmac.new( + secret, request.headers.get("X-NEXTCLOUD-TALK-RANDOM", "").encode("UTF-8"), digestmod=hashlib.sha256 + ) + hmac_sign.update(body) + if request.headers["X-NEXTCLOUD-TALK-SIGNATURE"] != hmac_sign.hexdigest(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return TalkBotMessage(json.loads(body)) + + def set_handlers( fast_api_app: FastAPI, - enabled_handler: Callable[[bool, NextcloudApp], str], - heartbeat_handler: Optional[Callable[[], str]] = None, + enabled_handler: typing.Callable[[bool, NextcloudApp], str], + heartbeat_handler: typing.Optional[typing.Callable[[], str]] = None, ): """Defines handlers for the application. @@ -34,14 +53,14 @@ def set_handlers( @fast_api_app.put("/enabled") def enabled_callback( enabled: bool, - nc: Annotated[NextcloudApp, Depends(nc_app)], + nc: typing.Annotated[NextcloudApp, Depends(nc_app)], ): r = enabled_handler(enabled, nc) - return JSONResponse(content={"error": r}, status_code=200) + return responses.JSONResponse(content={"error": r}, status_code=200) @fast_api_app.get("/heartbeat") def heartbeat_callback(): return_status = "ok" if heartbeat_handler is not None: return_status = heartbeat_handler() - return JSONResponse(content={"status": return_status}, status_code=200) + return responses.JSONResponse(content={"status": return_status}, status_code=200) diff --git a/nc_py_api/nextcloud.py b/nc_py_api/nextcloud.py index ee96649b..d9a96ce8 100644 --- a/nc_py_api/nextcloud.py +++ b/nc_py_api/nextcloud.py @@ -5,7 +5,8 @@ from fastapi import Request from httpx import Headers as HttpxHeaders -from ._misc import check_capabilities +from ._exceptions import NextcloudExceptionNotFound +from ._misc import check_capabilities, require_capabilities from ._preferences import PreferencesAPI from ._preferences_ex import AppConfigExAPI, PreferencesExAPI from ._session import AppConfig, NcSession, NcSessionApp, NcSessionBasic, ServerVersion @@ -190,6 +191,43 @@ def app_cfg(self) -> AppConfig: """Returns deploy config, with AppEcosystem version, Application version and name.""" return self._session.cfg + def register_talk_bot(self, callback_url: str, display_name: str, description: str = "") -> tuple[str, str]: + """Registers Talk BOT. + + .. note:: AppEcosystem will add a record in a case of successful registration to the ``appconfig_ex`` table. + + :param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides. + :param display_name: The name under which the messages will be posted. + :param description: Optional description shown in the admin settings. + :return: The secret used for signing requests. + """ + require_capabilities("app_ecosystem_v2", self._session.capabilities) + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + params = { + "name": display_name, + "route": callback_url, + "description": description, + } + result = self._session.ocs(method="POST", path=f"{self._session.ae_url}/talk_bot", json=params) + return result["id"], result["secret"] + + def unregister_talk_bot(self, callback_url: str) -> bool: + """Unregisters Talk BOT. + + :param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides. + :return: The secret used for signing requests. + """ + require_capabilities("app_ecosystem_v2", self._session.capabilities) + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + params = { + "route": callback_url, + } + try: + self._session.ocs(method="DELETE", path=f"{self._session.ae_url}/talk_bot", json=params) + except NextcloudExceptionNotFound: + return False + return True + def request_sign_check(self, request: Request) -> bool: """Verifies the signature and validity of an incoming request from the Nextcloud. diff --git a/nc_py_api/talk.py b/nc_py_api/talk.py index 0989b1fa..7e9bf662 100644 --- a/nc_py_api/talk.py +++ b/nc_py_api/talk.py @@ -2,9 +2,15 @@ import dataclasses import enum +import hashlib import typing -from ._misc import check_capabilities, clear_from_params_empty +from ._misc import ( + check_capabilities, + clear_from_params_empty, + random_string, + require_capabilities, +) from ._session import NcSessionBasic from .user_status import _UserStatus @@ -149,6 +155,111 @@ class BreakoutRoomStatus(enum.IntEnum): """Breakout rooms lobbies are enabled.""" +@dataclasses.dataclass +class TalkMessage: + """Talk message.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def message_id(self) -> int: + """Numeric identifier of the message. Most methods that require this should accept this class itself.""" + return self._raw_data["id"] + + @property + def token(self) -> str: + """Token identifier of the conversation which is used for further interaction.""" + return self._raw_data["token"] + + @property + def actor_type(self) -> str: + """Actor types of the chat message: **users**, **guests**, **bots**, **bridged**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """Actor id of the message author.""" + return self._raw_data["actorId"] + + @property + def actor_display_name(self) -> str: + """A display name of the message author.""" + return self._raw_data["actorDisplayName"] + + @property + def timestamp(self) -> int: + """Timestamp in seconds and UTC time zone.""" + return self._raw_data["timestamp"] + + @property + def system_message(self) -> str: + """Empty for the normal chat message or the type of the system message (untranslated).""" + return self._raw_data["systemMessage"] + + @property + def message_type(self) -> str: + """Currently known types are "comment", "comment_deleted", "system" and "command".""" + return self._raw_data["messageType"] + + @property + def is_replyable(self) -> bool: + """True if the user can post a reply to this message. + + .. note:: Only available with ``chat-replies`` capability. + """ + return self._raw_data["isReplyable"] + + @property + def reference_id(self) -> str: + """A reference string that was given while posting the message to be able to identify the sent message again. + + .. note:: Only available with ``chat-reference-id`` capability. + """ + return self._raw_data["referenceId"] + + @property + def message(self) -> str: + """Message string with placeholders. + + See `Rich Object String `_. + """ + return self._raw_data["message"] + + @property + def message_parameters(self) -> dict: + """Message parameters for the ``message``.""" + return self._raw_data["messageParameters"] + + @property + def expiration_timestamp(self) -> int: + """Unix time stamp when the message expires and show be removed from the client's UI without further note. + + .. note:: Only available with ``message-expiration`` capability. + """ + return self._raw_data["expirationTimestamp"] + + @property + def parent(self) -> list: + """To be refactored: `Description here `_.""" + return self._raw_data.get("parent", []) + + @property + def reactions(self) -> dict: + """An array map with relation between reaction emoji and total count of reactions with this emoji.""" + return self._raw_data.get("reactions", {}) + + @property + def reactions_self(self) -> list[str]: + """When the user reacted, this is the list of emojis the user reacted with.""" + return self._raw_data.get("reactionsSelf", []) + + @property + def markdown(self) -> bool: + """Whether the message should be rendered as markdown or shown as plain text.""" + return self._raw_data.get("markdown", False) + + @dataclasses.dataclass(init=False) class Conversation(_UserStatus): """Talk conversation.""" @@ -370,6 +481,25 @@ def last_read_message(self) -> int: """ return self._raw_data["lastReadMessage"] + @property + def last_common_read_message(self) -> int: + """``ID`` of the last message read by every user that has read privacy set to public in a room. + + When the user himself has it set to ``private`` the value is ``0``. + + .. note:: Only available with ``chat-read-status`` capability. + """ + return self._raw_data["lastCommonReadMessage"] + + @property + def last_message(self) -> typing.Optional[TalkMessage]: + """Last message in a conversation if available, otherwise ``empty``. + + .. note:: Even when given, the message will not contain the ``parent`` or ``reactionsSelf`` + attribute due to performance reasons + """ + return TalkMessage(self._raw_data["lastMessage"]) if self._raw_data["lastMessage"] else None + @property def breakout_room_mode(self) -> BreakoutRoomMode: """Breakout room configuration mode. @@ -419,10 +549,68 @@ def recording_status(self) -> CallRecordingStatus: return CallRecordingStatus(self._raw_data.get("callRecording", CallRecordingStatus.NO_RECORDING)) +@dataclasses.dataclass +class BotInfoBasic: + """Basic information about the Nextcloud Talk Bot.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def bot_id(self) -> int: + """Unique numeric identifier of the bot on this server.""" + return self._raw_data["id"] + + @property + def bot_name(self) -> str: + """The display name of the bot shown as author when it posts a message or reaction.""" + return self._raw_data["name"] + + @property + def description(self) -> str: + """A longer description of the bot helping moderators to decide if they want to enable this bot.""" + return self._raw_data["description"] + + @property + def state(self) -> int: + """One of the Bot states: ``0`` - Disabled, ``1`` - enabled, ``2`` - **No setup**.""" + return self._raw_data["state"] + + +@dataclasses.dataclass(init=False) +class BotInfo(BotInfoBasic): + """Full information about the Nextcloud Talk Bot.""" + + @property + def url(self) -> str: + """URL endpoint that is triggered by this bot.""" + return self._raw_data["url"] + + @property + def url_hash(self) -> str: + """Hash of the URL prefixed with ``bot-`` serves as ``actor_id``.""" + return self._raw_data["url_hash"] + + @property + def error_count(self) -> int: + """Number of consecutive errors.""" + return self._raw_data["error_count"] + + @property + def last_error_date(self) -> int: + """UNIX timestamp of the last error.""" + return self._raw_data["last_error_date"] + + @property + def last_error_message(self) -> typing.Optional[str]: + """The last exception message or error response information when trying to reach the bot.""" + return self._raw_data["last_error_message"] + + class _TalkAPI: """Class that implements work with Nextcloud Talk.""" - _ep_base: str = "/ocs/v2.php/apps/spreed/api/v4" + _ep_base: str = "/ocs/v2.php/apps/spreed" config_sha: str """Sha1 value over Talk config. After receiving a different value on subsequent requests, settings got refreshed.""" modified_since: int @@ -438,12 +626,17 @@ def available(self) -> bool: """Returns True if the Nextcloud instance supports this feature, False otherwise.""" return not check_capabilities("spreed", self._session.capabilities) + @property + def bots_available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("spreed.features.bots-v1", self._session.capabilities) + def get_user_conversations( self, no_status_update: bool = True, include_status: bool = False, modified_since: typing.Union[int, bool] = 0 ) -> list[Conversation]: """Returns the list of the user's conversations. - :param no_status_update: When the user status should not be automatically set to the online. Default = **True** + :param no_status_update: When the user status should not be automatically set to the online. :param include_status: Whether the user status information of all one-to-one conversations should be loaded. :param modified_since: When provided only conversations with a newer **lastActivity** (and one-to-one conversations when includeStatus is provided) are returned. @@ -455,13 +648,13 @@ def get_user_conversations( """ params: dict = {} if no_status_update: - params["noStatusUpdate"] = True + params["noStatusUpdate"] = 1 if include_status: params["includeStatus"] = True if modified_since: params["modifiedSince"] = self.modified_since if modified_since is True else modified_since - result = self._session.ocs("GET", self._ep_base + "/room", params=params) + result = self._session.ocs("GET", self._ep_base + "/api/v4/room", params=params) self.modified_since = int(self._session.response_headers["X-Nextcloud-Talk-Modified-Before"]) config_sha = self._session.response_headers["X-Nextcloud-Talk-Hash"] if self.config_sha != config_sha: @@ -502,7 +695,7 @@ def create_conversation( "objectId": object_id, } clear_from_params_empty(["invite", "source", "roomName", "objectType", "objectId"], params) - return Conversation(self._session.ocs("POST", self._ep_base + "/room", json=params)) + return Conversation(self._session.ocs("POST", self._ep_base + "/api/v4/room", json=params)) def delete_conversation(self, conversation: typing.Union[Conversation, str]) -> None: """Deletes a conversation. @@ -514,7 +707,7 @@ def delete_conversation(self, conversation: typing.Union[Conversation, str]) -> :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. """ token = conversation.token if isinstance(conversation, Conversation) else conversation - self._session.ocs("DELETE", self._ep_base + f"/room/{token}") + self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}") def leave_conversation(self, conversation: typing.Union[Conversation, str]) -> None: """Removes yourself from the conversation. @@ -525,4 +718,107 @@ def leave_conversation(self, conversation: typing.Union[Conversation, str]) -> N :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. """ token = conversation.token if isinstance(conversation, Conversation) else conversation - self._session.ocs("DELETE", self._ep_base + f"/room/{token}/participants/self") + self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}/participants/self") + + def send_message( + self, + message: str, + conversation: typing.Union[Conversation, str] = "", + reply_to_message: typing.Union[int, TalkMessage] = 0, + silent: bool = False, + actor_display_name: str = "", + ) -> str: + """Send a message and returns a "reference string" to identify the message again in a "get messages" request. + + :param message: The message the user wants to say. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + Need only if **reply_to_message** is not :py:class:`~nc_py_api.talk.TalkMessage` + :param reply_to_message: The message ID this message is a reply to. + + .. note:: Only allowed when the message type is not ``system`` or ``command``. + The message you are replying to should be from the same conversation. + :param silent: Flag controlling if the message should create a chat notifications for the users. + :param actor_display_name: Guest display name (**ignored for the logged-in users**). + :returns: A reference string to be able to identify the message again in a "get messages" request. + :raises ValueError: in case of an invalid usage. + """ + if not conversation and not isinstance(reply_to_message, TalkMessage): + raise ValueError("Either specify 'conversation' or provide 'TalkMessage'.") + + token = ( + reply_to_message.token + if isinstance(reply_to_message, TalkMessage) + else conversation.token if isinstance(conversation, Conversation) else conversation + ) + reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest() + params = { + "message": message, + "actorDisplayName": actor_display_name, + "replyTo": reply_to_message.message_id if isinstance(reply_to_message, TalkMessage) else reply_to_message, + "referenceId": reference_id, + "silent": silent, + } + self._session.ocs("POST", self._ep_base + f"/api/v1/chat/{token}", json=params) + return reference_id + + def receive_messages( + self, + conversation: typing.Union[Conversation, str], + look_in_future: bool = False, + limit: int = 100, + timeout: int = 30, + no_status_update: bool = True, + ) -> list[TalkMessage]: + """Receive chat messages of a conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param look_in_future: ``True`` to poll and wait for the new message or ``False`` to get history. + :param limit: Number of chat messages to receive (``100`` by default, ``200`` at most). + :param timeout: ``look_in_future=1`` only: seconds to wait for the new messages (60 secs at most). + :param no_status_update: When the user status should not be automatically set to the online. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + params = { + "lookIntoFuture": int(look_in_future), + "limit": limit, + "timeout": timeout, + "noStatusUpdate": int(no_status_update), + } + result = self._session.ocs("GET", self._ep_base + f"/api/v1/chat/{token}", params=params) + return [TalkMessage(i) for i in result] + + def list_bots(self) -> list[BotInfo]: + """Lists the bots that are installed on the server.""" + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + return [BotInfo(i) for i in self._session.ocs("GET", self._ep_base + "/api/v1/bot/admin")] + + def conversation_list_bots(self, conversation: typing.Union[Conversation, str]) -> list[BotInfoBasic]: + """Lists the bots that are enabled and can be enabled for the conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + return [BotInfoBasic(i) for i in self._session.ocs("GET", self._ep_base + f"/api/v1/bot/{token}")] + + def enable_bot(self, conversation: typing.Union[Conversation, str], bot: typing.Union[BotInfoBasic, int]) -> None: + """Enable a bot for a conversation as a moderator. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param bot: bot ID or :py:class:`~nc_py_api.talk.BotInfoBasic`. + """ + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot + self._session.ocs("POST", self._ep_base + f"/api/v1/bot/{token}/{bot_id}") + + def disable_bot(self, conversation: typing.Union[Conversation, str], bot: typing.Union[BotInfoBasic, int]) -> None: + """Disable a bot for a conversation as a moderator. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param bot: bot ID or :py:class:`~nc_py_api.talk.BotInfoBasic`. + """ + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot + self._session.ocs("DELETE", self._ep_base + f"/api/v1/bot/{token}/{bot_id}") diff --git a/nc_py_api/talk_bot.py b/nc_py_api/talk_bot.py new file mode 100644 index 00000000..8a096383 --- /dev/null +++ b/nc_py_api/talk_bot.py @@ -0,0 +1,215 @@ +"""Nextcloud Talk API for bots.""" +import dataclasses +import hashlib +import hmac +import json +import os +import typing + +import httpx + +from . import options +from ._misc import random_string +from ._session import BasicConfig +from .nextcloud import NextcloudApp + + +class ObjectContent(typing.TypedDict): + """Object content of :py:class:`~nc_py_api.talk_bot.TalkBotMessage`.""" + + message: str + parameters: dict + + +@dataclasses.dataclass +class TalkBotMessage: + """Talk message received by bots.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def actor_id(self) -> str: + """One of the attendee types followed by the ``/`` character and a unique identifier within the given type. + + For the users it is the Nextcloud user ID, for guests a **sha1** value. + """ + return self._raw_data["actor"]["id"] + + @property + def actor_display_name(self) -> str: + """The display name of the attendee sending the message.""" + return self._raw_data["actor"]["name"] + + @property + def object_id(self) -> int: + """The message ID of the given message on the origin server. + + It can be used to react or reply to the given message. + """ + return self._raw_data["object"]["id"] + + @property + def object_name(self) -> str: + """For normal written messages ``message``, otherwise one of the known ``system message identifiers``.""" + return self._raw_data["object"]["name"] + + @property + def object_content(self) -> ObjectContent: + """Dictionary with a ``message`` and ``parameters`` keys.""" + return json.loads(self._raw_data["object"]["content"]) + + @property + def object_media_type(self) -> str: + """``text/markdown`` when the message should be interpreted as **Markdown**, otherwise ``text/plain``.""" + return self._raw_data["object"]["mediaType"] + + @property + def conversation_token(self) -> str: + """The token of the conversation in which the message was posted. + + It can be used to react or reply to the given message. + """ + return self._raw_data["target"]["id"] + + @property + def conversation_name(self) -> str: + """The name of the conversation in which the message was posted.""" + return self._raw_data["target"]["name"] + + +class TalkBot: + """A class that implements the TalkBot functionality.""" + + _ep_base: str = "/ocs/v2.php/apps/spreed/api/v1/bot" + + def __init__(self, callback_url: str, display_name: str, description: str = ""): + """Class implementing Nextcloud Talk Bot functionality. + + :param callback_url: FastAPI endpoint which will be assigned to bot. + :param display_name: The display name of the bot that is shown as author when it posts a message or reaction. + :param description: Description of the bot helping moderators to decide if they want to enable this bot. + """ + self.callback_url = callback_url + self.display_name = display_name + self.description = description + + def enabled_handler(self, enabled: bool, nc: NextcloudApp) -> None: + """Handles the app ``on``/``off`` event in the context of the bot. + + :param enabled: Value that was passed to ``/enabled`` handler. + :param nc: **NextcloudApp** class that was passed ``/enabled`` handler. + """ + if enabled: + bot_id, bot_secret = nc.register_talk_bot(self.callback_url, self.display_name, self.description) + os.environ[bot_id] = bot_secret + else: + nc.unregister_talk_bot(self.callback_url) + + def send_message( + self, message: str, reply_to_message: typing.Union[int, TalkBotMessage], silent: bool = False, token: str = "" + ) -> tuple[httpx.Response, str]: + """Send a message and returns a "reference string" to identify the message again in a "get messages" request. + + :param message: The message to say. + :param reply_to_message: The message ID this message is a reply to. + + .. note:: Only allowed when the message type is not ``system`` or ``command``. + :param silent: Flag controlling if the message should create a chat notifications for the users. + :param token: Token of the conversation. + Can be empty if ``reply_to_message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :returns: Tuple, where fist element is :py:class:`httpx.Response` and second is a "reference string". + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(reply_to_message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + token = reply_to_message.conversation_token if isinstance(reply_to_message, TalkBotMessage) else token + reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest() + params = { + "message": message, + "replyTo": reply_to_message.object_id if isinstance(reply_to_message, TalkBotMessage) else reply_to_message, + "referenceId": reference_id, + "silent": silent, + } + return self._sign_send_request("POST", f"/{token}/message", params, message), reference_id + + def react_to_message( + self, message: typing.Union[int, TalkBotMessage], reaction: str, token: str = "" + ) -> httpx.Response: + """React to a message. + + :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to react to. + :param reaction: A single emoji. + :param token: Token of the conversation. + Can be empty if ``message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + message_id = message.object_id if isinstance(message, TalkBotMessage) else message + token = message.conversation_token if isinstance(message, TalkBotMessage) else token + params = { + "reaction": reaction, + } + return self._sign_send_request("POST", f"/{token}/reaction/{message_id}", params, reaction) + + def delete_reaction( + self, message: typing.Union[int, TalkBotMessage], reaction: str, token: str = "" + ) -> httpx.Response: + """Removes reaction from a message. + + :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to react to. + :param reaction: A single emoji. + :param token: Token of the conversation. + Can be empty if ``message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + message_id = message.object_id if isinstance(message, TalkBotMessage) else message + token = message.conversation_token if isinstance(message, TalkBotMessage) else token + params = { + "reaction": reaction, + } + return self._sign_send_request("DELETE", f"/{token}/reaction/{message_id}", params, reaction) + + def _sign_send_request(self, method: str, url_suffix: str, data: dict, data_to_sign: str) -> httpx.Response: + secret = get_bot_secret(self.callback_url) + if secret is None: + raise RuntimeError("Can't find the 'secret' of the bot. Has the bot been installed?") + talk_bot_random = random_string(32) + hmac_sign = hmac.new(secret, talk_bot_random.encode("UTF-8"), digestmod=hashlib.sha256) + hmac_sign.update(data_to_sign.encode("UTF-8")) + headers = { + "X-Nextcloud-Talk-Bot-Random": talk_bot_random, + "X-Nextcloud-Talk-Bot-Signature": hmac_sign.hexdigest(), + "OCS-APIRequest": "true", + } + nc_app_cfg = BasicConfig() + return httpx.request( + method, + url=nc_app_cfg.endpoint + "/ocs/v2.php/apps/spreed/api/v1/bot" + url_suffix, + json=data, + headers=headers, + cookies={"XDEBUG_SESSION": options.XDEBUG_SESSION} if options.XDEBUG_SESSION else {}, + timeout=nc_app_cfg.options.timeout, + verify=nc_app_cfg.options.nc_cert, + ) + + +def get_bot_secret(callback_url: str) -> typing.Union[bytes, None]: + """Returns the bot's secret from an environment variable or from the application's configuration on the server.""" + sha_1 = hashlib.sha1() + string_to_hash = os.environ["APP_ID"] + "_" + callback_url + sha_1.update(string_to_hash.encode("UTF-8")) + secret_key = sha_1.hexdigest() + if secret_key in os.environ: + return os.environ[secret_key].encode("UTF-8") + secret_value = NextcloudApp().appconfig_ex.get_value(secret_key) + if secret_value is not None: + os.environ[secret_key] = secret_value + return secret_value.encode("UTF-8") + return None diff --git a/scripts/dev_register.sh b/scripts/dev_register.sh index 57a52ca9..5582ec9b 100644 --- a/scripts/dev_register.sh +++ b/scripts/dev_register.sh @@ -13,7 +13,7 @@ NEXTCLOUD_URL="http://$2" APP_PORT=9009 APP_ID="nc_py_api" APP_SECRET="12345" AP echo $! > /tmp/_install.pid python3 tests/_install_wait.py "http://localhost:9009/heartbeat" "\"status\":\"ok\"" 15 0.5 docker exec "$1" sudo -u www-data php occ app_ecosystem_v2:app:register nc_py_api manual_install --json-info \ - "{\"appid\":\"nc_py_api\",\"name\":\"nc_py_api\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"host\":\"host.docker.internal\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"]},\"port\":9009,\"protocol\":\"http\",\"system_app\":1}" \ + "{\"appid\":\"nc_py_api\",\"name\":\"nc_py_api\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"host\":\"host.docker.internal\",\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\", \"TALK_BOT\"]},\"port\":9009,\"protocol\":\"http\",\"system_app\":1}" \ -e --force-scopes cat /tmp/_install.pid kill -15 "$(cat /tmp/_install.pid)" diff --git a/tests/_talk_bot.py b/tests/_talk_bot.py new file mode 100644 index 00000000..a6cfa568 --- /dev/null +++ b/tests/_talk_bot.py @@ -0,0 +1,57 @@ +from typing import Annotated + +import gfixture_set_env # noqa +import pytest +import requests +from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request +from starlette.datastructures import URL + +from nc_py_api import talk_bot +from nc_py_api.ex_app import run_app, talk_bot_app + +APP = FastAPI() +COVERAGE_BOT = talk_bot.TalkBot("/talk_bot_coverage", "Coverage bot", "Desc") + + +def coverage_talk_bot_process_request(message: talk_bot.TalkBotMessage, request: Request): + COVERAGE_BOT.react_to_message(message, "🥳") + COVERAGE_BOT.react_to_message(message, "🫡") + COVERAGE_BOT.delete_reaction(message, "🫡") + COVERAGE_BOT.send_message("Hello from bot!", message) + assert isinstance(message.actor_id, str) + assert isinstance(message.actor_display_name, str) + assert isinstance(message.object_name, str) + assert isinstance(message.object_content, dict) + assert message.object_media_type in ("text/markdown", "text/plain") + assert isinstance(message.conversation_name, str) + with pytest.raises(ValueError): + COVERAGE_BOT.react_to_message(message.object_id, "🥳") + with pytest.raises(ValueError): + COVERAGE_BOT.delete_reaction(message.object_id, "🥳") + with pytest.raises(ValueError): + COVERAGE_BOT.send_message("🥳", message.object_id) + with pytest.raises(HTTPException) as e: + headers = dict(request.scope["headers"]) + headers[b"x-nextcloud-talk-signature"] = b"122112442412" + request.scope["headers"] = [(k, v) for k, v in headers.items()] + del request._headers # noqa + talk_bot_app(request) + assert e.value.status_code == 401 + with pytest.raises(HTTPException) as e: + request._url = URL("sample_url") + talk_bot_app(request) + assert e.value.status_code == 500 + + +@APP.post("/talk_bot_coverage") +async def currency_talk_bot( + request: Request, + message: Annotated[talk_bot.TalkBotMessage, Depends(talk_bot_app)], + background_tasks: BackgroundTasks, +): + background_tasks.add_task(coverage_talk_bot_process_request, message, request) + return requests.Response() + + +if __name__ == "__main__": + run_app("_talk_bot:APP", log_level="trace") diff --git a/tests/_tests_at_the_end.py b/tests/_tests_at_the_end.py new file mode 100644 index 00000000..e6b42722 --- /dev/null +++ b/tests/_tests_at_the_end.py @@ -0,0 +1,33 @@ +import os +import sys +from subprocess import Popen + +import pytest +from _install_wait import check_heartbeat +from gfixture import NC, NC_APP + +# These tests will be run separate, and at the end of all other tests. + + +@pytest.mark.skipif(NC_APP is None or NC is None, reason="Requires both Nextcloud Client and App modes.") +def test_ex_app_enable_disable(): + child_environment = os.environ.copy() + child_environment["APP_PORT"] = os.environ.get("APP_PORT", "9009") + r = Popen( + [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "_install_only_enabled_handler.py")], + env=child_environment, + cwd=os.getcwd(), + ) + url = f"http://127.0.0.1:{child_environment['APP_PORT']}/heartbeat" + try: + if check_heartbeat(url, '"status":"ok"', 15, 0.3): + raise RuntimeError("`_install_only_enabled_handler` can not start.") + if NC.apps.ex_app_is_enabled("nc_py_api"): + NC.apps.ex_app_disable("nc_py_api") + assert NC.apps.ex_app_is_disabled("nc_py_api") is True + assert NC.apps.ex_app_is_enabled("nc_py_api") is False + NC.apps.ex_app_enable("nc_py_api") + assert NC.apps.ex_app_is_disabled("nc_py_api") is False + assert NC.apps.ex_app_is_enabled("nc_py_api") is True + finally: + r.terminate() diff --git a/tests/gfixture.py b/tests/gfixture.py index 1d7dc413..6cb46b89 100644 --- a/tests/gfixture.py +++ b/tests/gfixture.py @@ -1,15 +1,8 @@ from os import environ -from nc_py_api import Nextcloud, NextcloudApp - -if not environ.get("CI", False): # For local tests - environ["NC_AUTH_USER"] = "admin" - environ["NC_AUTH_PASS"] = "admin" # "MrtGY-KfY24-iiDyg-cr4n4-GLsNZ" - environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://nextcloud.local") - environ["APP_ID"] = "nc_py_api" - environ["APP_VERSION"] = "1.0.0" - environ["APP_SECRET"] = "12345" +import gfixture_set_env # noqa +from nc_py_api import Nextcloud, NextcloudApp NC = None if environ.get("SKIP_NC_CLIENT_TESTS", False) else Nextcloud() diff --git a/tests/gfixture_set_env.py b/tests/gfixture_set_env.py new file mode 100644 index 00000000..bc6f5542 --- /dev/null +++ b/tests/gfixture_set_env.py @@ -0,0 +1,10 @@ +from os import environ + +if not environ.get("CI", False): # For local tests + environ["NC_AUTH_USER"] = "admin" + environ["NC_AUTH_PASS"] = "admin" # "MrtGY-KfY24-iiDyg-cr4n4-GLsNZ" + environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://nextcloud.local") + environ["APP_ID"] = "nc_py_api" + environ["APP_VERSION"] = "1.0.0" + environ["APP_SECRET"] = "12345" + environ["APP_PORT"] = "9009" diff --git a/tests/talk_test.py b/tests/talk_test.py index ac59be81..30390ccb 100644 --- a/tests/talk_test.py +++ b/tests/talk_test.py @@ -1,12 +1,15 @@ import contextlib import pytest -from gfixture import NC, NC_TO_TEST +from gfixture import NC, NC_APP, NC_TO_TEST, NC_VERSION from users_test import TEST_USER_NAME, TEST_USER_PASSWORD -from nc_py_api import Nextcloud, NextcloudException, talk +from nc_py_api import Nextcloud, NextcloudException, talk, talk_bot -if NC_TO_TEST and NC_TO_TEST[0].talk.available is False: +if NC is None or NC_APP is None: + pytest.skip("Requires both Nextcloud Client and App modes.", allow_module_level=True) + +if NC_TO_TEST[0].talk.available is False: pytest.skip("Nextcloud Talk is not installed.", allow_module_level=True) @@ -15,14 +18,19 @@ def test_available(nc): assert nc.talk.available +@pytest.mark.parametrize("nc", NC_TO_TEST) +def test_bots_available(nc): + assert isinstance(nc.talk.bots_available, bool) + + @pytest.mark.parametrize("nc", NC_TO_TEST) def test_conversation_create_delete(nc): conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin") nc.talk.delete_conversation(conversation) assert isinstance(conversation.conversation_id, int) assert isinstance(conversation.token, str) and conversation.token - assert isinstance(conversation.conversation_type, talk.ConversationType) assert isinstance(conversation.name, str) + assert isinstance(conversation.conversation_type, talk.ConversationType) assert isinstance(conversation.display_name, str) assert isinstance(conversation.description, str) assert isinstance(conversation.participant_type, talk.ParticipantType) @@ -55,12 +63,34 @@ def test_conversation_create_delete(nc): assert isinstance(conversation.unread_mention, bool) assert isinstance(conversation.unread_mention_direct, bool) assert isinstance(conversation.last_read_message, int) + assert isinstance(conversation.last_message, talk.TalkMessage) or conversation.last_message is None + assert isinstance(conversation.last_common_read_message, int) assert isinstance(conversation.breakout_room_mode, talk.BreakoutRoomMode) assert isinstance(conversation.breakout_room_status, talk.BreakoutRoomStatus) assert isinstance(conversation.avatar_version, str) assert isinstance(conversation.is_custom_avatar, bool) assert isinstance(conversation.call_start_time, int) assert isinstance(conversation.recording_status, talk.CallRecordingStatus) + if conversation.last_message is None: + return + talk_msg = conversation.last_message + assert isinstance(talk_msg.message_id, int) + assert isinstance(talk_msg.token, str) + assert talk_msg.actor_type in ("users", "guests", "bots", "bridged") + assert isinstance(talk_msg.actor_id, str) + assert isinstance(talk_msg.actor_display_name, str) + assert isinstance(talk_msg.timestamp, int) + assert isinstance(talk_msg.system_message, str) + assert talk_msg.message_type in ("comment", "comment_deleted", "system", "command") + assert talk_msg.is_replyable is False + assert isinstance(talk_msg.reference_id, str) + assert isinstance(talk_msg.message, str) + assert isinstance(talk_msg.message_parameters, dict) + assert isinstance(talk_msg.expiration_timestamp, int) + assert isinstance(talk_msg.parent, list) + assert isinstance(talk_msg.reactions, dict) + assert isinstance(talk_msg.reactions_self, list) + assert isinstance(talk_msg.markdown, bool) @pytest.mark.parametrize("nc", NC_TO_TEST) @@ -79,7 +109,6 @@ def test_get_conversations_modified_since(nc): @pytest.mark.parametrize("nc", NC_TO_TEST) -@pytest.mark.skipif(NC is None, reason="Usual Nextcloud mode required for the test") def test_get_conversations_include_status(nc): with contextlib.suppress(NextcloudException): NC.users.create(TEST_USER_NAME, password=TEST_USER_PASSWORD) @@ -98,3 +127,75 @@ def test_get_conversations_include_status(nc): finally: nc.talk.leave_conversation(conversation.token) NC.users.delete(TEST_USER_NAME) + + +@pytest.mark.skipif(NC_VERSION["major"] < 27 and NC_VERSION["minor"] >= 1, reason="Run only on NC27.1+") +@pytest.mark.skipif(NC_APP.check_capabilities("spreed.features.bots-v1"), reason="Need Talk bots support.") +def test_register_unregister_talk_bot(): + NC_APP.unregister_talk_bot("/talk_bot_coverage") + list_of_bots = NC_APP.talk.list_bots() + NC_APP.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc") + assert len(list_of_bots) + 1 == len(NC_APP.talk.list_bots()) + NC_APP.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc") + assert len(list_of_bots) + 1 == len(NC_APP.talk.list_bots()) + assert NC_APP.unregister_talk_bot("/talk_bot_coverage") is True + assert len(list_of_bots) == len(NC_APP.talk.list_bots()) + assert NC_APP.unregister_talk_bot("/talk_bot_coverage") is False + assert len(list_of_bots) == len(NC_APP.talk.list_bots()) + + +@pytest.mark.skipif(NC_VERSION["major"] < 27 and NC_VERSION["minor"] >= 1, reason="Run only on NC27.1+") +@pytest.mark.skipif(NC_APP.check_capabilities("spreed.features.bots-v1"), reason="Need Talk bots support.") +@pytest.mark.parametrize("nc", NC_TO_TEST) +def test_list_bots(nc): + NC_APP.register_talk_bot("/some_url", "some bot name", "some desc") + registered_bot = [i for i in nc.talk.list_bots() if i.bot_name == "some bot name"][0] + assert isinstance(registered_bot.bot_id, int) + assert registered_bot.url.find("/some_url") != -1 + assert registered_bot.description == "some desc" + assert registered_bot.state == 1 + assert not registered_bot.error_count + assert registered_bot.last_error_date == 0 + assert registered_bot.last_error_message is None + assert isinstance(registered_bot.url_hash, str) + conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin") + try: + assert nc.talk.conversation_list_bots(conversation) + finally: + nc.talk.delete_conversation(conversation.token) + + +@pytest.mark.skipif(NC_VERSION["major"] < 27 and NC_VERSION["minor"] >= 1, reason="Run only on NC27.1+") +@pytest.mark.skipif(NC_APP.check_capabilities("spreed.features.bots-v1"), reason="Need Talk bots support.") +def test_chat_bot_receive_message(): + talk_bot_inst = talk_bot.TalkBot("/talk_bot_coverage", "Coverage bot", "Desc") + talk_bot_inst.enabled_handler(True, NC_APP) + conversation = NC_APP.talk.create_conversation(talk.ConversationType.GROUP, "admin") + try: + coverage_bot = [i for i in NC_APP.talk.list_bots() if i.url.endswith("/talk_bot_coverage")][0] + c_bot_info = [i for i in NC_APP.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id][0] + assert c_bot_info.state == 0 + NC_APP.talk.enable_bot(conversation, coverage_bot) + c_bot_info = [i for i in NC_APP.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id][0] + assert c_bot_info.state == 1 + with pytest.raises(ValueError): + NC_APP.talk.send_message("Here are the msg!") + NC_APP.talk.send_message("Here are the msg!", conversation) + msg_from_bot = None + for _ in range(40): + messages = NC_APP.talk.receive_messages(conversation, look_in_future=True, timeout=1) + if messages[-1].message == "Hello from bot!": + msg_from_bot = messages[-1] + break + assert msg_from_bot + c_bot_info = [i for i in NC_APP.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id][0] + assert c_bot_info.state == 1 + NC_APP.talk.disable_bot(conversation, coverage_bot) + c_bot_info = [i for i in NC_APP.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id][0] + assert c_bot_info.state == 0 + finally: + NC_APP.talk.delete_conversation(conversation.token) + talk_bot_inst.enabled_handler(False, NC_APP) + talk_bot_inst.callback_url = "invalid_url" + with pytest.raises(RuntimeError): + talk_bot_inst.send_message("message", 999999, token="sometoken") diff --git a/tests/zz_last_test.py b/tests/zz_last_test.py deleted file mode 100644 index 304c8978..00000000 --- a/tests/zz_last_test.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -import sys -from subprocess import Popen - -import pytest -from _install_wait import check_heartbeat -from gfixture import NC_APP, NC_TO_TEST - -from nc_py_api import Nextcloud - -# These tests should be run last, as they can affect further one. - - -@pytest.mark.skipif(not isinstance(NC_TO_TEST[:1][0], Nextcloud), reason="Not available for NextcloudApp.") -@pytest.mark.parametrize("nc", NC_TO_TEST[:1]) -@pytest.mark.skipif(NC_APP is None, reason="Not available without NextcloudApp.") -def test_ex_app_enable_disable(nc): - child_environment = os.environ.copy() - child_environment["APP_PORT"] = os.environ.get("APP_PORT", "9009") - r = Popen( - [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "_install_only_enabled_handler.py")], - env=child_environment, - cwd=os.getcwd(), - ) - url = f"http://127.0.0.1:{child_environment['APP_PORT']}/heartbeat" - try: - if check_heartbeat(url, '"status":"ok"', 15, 0.3): - raise RuntimeError("`_install_only_enabled_handler` can not start.") - if nc.apps.ex_app_is_enabled("nc_py_api"): - nc.apps.ex_app_disable("nc_py_api") - assert nc.apps.ex_app_is_disabled("nc_py_api") is True - assert nc.apps.ex_app_is_enabled("nc_py_api") is False - nc.apps.ex_app_enable("nc_py_api") - assert nc.apps.ex_app_is_disabled("nc_py_api") is False - assert nc.apps.ex_app_is_enabled("nc_py_api") is True - finally: - r.terminate()