From fb5b7e5019537499d84366099a3efbf962e1fc46 Mon Sep 17 00:00:00 2001 From: Daan Debie Date: Thu, 21 Nov 2024 14:45:05 +0100 Subject: [PATCH 1/4] chore: bump slack-sdk to 3.33.4 and aiohttp to 3.11.6 --- poetry.lock | 178 +++++++++++++++++++++++-------------------------- pyproject.toml | 2 +- 2 files changed, 83 insertions(+), 97 deletions(-) diff --git a/poetry.lock b/poetry.lock index 32756879..d0d470ef 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aioboto3" @@ -65,102 +65,87 @@ files = [ [[package]] name = "aiohttp" -version = "3.10.11" +version = "3.11.6" description = "Async http client/server framework (asyncio)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "aiohttp-3.10.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e"}, - {file = "aiohttp-3.10.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298"}, - {file = "aiohttp-3.10.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177"}, - {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217"}, - {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a"}, - {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a"}, - {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115"}, - {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a"}, - {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3"}, - {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038"}, - {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519"}, - {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc"}, - {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d"}, - {file = "aiohttp-3.10.11-cp310-cp310-win32.whl", hash = "sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120"}, - {file = "aiohttp-3.10.11-cp310-cp310-win_amd64.whl", hash = "sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674"}, - {file = "aiohttp-3.10.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07"}, - {file = "aiohttp-3.10.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695"}, - {file = "aiohttp-3.10.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24"}, - {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382"}, - {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa"}, - {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625"}, - {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9"}, - {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac"}, - {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a"}, - {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b"}, - {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16"}, - {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730"}, - {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8"}, - {file = "aiohttp-3.10.11-cp311-cp311-win32.whl", hash = "sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9"}, - {file = "aiohttp-3.10.11-cp311-cp311-win_amd64.whl", hash = "sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f"}, - {file = "aiohttp-3.10.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710"}, - {file = "aiohttp-3.10.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d"}, - {file = "aiohttp-3.10.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97"}, - {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725"}, - {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636"}, - {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385"}, - {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087"}, - {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f"}, - {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03"}, - {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d"}, - {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a"}, - {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e"}, - {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4"}, - {file = "aiohttp-3.10.11-cp312-cp312-win32.whl", hash = "sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb"}, - {file = "aiohttp-3.10.11-cp312-cp312-win_amd64.whl", hash = "sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27"}, - {file = "aiohttp-3.10.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127"}, - {file = "aiohttp-3.10.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413"}, - {file = "aiohttp-3.10.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461"}, - {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288"}, - {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067"}, - {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e"}, - {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1"}, - {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006"}, - {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f"}, - {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6"}, - {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31"}, - {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d"}, - {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00"}, - {file = "aiohttp-3.10.11-cp313-cp313-win32.whl", hash = "sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71"}, - {file = "aiohttp-3.10.11-cp313-cp313-win_amd64.whl", hash = "sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e"}, - {file = "aiohttp-3.10.11-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2"}, - {file = "aiohttp-3.10.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339"}, - {file = "aiohttp-3.10.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95"}, - {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92"}, - {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7"}, - {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d"}, - {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca"}, - {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa"}, - {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b"}, - {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658"}, - {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39"}, - {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9"}, - {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7"}, - {file = "aiohttp-3.10.11-cp38-cp38-win32.whl", hash = "sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4"}, - {file = "aiohttp-3.10.11-cp38-cp38-win_amd64.whl", hash = "sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec"}, - {file = "aiohttp-3.10.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106"}, - {file = "aiohttp-3.10.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6"}, - {file = "aiohttp-3.10.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01"}, - {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e"}, - {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829"}, - {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8"}, - {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc"}, - {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa"}, - {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b"}, - {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138"}, - {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777"}, - {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261"}, - {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f"}, - {file = "aiohttp-3.10.11-cp39-cp39-win32.whl", hash = "sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9"}, - {file = "aiohttp-3.10.11-cp39-cp39-win_amd64.whl", hash = "sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb"}, - {file = "aiohttp-3.10.11.tar.gz", hash = "sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7"}, + {file = "aiohttp-3.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7510b3ca2275691875ddf072a5b6cd129278d11fe09301add7d292fc8d3432de"}, + {file = "aiohttp-3.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfab0d2c3380c588fc925168533edb21d3448ad76c3eadc360ff963019161724"}, + {file = "aiohttp-3.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf02dba0f342f3a8228f43fae256aafc21c4bc85bffcf537ce4582e2b1565188"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92daedf7221392e7a7984915ca1b0481a94c71457c2f82548414a41d65555e70"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2274a7876e03429e3218589a6d3611a194bdce08c3f1e19962e23370b47c0313"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a2e1eae2d2f62f3660a1591e16e543b2498358593a73b193006fb89ee37abc6"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978ec3fb0a42efcd98aae608f58c6cfcececaf0a50b4e86ee3ea0d0a574ab73b"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51f87b27d9219ed4e202ed8d6f1bb96f829e5eeff18db0d52f592af6de6bdbf"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:04d1a02a669d26e833c8099992c17f557e3b2fdb7960a0c455d7b1cbcb05121d"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3679d5fcbc7f1ab518ab4993f12f80afb63933f6afb21b9b272793d398303b98"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a4b24e03d04893b5c8ec9cd5f2f11dc9c8695c4e2416d2ac2ce6c782e4e5ffa5"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d9abdfd35ecff1c95f270b7606819a0e2de9e06fa86b15d9080de26594cf4c23"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b5c3e7928a0ad80887a5eba1c1da1830512ddfe7394d805badda45c03db3109"}, + {file = "aiohttp-3.11.6-cp310-cp310-win32.whl", hash = "sha256:913dd9e9378f3c38aeb5c4fb2b8383d6490bc43f3b427ae79f2870651ae08f22"}, + {file = "aiohttp-3.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:4ac26d482c2000c3a59bf757a77adc972828c9d4177b4bd432a46ba682ca7271"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26ac4c960ea8debf557357a172b3ef201f2236a462aefa1bc17683a75483e518"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b1f13ebc99fb98c7c13057b748f05224ccc36d17dee18136c695ef23faaf4ff"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4679f1a47516189fab1774f7e45a6c7cac916224c91f5f94676f18d0b64ab134"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74491fdb3d140ff561ea2128cb7af9ba0a360067ee91074af899c9614f88a18f"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f51e1a90412d387e62aa2d243998c5eddb71373b199d811e6ed862a9f34f9758"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72ab89510511c3bb703d0bb5504787b11e0ed8be928ed2a7cf1cda9280628430"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6681c9e046d99646e8059266688374a063da85b2e4c0ebfa078cda414905d080"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a17f8a6d3ab72cbbd137e494d1a23fbd3ea973db39587941f32901bb3c5c350"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:867affc7612a314b95f74d93aac550ce0909bc6f0b6c658cc856890f4d326542"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:00d894ebd609d5a423acef885bd61e7f6a972153f99c5b3ea45fc01fe909196c"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:614c87be9d0d64477d1e4b663bdc5d1534fc0a7ebd23fb08347ab9fd5fe20fd7"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:533ed46cf772f28f3bffae81c0573d916a64dee590b5dfaa3f3d11491da05b95"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589884cfbc09813afb1454816b45677e983442e146183143f988f7f5a040791a"}, + {file = "aiohttp-3.11.6-cp311-cp311-win32.whl", hash = "sha256:1da63633ba921669eec3d7e080459d4ceb663752b3dafb2f31f18edd248d2170"}, + {file = "aiohttp-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:d778ddda09622e7d83095cc8051698a0084c155a1474bfee9bac27d8613dbc31"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:943a952df105a5305257984e7a1f5c2d0fd8564ff33647693c4d07eb2315446d"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d24ec28b7658970a1f1d98608d67f88376c7e503d9d45ff2ba1949c09f2b358c"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6720e809a660fdb9bec7c168c582e11cfedce339af0a5ca847a5d5b588dce826"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4252d30da0ada6e6841b325869c7ef5104b488e8dd57ec439892abbb8d7b3615"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f65f43ff01b238aa0b5c47962c83830a49577efe31bd37c1400c3d11d8a32835"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc5933f6c9b26404444d36babb650664f984b8e5fa0694540e7b7315d11a4ff"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bf546ba0c029dfffc718c4b67748687fd4f341b07b7c8f1719d6a3a46164798"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c351d05bbeae30c088009c0bb3b17dda04fd854f91cc6196c448349cc98f71c3"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:10499079b063576fad1597898de3f9c0a2ce617c19cc7cd6b62fdcff6b408bf7"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:442ee82eda47dd59798d6866ce020fb8d02ea31ac9ac82b3d719ed349e6a9d52"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:86fce9127bc317119b34786d9e9ae8af4508a103158828a535f56d201da6ab19"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:973d26a5537ce5d050302eb3cd876457451745b1da0624cbb483217970e12567"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532b8f038a4e001137d3600cea5d3439d1881df41bdf44d0f9651264d562fdf0"}, + {file = "aiohttp-3.11.6-cp312-cp312-win32.whl", hash = "sha256:4863c59f748dbe147da82b389931f2a676aebc9d3419813ed5ca32d057c9cb32"}, + {file = "aiohttp-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:5d7f481f82c18ac1f7986e31ba6eea9be8b2e2c86f1ef035b6866179b6c5dd68"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40f502350496ba4c6820816d3164f8a0297b9aa4e95d910da31beb189866a9df"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9072669b0bffb40f1f6977d0b5e8a296edc964f9cefca3a18e68649c214d0ce3"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:518160ecf4e6ffd61715bc9173da0925fcce44ae6c7ca3d3f098fe42585370fb"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f69cc1b45115ac44795b63529aa5caa9674be057f11271f65474127b24fc1ce6"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6be90a6beced41653bda34afc891617c6d9e8276eef9c183f029f851f0a3c3d"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00c22fe2486308770d22ef86242101d7b0f1e1093ce178f2358f860e5149a551"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2607ebb783e3aeefa017ec8f34b506a727e6b6ab2c4b037d65f0bc7151f4430a"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f761d6819870c2a8537f75f3e2fc610b163150cefa01f9f623945840f601b2c"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e44d1bc6c88f5234115011842219ba27698a5f2deee245c963b180080572aaa2"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e0cb6a1b1f499cb2aa0bab1c9f2169ad6913c735b7447e058e0c29c9e51c0b5"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a76b4d4ca34254dca066acff2120811e2a8183997c135fcafa558280f2cc53f3"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69051c1e45fb18c0ae4d39a075532ff0b015982e7997f19eb5932eb4a3e05c17"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aff2ed18274c0bfe0c1d772781c87d5ca97ae50f439729007cec9644ee9b15fe"}, + {file = "aiohttp-3.11.6-cp313-cp313-win32.whl", hash = "sha256:2fbea25f2d44df809a46414a8baafa5f179d9dda7e60717f07bded56300589b3"}, + {file = "aiohttp-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f77bc29a465c0f9f6573d1abe656d385fa673e34efe615bd4acc50899280ee47"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:de6123b298d17bca9e53581f50a275b36e10d98e8137eb743ce69ee766dbdfe9"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a10200f705f4fff00e148b7f41e5d1d929c7cd4ac523c659171a0ea8284cd6fb"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7776ef6901b54dd557128d96c71e412eec0c39ebc07567e405ac98737995aad"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e5c2a55583cd91936baf73d223807bb93ace6eb1fe54424782690f2707162ab"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b032bd6cf7422583bf44f233f4a1489fee53c6d35920123a208adc54e2aba41e"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fe2d99acbc5cf606f75d7347bf3a027c24c27bc052d470fb156f4cfcea5739"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84a79c366375c2250934d1238abe5d5ea7754c823a1c7df0c52bf0a2bfded6a9"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c33cbbe97dc94a34d1295a7bb68f82727bcbff2b284f73ae7e58ecc05903da97"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:19e4fb9ac727834b003338dcdd27dcfe0de4fb44082b01b34ed0ab67c3469fc9"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a97f6b2afbe1d27220c0c14ea978e09fb4868f462ef3d56d810d206bd2e057a2"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c3f7afeea03a9bc49be6053dfd30809cd442cc12627d6ca08babd1c1f9e04ccf"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0d10967600ce5bb69ddcb3e18d84b278efb5199d8b24c3c71a4959c2f08acfd0"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:60f2f631b9fe7aa321fa0f0ff3f5d8b9f7f9b72afd4eecef61c33cf1cfea5d58"}, + {file = "aiohttp-3.11.6-cp39-cp39-win32.whl", hash = "sha256:4d2b75333deb5c5f61bac5a48bba3dbc142eebbd3947d98788b6ef9cc48628ae"}, + {file = "aiohttp-3.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:8908c235421972a2e02abcef87d16084aabfe825d14cc9a1debd609b3cfffbea"}, + {file = "aiohttp-3.11.6.tar.gz", hash = "sha256:fd9f55c1b51ae1c20a1afe7216a64a88d38afee063baa23c7fce03757023c999"}, ] [package.dependencies] @@ -170,7 +155,8 @@ async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" -yarl = ">=1.12.0,<2.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] @@ -3440,4 +3426,4 @@ sqlite = ["aiosqlite"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "0ad3424fdf37339ba383115e986902beb28f803e5baa5deda178eb75c410e42f" +content-hash = "e2db14016c676808832b37056ffe1f96fe66d0f2fc6c5f358d4163b5d9ece7c6" diff --git a/pyproject.toml b/pyproject.toml index 6c237be0..37188d32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ APScheduler = "^3.9.1" redis = {version = ">=4.3.4,<6.0.0", optional = true} hiredis = {version = ">=2,<4", optional = true} slack-sdk = "^3.18.1" -aiohttp = ">=3.8.5,<3.11.0" +aiohttp = "^3.8.5" pyee = ">=9.0.4,<13.0.0" httpx = ">=0.23,<0.28" aioboto3 = {version = ">=10,<14", optional = true} From 833e7fd248ddfe786ac50946119382b342debd7a Mon Sep 17 00:00:00 2001 From: Daan Debie Date: Sun, 26 May 2024 17:33:28 +0200 Subject: [PATCH 2/4] feat: support modals (fixes #1036) - Add plugin methods for creating and updating modals and publishing the app home tab - Add models for view submissions and views closed - Add decorators for modals - Register modal handlers during plugin registration - Handle view submission and closed events - Add convenience methods to interaction models The following interaction models have convenience methods for dealing with modals: - `ModalSubmission` has methods for updating the submitted modal, pushing a new view or opening a new modal. It also has a method to send DMs to the user that submitted the modal - `ModalClosure` has a method to send DMs to the user that closed the modal - `BlockAction` now lets you send a DM to the user that triggered the block action or open a modal feat: expose open_im for plugins docs: document modal-related functionality --- README.md | 4 +- docs/api.md | 6 + docs/index.md | 4 +- docs/plugins/block-kit-actions.md | 9 +- docs/plugins/interacting.md | 2 +- docs/plugins/modals.md | 104 +++++++++++ docs/plugins/slash-commands.md | 16 +- machine/clients/slack.py | 44 +++-- machine/core.py | 59 +++++- machine/handlers/interactive_handler.py | 99 +++++++++- machine/handlers/logging.py | 7 +- machine/models/core.py | 14 +- machine/models/interactive.py | 16 +- machine/plugins/base.py | 84 ++++++++- machine/plugins/block_action.py | 53 +++++- machine/plugins/command.py | 20 +- machine/plugins/decorators.py | 85 +++++---- machine/plugins/metadata.py | 47 +++++ machine/plugins/modals.py | 182 +++++++++++++++++++ mkdocs.yml | 2 +- pyproject.toml | 2 +- tests/clients/test_slack_client.py | 7 +- tests/fake_plugins.py | 14 +- tests/handlers/conftest.py | 47 ++++- tests/handlers/requests.py | 173 ++++++++++++++++++ tests/handlers/test_command_handler.py | 2 +- tests/handlers/test_interactive_handler.py | 116 ++++++------ tests/handlers/test_logging.py | 2 +- tests/models/example_payloads/view_closed.py | 166 +++++++++++++++++ tests/models/test_block_actions.py | 50 +++-- tests/models/test_views.py | 15 ++ tests/plugins/test_decorators.py | 100 +++++++++- tests/test_plugin_registration.py | 27 ++- 33 files changed, 1386 insertions(+), 192 deletions(-) create mode 100644 docs/plugins/modals.md create mode 100644 machine/plugins/metadata.py create mode 100644 machine/plugins/modals.py create mode 100644 tests/handlers/requests.py create mode 100644 tests/models/example_payloads/view_closed.py create mode 100644 tests/models/test_views.py diff --git a/README.md b/README.md index 5c036739..8d063eb7 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ class DeploymentPlugin(MachineBasePlugin): **Dropped support for Python 3.8** (v0.38.0) As of [v0.38.0](https://github.com/DonDebonair/slack-machine/releases/tag/v0.38.0), support for Python 3.8 has been -dropped. Python 3.7 has reached end-of-life on 2024-10-07. +dropped. Python 3.8 has reached end-of-life on 2024-10-07. ## Features @@ -59,6 +59,7 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07. - Support for [blocks](https://api.slack.com/reference/block-kit/blocks) - Support for [message attachments](https://api.slack.com/docs/message-attachments) [Legacy 🏚] - Support for [interactive elements](https://api.slack.com/block-kit) +- Support for [modals](https://api.slack.com/surfaces/modals) - Listen and respond to any [Slack event](https://api.slack.com/events) supported by the Events API - Store and retrieve any kind of data in persistent storage (currently Redis, DynamoDB, SQLite and in-memory storage are supported) @@ -68,7 +69,6 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07. ### Coming Soon -- Support for modals - Support for shortcuts - ... and much more diff --git a/docs/api.md b/docs/api.md index 286fe646..3fa989ed 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,6 +18,10 @@ The following classes form the basis for Plugin development. ### ::: machine.plugins.block_action.BlockAction +### ::: machine.plugins.modals.ModalSubmission + +### ::: machine.plugins.modals.ModalClosure + ## Decorators @@ -34,6 +38,8 @@ These classes represent base objects from the Slack API ### ::: machine.models.channel.Channel +### ::: machine.models.interactive.View + ## Storage Storage is exposed to plugins through the `self.storage` field. The following class implements the interface plugins diff --git a/docs/index.md b/docs/index.md index 01291a72..812f0341 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ class DeploymentPlugin(MachineBasePlugin): **Dropped support for Python 3.8** (v0.38.0) As of [v0.38.0](https://github.com/DonDebonair/slack-machine/releases/tag/v0.38.0), support for Python 3.8 has been -dropped. Python 3.7 has reached end-of-life on 2024-10-07. +dropped. Python 3.8 has reached end-of-life on 2024-10-07. ## Features @@ -59,6 +59,7 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07. - Support for [blocks](https://api.slack.com/reference/block-kit/blocks) - Support for [message attachments](https://api.slack.com/docs/message-attachments) [Legacy 🏚] - Support for [interactive elements](https://api.slack.com/block-kit) +- Support for [modals](https://api.slack.com/surfaces/modals) - Listen and respond to any [Slack event](https://api.slack.com/events) supported by the Events API - Store and retrieve any kind of data in persistent storage (currently Redis, DynamoDB, SQLite, and in-memory storage are supported) @@ -68,6 +69,5 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07. ### Coming Soon -- Support for modals - Support for shortcuts - ... and much more diff --git a/docs/plugins/block-kit-actions.md b/docs/plugins/block-kit-actions.md index a9bc26e5..18378657 100644 --- a/docs/plugins/block-kit-actions.md +++ b/docs/plugins/block-kit-actions.md @@ -8,8 +8,9 @@ Slack Machine makes it easy to listen to _actions_ triggered by these interactiv ## Defining actions -When you're defining [blocks](https://api.slack.com/reference/block-kit ) for your interactive surfaces, each of -these blocks can be given a `block_id`. Within certain blocks, you can place +When you're defining [blocks](https://api.slack.com/reference/block-kit ) for your interactive surfaces - either by +providing a [dict][] or by leveraging the [models of the Slack SDK for Python](https://tools.slack.dev/python-slack-sdk/api-docs/slack_sdk/models/blocks/index.html) +- each of these blocks can be given a `block_id`. Within certain blocks, you can place [block elements](https://api.slack.com/reference/block-kit/block-elements) that are interactive. These interactive elements can be given an `action_id`. Given that one block can contain multiple action elements, each `block_id` can be linked to multiple `action_id`s. @@ -19,7 +20,7 @@ Whenever the user interacts with these elements, an event is sent to Slack Machi ## Listening to actions -With the [`action`][machine.plugins.decorators.action] decorator you can define which plugin methods should be +With the [`@action`][machine.plugins.decorators.action] decorator you can define which plugin methods should be called when a certain action is triggered. The decorator takes 2 arguments: the `block_id` and the `action_id` that you want to listen to. Both arguments are optional, but **one of them always needs to be set**. Both arguments accept a [`str`][str] or [`re.Pattern`][re.Pattern]. When a string is provided, the handler only fires upon an exact match, @@ -34,7 +35,7 @@ Your block action handler will be called with a [`BlockAction`][machine.plugins. contains useful information about the action that was triggered and the message or other surface in which the action was triggered. -You can optionally pass the `logger` argument to get a +You can optionally add the `logger` argument to your handler get a [logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions) The [`BlockAction`][machine.plugins.block_action.BlockAction] contains various useful fields and properties about diff --git a/docs/plugins/interacting.md b/docs/plugins/interacting.md index 6690560d..d7c8b673 100644 --- a/docs/plugins/interacting.md +++ b/docs/plugins/interacting.md @@ -219,7 +219,7 @@ You can read [the events section][slack-machine-events] to see how your plugin c ## Using the Slack Web API in other ways Sometimes you want to use [Slack Web API](https://api.slack.com/web) in ways that are not directly exposed by -[`MachineBaserPlugin`][machine.plugins.base.MachineBasePlugin]. In these cases you can use +[`MachineBasePlugin`][machine.plugins.base.MachineBasePlugin]. In these cases you can use [`self.web_client`][machine.plugins.base.MachineBasePlugin.web_client]. `self.web_client` references the [`AsyncWebClient`](https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/async_client.html#slack_sdk.web.async_client.AsyncWebClient) object of the underlying Slack Python SDK. You should be able to call any diff --git a/docs/plugins/modals.md b/docs/plugins/modals.md new file mode 100644 index 00000000..1cbdf4dd --- /dev/null +++ b/docs/plugins/modals.md @@ -0,0 +1,104 @@ +# Modals + +In Slack, [modals](https://api.slack.com/surfaces/modals) are a way to ask users for input or display information in a +dialog/popup-like form. Modals are a great way to collect information from users, display information, or confirm an +action. + +Modals can only be triggered by [actions the user takes](https://api.slack.com/interactivity#user). The most +common types of user actions that can trigger a modal are: + +- Shortcuts +- [Slash Commands][slash-commands] +- [Block Kit interative components][block-kit-actions] + +For each of these actions, Slack provides a `trigger_id` which can be used to open a modal. **This needs to be done +within 3 seconds of receiving the `trigger_id`**. Slack Machine abstracts most of this away for you and lets you +open modals from Slash Commands and Block Kit actions without having to worry about the `trigger_id`. + +## Defining and opening modals + +When you want to open a modal, you first need to define the +[_view_](https://api.slack.com/surfaces/modals#composing_views) with the content you want to show. This view has +some additional properties that define how the modal should behave. One important property is the `callback_id` which +is used to identify the modal when it is submitted or closed. + +You can define a modal view in 2 ways: + +- As a [dict][] that conforms to the [View schema](https://api.slack.com/reference/surfaces/views#modal) +- By constructing a [View](https://tools.slack.dev/python-slack-sdk/api-docs/slack_sdk/models/views/index.html#slack_sdk.models.views.View) + object from the Slack SDK for Python + +When you have defined the view, you can open the modal by calling the `open_modal` method on the +[`Command`][machine.plugins.command.Command] object that is passed to your Slash Command handler or on the +[`BlockAction`][machine.plugins.block_action.BlockAction] object that is passed to your Block Kit action handler. + +## Listening for modal interactions + +Once a modal is opened, the user can interact with the block kit elements within the modal, such as buttons, input +fields, datepickers etc. When the user interacts with these elements, a [block kit action](block-kit-actions.md) can be +triggered which lets you deal with input. + +Additionally, when the user _submits_ the modal, this triggers a `view_submission` event that you can listen to with +the [`@modal`][machine.plugins.decorators.modal] decorator. This decorator takes a `callback_id` as +an argument, which is used to identify the modal that was submitted. The `callback_id` can be a [`str`][str] or a +[`re.Pattern`][re.Pattern]. When a string is provided, the handler only fires upon an exact match, whereas with a regex +pattern, you can have the handler fired for multiple matching `callback_id`s. This is convenient when you want one +handler to process multiple modals, for example. + +Unless you want to listen for changes to specific input fields - for example to update the modal in-place - it's +probably easiest to use the `@modal` decorator and process the entire input upon modal submission. + +### The modal handler function + +The handler function will be called with a +[`ModalSubmission`][machine.plugins.modals.ModalSubmission] object that contains useful information about the +modal that was submitted and the user that submitted it. The `ModalSubmission` object has a property +[`view`][machine.plugins.modals.ModalSubmission.view] that contains the complete view that was submitted, including the +state of the input fields of the modal. The object also has a [`user`][machine.plugins.modals.ModalSubmission.user] +property that corresponds to the user that submitted the modal. + +You can optionally add the `logger` argument to your handler get a +[logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions) + +The `ModalSubmission` contains methods for +[updating the current modal view][machine.plugins.modals.ModalSubmission.update_modal], +[pushing a new view on top of the current one][machine.plugins.modals.ModalSubmission.push_modal] or even +[opening a completely new modal][machine.plugins.modals.ModalSubmission.open_modal]. + +You can also send a message to the user that submitted the modal with the +[`send_dm`][machine.plugins.modals.ModalSubmission.send_dm] method. + +The modal handler function can be defined as a regular `async` function or a generator. When you define it as a +generator, you can use the `yield` statement to: + +- [Update the modal view in-place](https://api.slack.com/surfaces/modals#updating_response) +- [Push a new view on top of the current one](https://api.slack.com/surfaces/modals#add_response) +- [Close the current view](https://api.slack.com/surfaces/modals#close_current_view) (which is the default behavior when + nothing is yielded) +- [Close all the views on the stack](https://api.slack.com/surfaces/modals#close_all_views) +- [Display errors in the modal](https://api.slack.com/surfaces/modals#displaying_errors), which is useful when you want + to show validation errors to the user. + +!!! warning + + You must yield a response to Slack within 3 seconds of receiving the `view_submission` event. If you don't, Slack + will show an error to the user. + + +## Listening for modal closures + +Sometimes you want to know when a user closes a modal without submitting it. This can be useful to clean up +resources or store the state of the modal for later continuation. You can listen for modal closures with the +[`@modal_closed`][machine.plugins.decorators.modal_closed] decorator. This decorator takes a `callback_id` as parameter +and works the same way as the `@modal` decorator. + +The handler function will be called with a +[`ModalClosure`][machine.plugins.modals.ModalClosure] object that contains information about the modal that was +closed. Just like the `ModalSubmission` object, the `ModalClosure` object has a +[`view`][machine.plugins.modals.ModalSubmission.view] property that contains the complete view of the modal that was +closed, including the state of the input fields. The object also has a +[`user`][machine.plugins.modals.ModalClosure.user] property that corresponds to the user that submitted the +modal. + +You can send a message to the user that submitted the modal with the +[`send_dm`][machine.plugins.modals.ModalClosure.send_dm] method. diff --git a/docs/plugins/slash-commands.md b/docs/plugins/slash-commands.md index 2c3dfb20..696963f8 100644 --- a/docs/plugins/slash-commands.md +++ b/docs/plugins/slash-commands.md @@ -34,7 +34,7 @@ features: ## Defining your Slash Command in code -The next step is to use the [`command`][machine.plugins.decorators.command] decorator on the function that should be +The next step is to use the [`@command`][machine.plugins.decorators.command] decorator on the function that should be triggered when the user uses the Slash Command you defined. The decorator takes only 1 parameter: the slash command that should trigger the decorated function. It should be the same as the Slash Command you just defined in the App dashboard. @@ -55,7 +55,7 @@ information about the slash command invocation. The most important property is p [`text`][machine.plugins.command.Command.text], which contains any additional text that was passed when the slash command was used. -You can optionally pass the `logger` argument to get a +You can optionally add the `logger` argument to your handler get a [logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions) ### Responding to a command @@ -96,14 +96,10 @@ async def hello_again(self, command): await command.say("This will be sent after the initial acknowledgement") ``` -## Other types of responses +## Opening modals The [`Command`][machine.plugins.command.Command] object that your handler receives, contains an extra piece of information you can use to trigger more varied reponses: the [`trigger_id`][machine.plugins.command.Command.trigger_id] -The `trigger_id` can used specifically to trigger -[_modal responses_](https://api.slack.com/interactivity/handling#modal_responses). For now, creating a modal is -something you have to take care of yourself. More information on this can be found -[here](https://api.slack.com/surfaces/modals/using). - -In future releases, Slack Machine will make working with modals much easier by allowing modals to be opened directly -through the provided command object, and responding to interactions happening in modals through new decorators. +The `trigger_id` can used specifically to trigger [_modal responses_][modals]. You don't have to worry about the +`trigger_id` and instead can just call the [`open_modal`][machine.plugins.command.Command.open_modal] method on the +`Command` object to open a modal. diff --git a/machine/clients/slack.py b/machine/clients/slack.py index 474c1002..c4563bb3 100644 --- a/machine/clients/slack.py +++ b/machine/clients/slack.py @@ -1,11 +1,11 @@ from __future__ import annotations import asyncio -import sys from datetime import datetime from typing import Any, AsyncGenerator, Awaitable, Callable from slack_sdk.errors import SlackApiError +from slack_sdk.models.views import View from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest @@ -13,15 +13,11 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse from structlog.stdlib import get_logger +from zoneinfo import ZoneInfo from machine.models import Channel, User from machine.utils.datetime import calculate_epoch -if sys.version_info >= (3, 9): - from zoneinfo import ZoneInfo # pragma: no cover -else: - from backports.zoneinfo import ZoneInfo # pragma: no cover - logger = get_logger(__name__) @@ -293,24 +289,22 @@ async def react(self, channel: Channel | str, ts: str, emoji: str) -> AsyncSlack channel_id = id_for_channel(channel) return await self._client.web_client.reactions_add(name=emoji, channel=channel_id, timestamp=ts) - async def open_im(self, user: User | str) -> str: - user_id = id_for_user(user) - response = await self._client.web_client.conversations_open(users=user_id) + async def open_im(self, users: User | str | list[User | str]) -> str: + user_ids = [id_for_user(user) for user in users] if isinstance(users, list) else id_for_user(users) + response = await self._client.web_client.conversations_open(users=user_ids) return response["channel"]["id"] async def send_dm(self, user: User | str, text: str | None, **kwargs: Any) -> AsyncSlackResponse: user_id = id_for_user(user) - dm_channel_id = await self.open_im(user_id) - return await self._client.web_client.chat_postMessage(channel=dm_channel_id, text=text, as_user=True, **kwargs) + return await self._client.web_client.chat_postMessage(channel=user_id, text=text, as_user=True, **kwargs) async def send_dm_scheduled(self, when: datetime, user: User | str, text: str, **kwargs: Any) -> AsyncSlackResponse: user_id = id_for_user(user) - dm_channel_id = await self.open_im(user_id) scheduled_ts = calculate_epoch(when, self._tz) return await self._client.web_client.chat_scheduleMessage( - channel=dm_channel_id, text=text, as_user=True, post_at=scheduled_ts, **kwargs + channel=user_id, text=text, as_user=True, post_at=scheduled_ts, **kwargs ) async def pin_message(self, channel: Channel | str, ts: str) -> AsyncSlackResponse: @@ -324,3 +318,27 @@ async def unpin_message(self, channel: Channel | str, ts: str) -> AsyncSlackResp async def set_topic(self, channel: Channel | str, topic: str, **kwargs: Any) -> AsyncSlackResponse: channel_id = id_for_channel(channel) return await self._client.web_client.conversations_setTopic(channel=channel_id, topic=topic, **kwargs) + + async def open_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse: + return await self._client.web_client.views_open(trigger_id=trigger_id, view=view, **kwargs) + + async def push_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse: + return await self._client.web_client.views_push(trigger_id=trigger_id, view=view, **kwargs) + + async def update_modal( + self, + view: dict | View, + view_id: str | None = None, + external_id: str | None = None, + hash: str | None = None, + **kwargs: Any, + ) -> AsyncSlackResponse: + return await self._client.web_client.views_update( + view=view, view_id=view_id, external_id=external_id, hash=hash, **kwargs + ) + + async def publish_home_tab( + self, user: User | str, view: dict | View, hash: str | None = None, **kwargs: Any + ) -> AsyncSlackResponse: + user_id = id_for_user(user) + return await self._client.web_client.views_publish(user_id=user_id, view=view, hash=hash, **kwargs) diff --git a/machine/core.py b/machine/core.py index 17a96532..eb36f688 100644 --- a/machine/core.py +++ b/machine/core.py @@ -12,6 +12,7 @@ from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.web.async_client import AsyncWebClient from structlog.stdlib import get_logger +from zoneinfo import ZoneInfo from machine.clients.slack import SlackClient from machine.handlers import ( @@ -27,22 +28,19 @@ HumanHelp, Manual, MessageHandler, + ModalHandler, RegisteredActions, - action_block_id_to_str, + matcher_to_str, ) from machine.plugins.base import MachineBasePlugin -from machine.plugins.decorators import ActionConfig, CommandConfig, DecoratedPluginFunc, MatcherConfig, Metadata +from machine.plugins.decorators import DecoratedPluginFunc +from machine.plugins.metadata import ActionConfig, CommandConfig, MatcherConfig, Metadata, ModalConfig from machine.settings import import_settings from machine.storage import MachineBaseStorage, PluginStorage from machine.utils.collections import CaseInsensitiveDict from machine.utils.logging import configure_logging from machine.utils.module_loading import import_string -if sys.version_info >= (3, 9): - from zoneinfo import ZoneInfo # pragma: no cover -else: - from backports.zoneinfo import ZoneInfo # pragma: no cover - logger = get_logger(__name__) @@ -236,6 +234,26 @@ def _register_plugin_actions( block_action_config=block_action_config, class_help=class_help, ) + for modal_config in metadata.plugin_actions.modal_submissions: + self._register_modal_handler( + type_="modal", + class_=cls_instance, + class_name=plugin_class_name, + fq_fn_name=fq_fn_name, + function=fn, + modal_config=modal_config, + class_help=class_help, + ) + for modal_config in metadata.plugin_actions.modal_closures: + self._register_modal_handler( + type_="modal_closed", + class_=cls_instance, + class_name=plugin_class_name, + fq_fn_name=fq_fn_name, + function=fn, + modal_config=modal_config, + class_help=class_help, + ) if metadata.plugin_actions.schedule is not None: self._scheduler.add_job( @@ -314,11 +332,34 @@ def _register_block_action_handler( action_id_matcher=block_action_config.action_id, block_id_matcher=block_action_config.block_id, ) - action_id = action_block_id_to_str(block_action_config.action_id) - block_id = action_block_id_to_str(block_action_config.block_id) + action_id = matcher_to_str(block_action_config.action_id) + block_id = matcher_to_str(block_action_config.block_id) key = f"{fq_fn_name}-{action_id}-{block_id}" self._registered_actions.block_actions[key] = handler + def _register_modal_handler( + self, + type_: Literal["modal", "modal_closed"], + class_: MachineBasePlugin, + class_name: str, + fq_fn_name: str, + function: Callable[..., Awaitable[None]], + modal_config: ModalConfig, + class_help: str, + ) -> None: + signature = Signature.from_callable(function) + logger.debug("signature of modal handler", signature=signature, function=fq_fn_name) + handler = ModalHandler( + class_=class_, + class_name=class_name, + function=function, + function_signature=signature, + callback_id_matcher=modal_config.callback_id, + is_generator=modal_config.is_generator, + ) + key = f"{fq_fn_name}-{matcher_to_str(modal_config.callback_id)}" + getattr(self._registered_actions, type_)[key] = handler + @staticmethod def _parse_human_help(doc: str) -> HumanHelp: summary = doc.splitlines()[0].split(":") diff --git a/machine/handlers/interactive_handler.py b/machine/handlers/interactive_handler.py index bfd8f4f9..7d2a1c10 100644 --- a/machine/handlers/interactive_handler.py +++ b/machine/handlers/interactive_handler.py @@ -1,9 +1,11 @@ from __future__ import annotations import asyncio +import contextlib import re -from typing import Awaitable, Callable, Union +from typing import AsyncGenerator, Awaitable, Callable, Union, cast +from slack_sdk.models.views import View from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse @@ -12,8 +14,15 @@ from machine.clients.slack import SlackClient from machine.handlers.logging import create_scoped_logger from machine.models.core import RegisteredActions -from machine.models.interactive import Action, BlockActionsPayload, InteractivePayload +from machine.models.interactive import ( + Action, + BlockActionsPayload, + InteractivePayload, + ViewClosedPayload, + ViewSubmissionPayload, +) from machine.plugins.block_action import BlockAction +from machine.plugins.modals import ModalClosure, ModalSubmission logger = get_logger(__name__) @@ -25,13 +34,19 @@ def create_interactive_handler( async def handle_interactive_request(client: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None: if request.type == "interactive": logger.debug("interactive trigger received", payload=request.payload) - # Acknowledge the request anyway - response = SocketModeResponse(envelope_id=request.envelope_id) - # Don't forget having await for method calls - await client.send_socket_mode_response(response) parsed_payload = InteractivePayload.validate_python(request.payload) if parsed_payload.type == "block_actions": + # Acknowledge the request + response = SocketModeResponse(envelope_id=request.envelope_id) + await client.send_socket_mode_response(response) await handle_block_actions(parsed_payload, plugin_actions, slack_client) + if parsed_payload.type == "view_submission": + await handle_view_submission(parsed_payload, request.envelope_id, client, plugin_actions, slack_client) + if parsed_payload.type == "view_closed": + # Acknowledge the request + response = SocketModeResponse(envelope_id=request.envelope_id) + await client.send_socket_mode_response(response) + await handle_view_closed(parsed_payload, plugin_actions, slack_client) return handle_interactive_request @@ -64,6 +79,70 @@ async def handle_block_actions( await asyncio.gather(*handler_funcs) +async def handle_view_submission( + payload: ViewSubmissionPayload, + envelope_id: str, + socket_mode_client: AsyncBaseSocketModeClient, + plugin_actions: RegisteredActions, + slack_client: SlackClient, +) -> None: + handler_funcs = [] + modal_submission_obj = _gen_modal_submission(payload, slack_client) + for handler in plugin_actions.modal.values(): + if _matches(handler.callback_id_matcher, payload.view.callback_id): + if "logger" in handler.function_signature.parameters: + view_submission_logger = create_scoped_logger( + handler.class_name, + handler.function.__name__, + user_id=payload.user.id, + user_name=payload.user.name, + ) + extra_args = {"logger": view_submission_logger} + else: + extra_args = {} + # Check if the handler is a generator. In this case we have an immediate response we can send back + if handler.is_generator: + gen_fn = cast(Callable[..., AsyncGenerator[Union[dict, View], None]], handler.function) + logger.debug("Modal submission handler is generator, returning immediate ack") + gen = gen_fn(modal_submission_obj, **extra_args) + # return immediate reponse + response = await gen.__anext__() + ack_response = SocketModeResponse(envelope_id=envelope_id, payload=response) + await socket_mode_client.send_socket_mode_response(ack_response) + # Now run the rest of the function + with contextlib.suppress(StopAsyncIteration): + await gen.__anext__() + else: + logger.debug("Modal submission is regular async function") + ack_response = SocketModeResponse(envelope_id=envelope_id) + await socket_mode_client.send_socket_mode_response(ack_response) + handler_funcs.append(handler.function(modal_submission_obj, **extra_args)) + await asyncio.gather(*handler_funcs) + + +async def handle_view_closed( + payload: ViewClosedPayload, + plugin_actions: RegisteredActions, + slack_client: SlackClient, +) -> None: + handler_funcs = [] + modal_submission_obj = _gen_modal_closure(payload, slack_client) + for handler in plugin_actions.modal_closed.values(): + if _matches(handler.callback_id_matcher, payload.view.callback_id): + if "logger" in handler.function_signature.parameters: + view_closure_logger = create_scoped_logger( + handler.class_name, + handler.function.__name__, + user_id=payload.user.id, + user_name=payload.user.name, + ) + extra_args = {"logger": view_closure_logger} + else: + extra_args = {} + handler_funcs.append(handler.function(modal_submission_obj, **extra_args)) + await asyncio.gather(*handler_funcs) + + def _matches(matcher: Union[re.Pattern[str], str, None], input_: str) -> bool: if matcher is None: return True @@ -74,3 +153,11 @@ def _matches(matcher: Union[re.Pattern[str], str, None], input_: str) -> bool: def _gen_block_action(payload: BlockActionsPayload, triggered_action: Action, slack_client: SlackClient) -> BlockAction: return BlockAction(slack_client, payload, triggered_action) + + +def _gen_modal_submission(payload: ViewSubmissionPayload, slack_client: SlackClient) -> ModalSubmission: + return ModalSubmission(slack_client, payload) + + +def _gen_modal_closure(payload: ViewClosedPayload, slack_client: SlackClient) -> ModalClosure: + return ModalClosure(slack_client, payload) diff --git a/machine/handlers/logging.py b/machine/handlers/logging.py index 23d3e290..fbad603f 100644 --- a/machine/handlers/logging.py +++ b/machine/handlers/logging.py @@ -8,7 +8,12 @@ async def log_request(_: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None: - logger.debug("Request received", type=request.type, request=request.to_dict()) + logger.debug( + "Request received", + type=request.type, + request=request.to_dict(), + accepts_response_payload=request.accepts_response_payload, + ) def create_scoped_logger(class_name: str, function_name: str, **kwargs: Any) -> BoundLogger: diff --git a/machine/models/core.py b/machine/models/core.py index 7793a78a..ee141821 100644 --- a/machine/models/core.py +++ b/machine/models/core.py @@ -52,6 +52,16 @@ class BlockActionHandler: block_id_matcher: Union[re.Pattern[str], str, None] +@dataclass +class ModalHandler: + class_: MachineBasePlugin + class_name: str + function: Callable[..., Awaitable[None]] + function_signature: Signature + callback_id_matcher: Union[re.Pattern[str], str] + is_generator: bool + + @dataclass class RegisteredActions: listen_to: dict[str, MessageHandler] = field(default_factory=dict) @@ -59,9 +69,11 @@ class RegisteredActions: process: dict[str, dict[str, Callable[[dict[str, Any]], Awaitable[None]]]] = field(default_factory=dict) command: dict[str, CommandHandler] = field(default_factory=dict) block_actions: dict[str, BlockActionHandler] = field(default_factory=dict) + modal: dict[str, ModalHandler] = field(default_factory=dict) + modal_closed: dict[str, ModalHandler] = field(default_factory=dict) -def action_block_id_to_str(id_: Union[str, re.Pattern[str], None]) -> str: +def matcher_to_str(id_: Union[str, re.Pattern[str], None]) -> str: if id_ is None: return "*" elif isinstance(id_, str): diff --git a/machine/models/interactive.py b/machine/models/interactive.py index 4ad3994b..d8934c8e 100644 --- a/machine/models/interactive.py +++ b/machine/models/interactive.py @@ -424,6 +424,18 @@ class ViewSubmissionPayload(TypedModel): is_enterprise_install: bool -InteractivePayload: TypeAdapter[Union[BlockActionsPayload, ViewSubmissionPayload]] = TypeAdapter( - Annotated[Union[BlockActionsPayload, ViewSubmissionPayload], Field(discriminator="type")] +class ViewClosedPayload(TypedModel): + type: Literal["view_closed"] + team: Team + user: User + view: View + enterprise: Optional[str] + api_app_id: str + token: str + is_cleared: bool + is_enterprise_install: bool + + +InteractivePayload: TypeAdapter[Union[BlockActionsPayload, ViewSubmissionPayload, ViewClosedPayload]] = TypeAdapter( + Annotated[Union[BlockActionsPayload, ViewSubmissionPayload, ViewClosedPayload], Field(discriminator="type")] ) diff --git a/machine/plugins/base.py b/machine/plugins/base.py index 5c67c06c..d46bea38 100644 --- a/machine/plugins/base.py +++ b/machine/plugins/base.py @@ -5,6 +5,7 @@ from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block +from slack_sdk.models.views import View from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse @@ -268,7 +269,6 @@ async def update( :param text: message text :param attachments: optional attachments (see [attachments]) :param blocks: optional blocks (see [blocks]) - :param thread_ts: optional timestamp of thread, to send a message in that thread :param ephemeral_user: optional user name or id if the message needs to visible to a specific user only :return: Dictionary deserialized from [`chat.update`](https://api.slack.com/methods/chat.update) request @@ -352,7 +352,7 @@ async def send_dm( :param text: message text :param attachments: optional attachments (see `attachments`_) :param blocks: optional blocks (see `blocks`_) - :return: Dictionary deserialized from `chat.postMessage`_ request. + :return: Dictionary deserialized from `chat.postMessage`_ response. .. _chat.postMessage: https://api.slack.com/methods/chat.postMessage """ @@ -387,6 +387,18 @@ async def send_dm_scheduled( when, user, text=text, attachments=attachments, blocks=blocks, **kwargs ) + async def open_im(self, users: User | str | list[User | str]) -> str: + """Open a DM channel with one or more users + + Open a DM channel with one or more users. If the DM channel already exists, the existing channel id + will be returned. If the DM channel does not exist, a new channel will be created and the + id of the new channel will be returned. + + :param users: :py:class:`~machine.models.user.User` object or id of user to open DM with. + :return: id of the DM channel + """ + return await self._client.open_im(users) + def emit(self, event: str, **kwargs: Any) -> None: """Emit an event @@ -431,3 +443,71 @@ async def set_topic(self, channel: Channel | str, topic: str, **kwargs: Any) -> :return: response from the Slack Web API """ return await self._client.set_topic(channel, topic, **kwargs) + + async def open_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse: + """Open a modal dialog + + Open a modal dialog in response to a user action. The modal dialog can be used to collect + information from the user, or to display information to the user. + + :param trigger_id: trigger id is provided by Slack when a user action is performed, such as a slash command + or a button click + :param view: view definition for the modal dialog + :return: response from the Slack Web API + """ + return await self._client.web_client.views_open(trigger_id=trigger_id, view=view, **kwargs) + + async def push_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse: + """Push a new view onto the stack of a modal that was already opened + + Push a new view onto the stack of a modal that was already opened by a open_modal call. At most 3 views can be + active in a modal at the same time. For more information on the lifecycle of modals, refer to the + [relevant Slack documentation](https://api.slack.com/surfaces/modals) + + :param trigger_id: trigger id is provided by Slack when a user action is performed, such as a slash command + or a button click + :param view: view definition for the modal dialog + :return: response from the Slack Web API + """ + return await self._client.push_modal(trigger_id=trigger_id, view=view, **kwargs) + + async def update_modal( + self, + view: dict | View, + view_id: str | None = None, + external_id: str | None = None, + hash: str | None = None, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Update a modal dialog + + Update a modal dialog that was previously opened. You can update the view by providing the view_id or the + external_id of the modal. external_id has precedence over view_id, but at least one needs to be provided. + You can also provide a hash of the view that you want to update to prevent race conditions. + + :param view: view definition for the modal dialog + :param view_id: id of the view to update + :param external_id: external id of the view to update + :param hash: hash of the view to update + :return: response from the Slack Web API + """ + return await self._client.update_modal(view=view, view_id=view_id, external_id=external_id, hash=hash, **kwargs) + + async def publish_home_tab( + self, user: User | str, view: dict | View, hash: str | None = None, **kwargs: Any + ) -> AsyncSlackResponse: + """Publish a view to the home tab of a user + + Publish a view to the home tab of a user. The view will be visible to the user when they open the home tab of + your Slack app. This method can be used both to publish a new view for the home tab or update an existing view. + You can provide a hash of the view that you want to update to prevent race conditions. + + Note: be careful with the use of this method, as you might be overwriting the user's home tab that was set by + another Slack Machine plugin enabled in your bot. + + :param user: user for whom to publish or update the home tab + :param view: view definition for the home tab + :param hash: hash of the view to update + :return: response from the Slack Web API + """ + return await self._client.publish_home_tab(user=user, view=view, hash=hash, **kwargs) diff --git a/machine/plugins/block_action.py b/machine/plugins/block_action.py index 59db9244..eab4bdec 100644 --- a/machine/plugins/block_action.py +++ b/machine/plugins/block_action.py @@ -4,6 +4,8 @@ from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block +from slack_sdk.models.views import View +from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.webhook import WebhookResponse from slack_sdk.webhook.async_client import AsyncWebhookClient from structlog.stdlib import get_logger @@ -30,7 +32,7 @@ class BlockAction: def __init__(self, client: SlackClient, payload: BlockActionsPayload, triggered_action: Action): self._client = client - self.payload = payload #: blablab + self.payload = payload """The payload that was received by the bot when the action was triggered that this plugin method listens for""" self.triggered_action = triggered_action """The action that triggered this plugin method""" @@ -74,7 +76,7 @@ def response_url(self) -> Optional[str]: def trigger_id(self) -> str: """The trigger id associated with the action - The trigger id can be user ot open a modal + The trigger id can be used to open a modal :return: the trigger id for the action """ @@ -136,3 +138,50 @@ async def say( delete_original=delete_original, **kwargs, ) + + async def send_dm( + self, + text: str | None = None, + attachments: Sequence[Attachment] | Sequence[dict[str, Any]] | None = None, + blocks: Sequence[Block] | Sequence[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Send a DM to the user that triggered the block action + + Send a Direct Message to the user that triggered the block action by opening a DM channel and + sending a message to it. Allows for rich formatting using `blocks`_ and/or `attachments`_. + Allows for rich formatting using [blocks] and/or [attachments] . You can provide blocks + and attachments as Python dicts or you can use the [convenient classes] that the + underlying slack client provides. + Any extra kwargs you provide, will be passed on directly to the `chat.postMessage` request. + + [attachments]: https://api.slack.com/docs/message-attachments + [blocks]: https://api.slack.com/reference/block-kit/blocks + [convenient classes]: https://github.com/slackapi/python-slack-sdk/tree/main/slack/web/classes + + :param text: message text + :param attachments: optional attachments (see [attachments]) + :param blocks: optional blocks (see [blocks]) + :return: Dictionary deserialized from [chat.postMessage] response. + + [chat.postMessage]: https://api.slack.com/methods/chat.postMessage + """ + return await self._client.send_dm(self.user.id, text, attachments=attachments, blocks=blocks, **kwargs) + + async def open_modal( + self, + view: dict | View, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Open a modal in response to the block action + + Open a modal in response to the block action, using the trigger_id that was returned when the block action was + triggered. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_open()` + + Note: you have to call this method within 3 seconds of receiving the block action payload. + + :param view: the view to open + :return: Dictionary deserialized from `AsyncWebClient.views_open()` + """ + return await self._client.open_modal(self.trigger_id, view, **kwargs) diff --git a/machine/plugins/command.py b/machine/plugins/command.py index 31ef61c8..55be366b 100644 --- a/machine/plugins/command.py +++ b/machine/plugins/command.py @@ -4,6 +4,8 @@ from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block +from slack_sdk.models.views import View +from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.webhook import WebhookResponse from slack_sdk.webhook.async_client import AsyncWebhookClient @@ -116,10 +118,26 @@ async def say( :param ephemeral: `True/False` wether to send the message as an ephemeral message, only visible to the sender of the original message :return: Dictionary deserialized from `AsyncWebhookClient.send()` - """ response_type = "ephemeral" if ephemeral else "in_channel" return await self._webhook_client.send( text=text, attachments=attachments, blocks=blocks, response_type=response_type, **kwargs ) + + async def open_modal( + self, + view: dict | View, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Open a modal in response to the command + + Open a modal in response to the command, using the trigger_id that was returned when the command was invoked. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_open()` + + Note: you have to call this method within 3 seconds of receiving the command payload. + + :param view: the view to open + :return: Dictionary deserialized from `AsyncWebClient.views_open()` + """ + return await self._client.open_modal(self.trigger_id, view, **kwargs) diff --git a/machine/plugins/decorators.py b/machine/plugins/decorators.py index 379240ad..4545fc42 100644 --- a/machine/plugins/decorators.py +++ b/machine/plugins/decorators.py @@ -2,7 +2,6 @@ import inspect import re -from dataclasses import dataclass, field from datetime import datetime, tzinfo from typing import Any, Awaitable, Callable, Protocol, TypeVar, Union, cast @@ -13,44 +12,10 @@ from machine.plugins.admin_utils import RoleCombinator, matching_roles_by_user_id from machine.plugins.base import MachineBasePlugin from machine.plugins.message import Message +from machine.plugins.metadata import ActionConfig, CommandConfig, MatcherConfig, Metadata, ModalConfig logger = get_logger(__name__) - -@dataclass -class MatcherConfig: - regex: re.Pattern[str] - handle_changed_message: bool = False - - -@dataclass -class CommandConfig: - command: str - is_generator: bool = False - - -@dataclass -class ActionConfig: - action_id: Union[re.Pattern[str], str, None] = None - block_id: Union[re.Pattern[str], str, None] = None - - -@dataclass -class PluginActions: - process: list[str] = field(default_factory=list) - listen_to: list[MatcherConfig] = field(default_factory=list) - respond_to: list[MatcherConfig] = field(default_factory=list) - schedule: dict[str, Any] | None = None - commands: list[CommandConfig] = field(default_factory=list) - actions: list[ActionConfig] = field(default_factory=list) - - -@dataclass -class Metadata: - plugin_actions: PluginActions = field(default_factory=PluginActions) - required_settings: list[str] = field(default_factory=list) - - P = ParamSpec("P") R = TypeVar("R", covariant=True, bound=Union[Awaitable[None], MachineBasePlugin]) @@ -188,6 +153,54 @@ def action_decorator(f: Callable[P, R]) -> DecoratedPluginFunc[P, R]: return action_decorator +def modal(callback_id: Union[re.Pattern[str], str]) -> Callable[[Callable[P, R]], DecoratedPluginFunc[P, R]]: + """Respond to modal submissions + + This decorator will enable a Plugin method to be triggered when certain modals are submitted. + The Plugin method will be called when a modal submission event is received for which the + callback_id matches the provided value. The callback_id can be a string or a regex pattern. + + :param callback_id: the callback_id to respond to, can be a string or regex pattern + :return: wrapped method + """ + + def modal_decorator(f: Callable[P, R]) -> DecoratedPluginFunc[P, R]: + fn = cast(DecoratedPluginFunc, f) + fn.metadata = getattr(f, "metadata", Metadata()) + is_generator = inspect.isasyncgenfunction(f) + fn.metadata.plugin_actions.modal_submissions.append( + ModalConfig(callback_id=callback_id, is_generator=is_generator) + ) + return fn + + return modal_decorator + + +def modal_closed(callback_id: Union[re.Pattern[str], str]) -> Callable[[Callable[P, R]], DecoratedPluginFunc[P, R]]: + """Respond to modal closures + + This decorator will enable a Plugin method to be triggered when certain modals are closed. + The Plugin method will be called when a modal closure event is received for which the + callback_id matches the provided value. The callback_id can be a string or a regex pattern. + + Note: in order to receive modal close events, the modal must have the `notify_on_close` property set to `True`. + + :param callback_id: the callback_id to respond to, can be a string or regex pattern + :return: wrapped method + """ + + def modal_closed_decorator(f: Callable[P, R]) -> DecoratedPluginFunc[P, R]: + fn = cast(DecoratedPluginFunc, f) + fn.metadata = getattr(f, "metadata", Metadata()) + is_generator = inspect.isasyncgenfunction(f) + if is_generator: + raise ValueError("Modal closed handlers cannot be async generators") + fn.metadata.plugin_actions.modal_closures.append(ModalConfig(callback_id=callback_id)) + return fn + + return modal_closed_decorator + + def schedule( year: int | str | None = None, month: int | str | None = None, diff --git a/machine/plugins/metadata.py b/machine/plugins/metadata.py new file mode 100644 index 00000000..11835fda --- /dev/null +++ b/machine/plugins/metadata.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Union + + +@dataclass +class MatcherConfig: + regex: re.Pattern[str] + handle_changed_message: bool = False + + +@dataclass +class CommandConfig: + command: str + is_generator: bool = False + + +@dataclass +class ActionConfig: + action_id: Union[re.Pattern[str], str, None] = None + block_id: Union[re.Pattern[str], str, None] = None + + +@dataclass +class ModalConfig: + callback_id: Union[re.Pattern[str], str] + is_generator: bool = False + + +@dataclass +class PluginActions: + process: list[str] = field(default_factory=list) + listen_to: list[MatcherConfig] = field(default_factory=list) + respond_to: list[MatcherConfig] = field(default_factory=list) + schedule: dict[str, Any] | None = None + commands: list[CommandConfig] = field(default_factory=list) + actions: list[ActionConfig] = field(default_factory=list) + modal_submissions: list[ModalConfig] = field(default_factory=list) + modal_closures: list[ModalConfig] = field(default_factory=list) + + +@dataclass +class Metadata: + plugin_actions: PluginActions = field(default_factory=PluginActions) + required_settings: list[str] = field(default_factory=list) diff --git a/machine/plugins/modals.py b/machine/plugins/modals.py new file mode 100644 index 00000000..4985a098 --- /dev/null +++ b/machine/plugins/modals.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from typing import Any, Sequence + +from slack_sdk.models.attachments import Attachment +from slack_sdk.models.blocks import Block +from slack_sdk.models.views import View as SlackSDKView +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from machine.clients.slack import SlackClient +from machine.models import User +from machine.models.interactive import View, ViewClosedPayload, ViewSubmissionPayload + + +class ModalSubmission: + payload: ViewSubmissionPayload + + def __init__(self, client: SlackClient, payload: ViewSubmissionPayload): + self._client = client + self.payload = payload + + @property + def user(self) -> User: + """The user that submitted the modal + + :return: the user that submitted the modal + """ + return self._client.users[self.payload.user.id] + + @property + def view(self) -> View: + """The view that was submitted including the state of all the elements in the view + + :return: the view that was submitted + """ + return self.payload.view + + @property + def trigger_id(self) -> str: + """The trigger id associated with the submitted modal + + The trigger id can be user ot open another modal + + :return: the trigger id for the modal + """ + return self.payload.trigger_id + + async def open_modal( + self, + view: dict | SlackSDKView, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Open another modal in response to the modal submission + + Open another modal in response to modal submission, using the trigger_id that was returned when the modal was + submitted. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_open()` + + Note: you have to call this method within 3 seconds of receiving the modal submission payload. + + :param view: the view to open + :return: Dictionary deserialized from `AsyncWebClient.views_open()` + """ + return await self._client.open_modal(self.trigger_id, view, **kwargs) + + async def push_modal( + self, + view: dict | SlackSDKView, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Push a new modal view in response to the modal submission + + Push a new modal view on top of the view stack in response to modal submission, using the trigger_id that was + returned when the modal was submitted. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_push()` + + Note: you have to call this method within 3 seconds of receiving the modal submission payload. + + :param view: the view to push + :return: Dictionary deserialized from `AsyncWebClient.views_push()` + """ + return await self._client.push_modal(self.trigger_id, view, **kwargs) + + async def update_modal( + self, + view: dict | SlackSDKView, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Update the modal view in response to the modal submission + + Update the modal view in response to modal submission, using the trigger_id that was returned when the modal was + submitted. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_update()` + + Note: you have to call this method within 3 seconds of receiving the modal submission payload. + + :param view: the view to update + :return: Dictionary deserialized from `AsyncWebClient.views_update()` + """ + return await self._client.update_modal(view, self.payload.view.id, self.payload.view.external_id, **kwargs) + + async def send_dm( + self, + text: str | None = None, + attachments: Sequence[Attachment] | Sequence[dict[str, Any]] | None = None, + blocks: Sequence[Block] | Sequence[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Send a DM to the user that submitted the modal + + Send a Direct Message to the user that submitted the modal by opening a DM channel and + sending a message to it. + Allows for rich formatting using [blocks] and/or [attachments] . You can provide blocks + and attachments as Python dicts or you can use the [convenient classes] that the + underlying slack client provides. + Any extra kwargs you provide, will be passed on directly to the `chat.postMessage` request. + + [attachments]: https://api.slack.com/docs/message-attachments + [blocks]: https://api.slack.com/reference/block-kit/blocks + [convenient classes]: https://github.com/slackapi/python-slack-sdk/tree/main/slack/web/classes + + :param text: message text + :param attachments: optional attachments (see [attachments]) + :param blocks: optional blocks (see [blocks]) + :return: Dictionary deserialized from [chat.postMessage] response. + + [chat.postMessage]: https://api.slack.com/methods/chat.postMessage + """ + return await self._client.send_dm(self.user.id, text, attachments=attachments, blocks=blocks, **kwargs) + + +class ModalClosure: + payload: ViewClosedPayload + + def __init__(self, client: SlackClient, payload: ViewClosedPayload): + self._client = client + self.payload = payload + + @property + def user(self) -> User: + """The user that closed the modal + + :return: the user that closed the modal + """ + return self._client.users[self.payload.user.id] + + @property + def view(self) -> View: + """The view that was closed including the state of all the elements in the view when it was closed + + :return: the view that was closed + """ + return self.payload.view + + async def send_dm( + self, + text: str | None = None, + attachments: Sequence[Attachment] | Sequence[dict[str, Any]] | None = None, + blocks: Sequence[Block] | Sequence[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Send a DM to the user that closed the modal + + Send a Direct Message to the user that closed the modal by opening a DM channel and + sending a message to it. + Allows for rich formatting using [blocks] and/or [attachments] . You can provide blocks + and attachments as Python dicts or you can use the [convenient classes] that the + underlying slack client provides. + Any extra kwargs you provide, will be passed on directly to the `chat.postMessage` request. + + [attachments]: https://api.slack.com/docs/message-attachments + [blocks]: https://api.slack.com/reference/block-kit/blocks + [convenient classes]: https://github.com/slackapi/python-slack-sdk/tree/main/slack/web/classes + + :param text: message text + :param attachments: optional attachments (see [attachments]) + :param blocks: optional blocks (see [blocks]) + :return: Dictionary deserialized from [chat.postMessage] response. + + [chat.postMessage]: https://api.slack.com/methods/chat.postMessage + """ + return await self._client.send_dm(self.user.id, text, attachments=attachments, blocks=blocks, **kwargs) diff --git a/mkdocs.yml b/mkdocs.yml index 815f4b8f..0c84101d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ nav: - 'plugins/slash-commands.md' - 'plugins/interacting.md' - 'plugins/block-kit-actions.md' + - 'plugins/modals.md' - 'plugins/settings.md' - 'plugins/storage.md' - 'plugins/misc.md' @@ -67,4 +68,3 @@ plugins: members_order: source import: - https://docs.python.org/dev/objects.inv - - https://slack.dev/python-slack-sdk/objects.inv diff --git a/pyproject.toml b/pyproject.toml index 37188d32..4361b3c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ mock_use_standalone_module = true addopts = "--verbose --cov-report term-missing --cov-report xml --junit-xml pytest.xml --cov=machine" [tool.mypy] -python_version = "3.12" +python_version = "3.13" ignore_missing_imports = true show_column_numbers = true show_error_codes = true diff --git a/tests/clients/test_slack_client.py b/tests/clients/test_slack_client.py index e941c968..03f3cf30 100644 --- a/tests/clients/test_slack_client.py +++ b/tests/clients/test_slack_client.py @@ -1,22 +1,17 @@ from __future__ import annotations -import sys from typing import Any import pytest from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.web.async_client import AsyncWebClient +from zoneinfo import ZoneInfo from machine.clients.slack import SlackClient, id_for_channel, id_for_user from machine.models.channel import Channel from machine.models.user import User -if sys.version_info >= (3, 9): - from zoneinfo import ZoneInfo -else: - from backports.zoneinfo import ZoneInfo - # TODO: more tests diff --git a/tests/fake_plugins.py b/tests/fake_plugins.py index 0f207b52..2d2c7980 100644 --- a/tests/fake_plugins.py +++ b/tests/fake_plugins.py @@ -1,7 +1,7 @@ import re from machine.plugins.base import MachineBasePlugin -from machine.plugins.decorators import action, command, listen_to, process, respond_to +from machine.plugins.decorators import action, command, listen_to, modal, modal_closed, process, respond_to class FakePlugin(MachineBasePlugin): @@ -29,6 +29,18 @@ async def generator_command_function(self, payload): async def block_action_function(self, payload): pass + @modal(callback_id=re.compile(r"my_modal.*", re.IGNORECASE)) + async def modal_function(self, payload): + pass + + @modal(callback_id="my_generator_modal") + async def generator_modal_function(self, payload): + yield "hello" + + @modal_closed(callback_id="my_modal_2") + async def modal_closed_function(self, payload): + pass + class FakePlugin2(MachineBasePlugin): async def init(self): diff --git a/tests/handlers/conftest.py b/tests/handlers/conftest.py index b5922eec..9d70192f 100644 --- a/tests/handlers/conftest.py +++ b/tests/handlers/conftest.py @@ -3,10 +3,9 @@ import pytest from slack_sdk.socket_mode.aiohttp import SocketModeClient -from slack_sdk.socket_mode.request import SocketModeRequest from machine.clients.slack import SlackClient -from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, RegisteredActions +from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, ModalHandler, RegisteredActions from machine.storage import MachineBaseStorage from machine.utils.collections import CaseInsensitiveDict from tests.fake_plugins import FakePlugin @@ -36,6 +35,9 @@ def fake_plugin(mocker, slack_client, storage): mocker.spy(plugin_instance, "command_function") mocker.spy(plugin_instance, "generator_command_function") mocker.spy(plugin_instance, "block_action_function") + mocker.spy(plugin_instance, "modal_function") + mocker.spy(plugin_instance, "generator_modal_function") + mocker.spy(plugin_instance, "modal_closed_function") return plugin_instance @@ -46,6 +48,10 @@ def plugin_actions(fake_plugin): process_fn = fake_plugin.process_function command_fn = fake_plugin.command_function generator_command_fn = fake_plugin.generator_command_function + block_action_fn = fake_plugin.block_action_function + modal_fn = fake_plugin.modal_function + generator_modal_fn = fake_plugin.generator_modal_function + modal_closed_fn = fake_plugin.modal_closed_function plugin_actions = RegisteredActions( listen_to={ "TestPlugin.listen_function-hi": MessageHandler( @@ -90,16 +96,39 @@ def plugin_actions(fake_plugin): "TestPlugin.block_action_function-my_action.*-my_block": BlockActionHandler( class_=fake_plugin, class_name="tests.fake_plugins.FakePlugin", - function=fake_plugin.block_action_function, - function_signature=Signature.from_callable(fake_plugin.block_action_function), + function=block_action_fn, + function_signature=Signature.from_callable(block_action_fn), action_id_matcher=re.compile("my_action.*", re.IGNORECASE), block_id_matcher="my_block", ) }, + modal={ + "TestPlugin.modal_function-my_modal.*": ModalHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=modal_fn, + function_signature=Signature.from_callable(modal_fn), + callback_id_matcher=re.compile("my_modal.*", re.IGNORECASE), + is_generator=False, + ), + "TestPlugin.generator_modal_function-my_generator_modal": ModalHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=generator_modal_fn, + function_signature=Signature.from_callable(generator_modal_fn), + callback_id_matcher="my_generator_modal", + is_generator=True, + ), + }, + modal_closed={ + "TestPlugin.modal_closed_function-my_modal_2": ModalHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=modal_closed_fn, + function_signature=Signature.from_callable(modal_closed_fn), + callback_id_matcher="my_modal_2", + is_generator=False, + ) + }, ) return plugin_actions - - -def gen_command_request(command: str, text: str): - payload = {"command": command, "text": text, "response_url": "https://my.webhook.com"} - return SocketModeRequest(type="slash_commands", envelope_id="x", payload=payload) diff --git a/tests/handlers/requests.py b/tests/handlers/requests.py new file mode 100644 index 00000000..7f587755 --- /dev/null +++ b/tests/handlers/requests.py @@ -0,0 +1,173 @@ +from slack_sdk.socket_mode.request import SocketModeRequest + + +def gen_command_request(command: str, text: str): + payload = {"command": command, "text": text, "response_url": "https://my.webhook.com"} + return SocketModeRequest(type="slash_commands", envelope_id="x", payload=payload) + + +def _gen_block_action_request(action_id, block_id): + payload = { + "type": "block_actions", + "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"}, + "api_app_id": "A12345678", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "1234567890.123456", + "channel_id": "C12345678", + "is_ephemeral": False, + }, + "channel": {"id": "C12345678", "name": "channel-name"}, + "message": { + "type": "message", + "user": "U87654321", + "ts": "1234567890.123456", + "bot_id": "B12345678", + "app_id": "A12345678", + "text": "Hello, world!", + "team": "T12345678", + "blocks": [ + { + "type": "actions", + "block_id": block_id, + "elements": [ + { + "type": "button", + "action_id": action_id, + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "U12345678", + }, + ], + }, + ], + }, + "state": {"values": {}}, + "trigger_id": "1234567890.123456", + "team": {"id": "T12345678", "domain": "workspace-domain"}, + "enterprise": None, + "is_enterprise_install": False, + "actions": [ + { + "type": "button", + "action_id": action_id, + "block_id": block_id, + "action_ts": "1234567890.123456", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "value": "U12345678", + "style": "primary", + } + ], + "response_url": "https://hooks.slack.com/actions/T12345678/1234567890/1234567890", + } + return SocketModeRequest(type="interactive", envelope_id="x", payload=payload) + + +def _gen_view_submission_request(callback_id): + payload = { + "type": "view_submission", + "team": {"id": "T12345678", "domain": "workspace-domain"}, + "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"}, + "api_app_id": "A12345678", + "token": "verification_token", + "trigger_id": "1234567890.123456", + "enterprise": None, + "is_enterprise_install": False, + "response_urls": [], + "view": { + "id": "V1234567890", + "team_id": "T12345678", + "type": "modal", + "blocks": [ + { + "type": "header", + "block_id": "k3dNV", + "text": {"type": "plain_text", "text": "What do you want?", "emoji": True}, + }, + { + "type": "input", + "block_id": "modal_input", + "label": {"type": "plain_text", "text": "Give your opinion", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "opinion", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + }, + }, + ], + "private_metadata": "", + "callback_id": callback_id, + "state": {"values": {"modal_input": {"opinion": {"type": "plain_text_input", "value": "YYippieee"}}}}, + "hash": "1717180005.lGLYVzOE", + "title": {"type": "plain_text", "text": "My App", "emoji": True}, + "clear_on_close": False, + "notify_on_close": True, + "close": {"type": "plain_text", "text": ":cry: Cancel", "emoji": True}, + "submit": {"type": "plain_text", "text": ":rocket: Submit", "emoji": True}, + "previous_view_id": None, + "root_view_id": "V1234567890", + "app_id": "A12345678", + "external_id": "", + "app_installed_team_id": "T12345678", + "bot_id": "B1234567890", + }, + } + return SocketModeRequest(type="interactive", envelope_id="x", payload=payload) + + +def _gen_view_closed_request(callback_id): + payload = { + "type": "view_closed", + "team": {"id": "T12345678", "domain": "workspace-domain"}, + "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"}, + "api_app_id": "A12345678", + "token": "verification_token", + "enterprise": None, + "is_enterprise_install": False, + "is_cleared": True, + "view": { + "id": "V1234567890", + "team_id": "T12345678", + "type": "modal", + "blocks": [ + { + "type": "header", + "block_id": "k3dNV", + "text": {"type": "plain_text", "text": "What do you want?", "emoji": True}, + }, + { + "type": "input", + "block_id": "modal_input", + "label": {"type": "plain_text", "text": "Give your opinion", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "opinion", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + }, + }, + ], + "private_metadata": "", + "callback_id": callback_id, + "state": {"values": {"modal_input": {"opinion": {"type": "plain_text_input", "value": "YYippieee"}}}}, + "hash": "1717180005.lGLYVzOE", + "title": {"type": "plain_text", "text": "My App", "emoji": True}, + "clear_on_close": False, + "notify_on_close": True, + "close": {"type": "plain_text", "text": ":cry: Cancel", "emoji": True}, + "submit": {"type": "plain_text", "text": ":rocket: Submit", "emoji": True}, + "previous_view_id": None, + "root_view_id": "V1234567890", + "app_id": "A12345678", + "external_id": "", + "app_installed_team_id": "T12345678", + "bot_id": "B1234567890", + }, + } + return SocketModeRequest(type="interactive", envelope_id="x", payload=payload) diff --git a/tests/handlers/test_command_handler.py b/tests/handlers/test_command_handler.py index 5db510d0..4becd130 100644 --- a/tests/handlers/test_command_handler.py +++ b/tests/handlers/test_command_handler.py @@ -2,7 +2,7 @@ from machine.handlers import create_slash_command_handler from machine.plugins.command import Command -from tests.handlers.conftest import gen_command_request +from tests.handlers.requests import gen_command_request def _assert_command(args, command, text): diff --git a/tests/handlers/test_interactive_handler.py b/tests/handlers/test_interactive_handler.py index beb7c6d0..c1df00ec 100644 --- a/tests/handlers/test_interactive_handler.py +++ b/tests/handlers/test_interactive_handler.py @@ -1,68 +1,11 @@ import re import pytest -from slack_sdk.socket_mode.request import SocketModeRequest from machine.handlers.interactive_handler import _matches, create_interactive_handler from machine.plugins.block_action import BlockAction - - -def _gen_block_action_request(action_id, block_id): - payload = { - "type": "block_actions", - "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"}, - "api_app_id": "A12345678", - "token": "verification_token", - "container": { - "type": "message", - "message_ts": "1234567890.123456", - "channel_id": "C12345678", - "is_ephemeral": False, - }, - "channel": {"id": "C12345678", "name": "channel-name"}, - "message": { - "type": "message", - "user": "U87654321", - "ts": "1234567890.123456", - "bot_id": "B12345678", - "app_id": "A12345678", - "text": "Hello, world!", - "team": "T12345678", - "blocks": [ - { - "type": "actions", - "block_id": block_id, - "elements": [ - { - "type": "button", - "action_id": action_id, - "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, - "style": "primary", - "value": "U12345678", - }, - ], - }, - ], - }, - "state": {"values": {}}, - "trigger_id": "1234567890.123456", - "team": {"id": "T12345678", "domain": "workspace-domain"}, - "enterprise": None, - "is_enterprise_install": False, - "actions": [ - { - "type": "button", - "action_id": action_id, - "block_id": block_id, - "action_ts": "1234567890.123456", - "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, - "value": "U12345678", - "style": "primary", - } - ], - "response_url": "https://hooks.slack.com/actions/T12345678/1234567890/1234567890", - } - return SocketModeRequest(type="interactive", envelope_id="x", payload=payload) +from machine.plugins.modals import ModalClosure, ModalSubmission +from tests.handlers.requests import _gen_block_action_request, _gen_view_closed_request, _gen_view_submission_request def test_matches(): @@ -89,3 +32,58 @@ async def test_create_interactive_handler_for_block_actions( resp = socket_mode_client.send_socket_mode_response.call_args.args[0] assert resp.envelope_id == "x" assert resp.payload is None + + +@pytest.mark.asyncio +async def test_create_interactive_handler_for_view_submission( + plugin_actions, fake_plugin, socket_mode_client, slack_client +): + handler = create_interactive_handler(plugin_actions, slack_client) + request = _gen_view_submission_request("my_modal_1") + await handler(socket_mode_client, request) + assert fake_plugin.modal_function.call_count == 1 + args = fake_plugin.modal_function.call_args + assert isinstance(args[0][0], ModalSubmission) + assert args[0][0].payload.view.callback_id == "my_modal_1" + socket_mode_client.send_socket_mode_response.assert_called_once() + resp = socket_mode_client.send_socket_mode_response.call_args.args[0] + assert resp.envelope_id == "x" + assert resp.payload is None + assert fake_plugin.generator_modal_function.call_count == 0 + + +@pytest.mark.asyncio +async def test_create_interactive_handler_for_view_submission_generator( + plugin_actions, fake_plugin, socket_mode_client, slack_client +): + handler = create_interactive_handler(plugin_actions, slack_client) + request = _gen_view_submission_request("my_generator_modal") + await handler(socket_mode_client, request) + assert fake_plugin.generator_modal_function.call_count == 1 + args = fake_plugin.generator_modal_function.call_args + assert isinstance(args[0][0], ModalSubmission) + assert args[0][0].payload.view.callback_id == "my_generator_modal" + socket_mode_client.send_socket_mode_response.assert_called_once() + resp = socket_mode_client.send_socket_mode_response.call_args.args[0] + assert resp.envelope_id == "x" + assert resp.payload == {"text": "hello"} + assert fake_plugin.modal_function.call_count == 0 + + +@pytest.mark.asyncio +async def test_create_interactive_handler_for_view_closed( + plugin_actions, fake_plugin, socket_mode_client, slack_client +): + handler = create_interactive_handler(plugin_actions, slack_client) + request = _gen_view_closed_request("my_modal_2") + await handler(socket_mode_client, request) + assert fake_plugin.modal_closed_function.call_count == 1 + args = fake_plugin.modal_closed_function.call_args + assert isinstance(args[0][0], ModalClosure) + assert args[0][0].payload.view.callback_id == "my_modal_2" + socket_mode_client.send_socket_mode_response.assert_called_once() + resp = socket_mode_client.send_socket_mode_response.call_args.args[0] + assert resp.envelope_id == "x" + assert resp.payload is None + assert fake_plugin.modal_function.call_count == 0 + assert fake_plugin.generator_modal_function.call_count == 0 diff --git a/tests/handlers/test_logging.py b/tests/handlers/test_logging.py index 3b8ae886..4a33317d 100644 --- a/tests/handlers/test_logging.py +++ b/tests/handlers/test_logging.py @@ -2,7 +2,7 @@ from structlog.testing import capture_logs from machine.handlers import log_request -from tests.handlers.conftest import gen_command_request +from tests.handlers.requests import gen_command_request @pytest.mark.asyncio diff --git a/tests/models/example_payloads/view_closed.py b/tests/models/example_payloads/view_closed.py new file mode 100644 index 00000000..f18f9266 --- /dev/null +++ b/tests/models/example_payloads/view_closed.py @@ -0,0 +1,166 @@ +payload = { + "type": "view_closed", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "view": { + "id": "V075UPJMERW", + "team_id": "TQSD32X16", + "type": "modal", + "blocks": [ + { + "type": "section", + "block_id": "JyQHG", + "text": { + "type": "plain_text", + "text": ":wave: Hey David!\n\nWe'd love to hear from you how we can make this place the best place you’ve ever worked.", # noqa: E501 + "emoji": True, + }, + }, + {"type": "divider", "block_id": "PQxVr"}, + { + "type": "input", + "block_id": "my_block_id", + "label": {"type": "plain_text", "text": "Select a channel to post the result on", "emoji": True}, + "optional": True, + "dispatch_action": False, + "element": { + "type": "conversations_select", + "action_id": "my_action_id", + "default_to_current_conversation": True, + "response_url_enabled": True, + "initial_conversation": "CQEUMSV7D", + }, + }, + { + "type": "input", + "block_id": "working_here", + "label": {"type": "plain_text", "text": "You enjoy working here at Pistachio & Co", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "working_here_options", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "WQ3Jr", + "label": {"type": "plain_text", "text": "What do you want for our team weekly lunch?", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "placeholder": {"type": "plain_text", "text": "Select your favorites", "emoji": True}, + "options": [ + {"text": {"type": "plain_text", "text": ":pizza: Pizza", "emoji": True}, "value": "value-0"}, + { + "text": {"type": "plain_text", "text": ":fried_shrimp: Thai food", "emoji": True}, + "value": "value-1", + }, + { + "text": {"type": "plain_text", "text": ":desert_island: Hawaiian", "emoji": True}, + "value": "value-2", + }, + { + "text": {"type": "plain_text", "text": ":meat_on_bone: Texas BBQ", "emoji": True}, + "value": "value-3", + }, + { + "text": {"type": "plain_text", "text": ":hamburger: Burger", "emoji": True}, + "value": "value-4", + }, + {"text": {"type": "plain_text", "text": ":taco: Tacos", "emoji": True}, "value": "value-5"}, + { + "text": {"type": "plain_text", "text": ":green_salad: Salad", "emoji": True}, + "value": "value-6", + }, + {"text": {"type": "plain_text", "text": ":stew: Indian", "emoji": True}, "value": "value-7"}, + ], + "action_id": "+OG15", + }, + }, + { + "type": "input", + "block_id": "tzZoU", + "label": { + "type": "plain_text", + "text": "What can we do to improve your experience working here?", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + "action_id": "B+FPE", + }, + }, + { + "type": "input", + "block_id": "xpaXr", + "label": {"type": "plain_text", "text": "Anything else you want to tell us?", "emoji": True}, + "optional": True, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + "action_id": "ME5+y", + }, + }, + { + "type": "section", + "block_id": "DZbIt", + "text": {"type": "mrkdwn", "text": "Pick a date for the deadline.", "verbatim": False}, + "accessory": { + "type": "datepicker", + "action_id": "datepicker-action", + "initial_date": "1990-04-28", + "placeholder": {"type": "plain_text", "text": "Select a date", "emoji": True}, + }, + }, + ], + "private_metadata": "", + "callback_id": "", + "state": { + "values": { + "my_block_id": { + "my_action_id": { + "type": "conversations_select", + "selected_conversation": "CQEUMSV7D", + "response_url_enabled": True, + } + }, + "DZbIt": {"datepicker-action": {"type": "datepicker", "selected_date": "1990-04-28"}}, + } + }, + "hash": "1716738939.7GIdWSjv", + "title": {"type": "plain_text", "text": "Workplace check-in", "emoji": True}, + "clear_on_close": False, + "notify_on_close": True, + "close": {"type": "plain_text", "text": ":cry: Cancel", "emoji": True}, + "submit": {"type": "plain_text", "text": ":heart: Submit", "emoji": True}, + "previous_view_id": None, + "root_view_id": "V075UPJMERW", + "app_id": "A039QKQ6G1E", + "external_id": "", + "app_installed_team_id": "TQSD32X16", + "bot_id": "B0390TCABQB", + }, + "is_cleared": True, + "is_enterprise_install": False, + "enterprise": None, +} diff --git a/tests/models/test_block_actions.py b/tests/models/test_block_actions.py index 90c4202f..4f52ce7d 100644 --- a/tests/models/test_block_actions.py +++ b/tests/models/test_block_actions.py @@ -1,4 +1,4 @@ -from machine.models.interactive import BlockActionsPayload +from machine.models.interactive import BlockActionsPayload, InteractivePayload from tests.models.example_payloads.block_action_button import payload as button_payload from tests.models.example_payloads.block_action_button2 import payload as button_payload2 from tests.models.example_payloads.block_action_button_no_value import payload as button_no_value_payload @@ -18,68 +18,84 @@ def test_block_action_radio_button(): - validated_radio_button_payload = BlockActionsPayload.model_validate(radio_button_payload) + validated_radio_button_payload = InteractivePayload.validate_python(radio_button_payload) assert validated_radio_button_payload is not None + assert isinstance(validated_radio_button_payload, BlockActionsPayload) def test_block_action_button(): - validated_button_payload = BlockActionsPayload.model_validate(button_payload) + validated_button_payload = InteractivePayload.validate_python(button_payload) assert validated_button_payload is not None - validated_button_payload2 = BlockActionsPayload.model_validate(button_payload2) + assert isinstance(validated_button_payload, BlockActionsPayload) + validated_button_payload2 = InteractivePayload.validate_python(button_payload2) assert validated_button_payload2 is not None - validated_button_no_value_payload = BlockActionsPayload.model_validate(button_no_value_payload) + assert isinstance(validated_button_payload2, BlockActionsPayload) + validated_button_no_value_payload = InteractivePayload.validate_python(button_no_value_payload) assert validated_button_no_value_payload is not None + assert isinstance(validated_button_no_value_payload, BlockActionsPayload) def test_block_action_checkboxes(): - validated_checkboxes_payload = BlockActionsPayload.model_validate(checkboxes_payload) + validated_checkboxes_payload = InteractivePayload.validate_python(checkboxes_payload) assert validated_checkboxes_payload is not None - validated_checkboxes_payload2 = BlockActionsPayload.model_validate(checkboxes_payload2) + assert isinstance(validated_checkboxes_payload, BlockActionsPayload) + validated_checkboxes_payload2 = InteractivePayload.validate_python(checkboxes_payload2) assert validated_checkboxes_payload2 is not None + assert isinstance(validated_checkboxes_payload2, BlockActionsPayload) def test_block_action_datepicker(): - validated_datepicker_payload = BlockActionsPayload.model_validate(datepicker_payload) + validated_datepicker_payload = InteractivePayload.validate_python(datepicker_payload) assert validated_datepicker_payload is not None - validated_datepicker_payload2 = BlockActionsPayload.model_validate(datepicker_payload2) + assert isinstance(validated_datepicker_payload, BlockActionsPayload) + validated_datepicker_payload2 = InteractivePayload.validate_python(datepicker_payload2) assert validated_datepicker_payload2 is not None + assert isinstance(validated_datepicker_payload2, BlockActionsPayload) def test_block_action_static_select(): - validated_static_select_payload = BlockActionsPayload.model_validate(static_select_payload) + validated_static_select_payload = InteractivePayload.validate_python(static_select_payload) assert validated_static_select_payload is not None + assert isinstance(validated_static_select_payload, BlockActionsPayload) def test_block_action_conversations_select(): - validated_conversations_select_payload = BlockActionsPayload.model_validate(conversations_select_payload) + validated_conversations_select_payload = InteractivePayload.validate_python(conversations_select_payload) assert validated_conversations_select_payload is not None + assert isinstance(validated_conversations_select_payload, BlockActionsPayload) def test_block_action_multi_static_select(): - validated_multi_static_select_payload = BlockActionsPayload.model_validate(multi_static_select_payload) + validated_multi_static_select_payload = InteractivePayload.validate_python(multi_static_select_payload) assert validated_multi_static_select_payload is not None + assert isinstance(validated_multi_static_select_payload, BlockActionsPayload) def test_block_action_multi_channels_select(): - validated_multi_channels_select_payload = BlockActionsPayload.model_validate(multi_channels_select_payload) + validated_multi_channels_select_payload = InteractivePayload.validate_python(multi_channels_select_payload) assert validated_multi_channels_select_payload is not None + assert isinstance(validated_multi_channels_select_payload, BlockActionsPayload) def test_block_action_timepicker(): - validated_timepicker_payload = BlockActionsPayload.model_validate(timepicker_payload) + validated_timepicker_payload = InteractivePayload.validate_python(timepicker_payload) assert validated_timepicker_payload is not None + assert isinstance(validated_timepicker_payload, BlockActionsPayload) def test_block_action_url_input(): - validated_url_payload = BlockActionsPayload.model_validate(url_payload) + validated_url_payload = InteractivePayload.validate_python(url_payload) assert validated_url_payload is not None + assert isinstance(validated_url_payload, BlockActionsPayload) def test_block_action_overflow(): - validated_overflow_payload = BlockActionsPayload.model_validate(overflow_payload) + validated_overflow_payload = InteractivePayload.validate_python(overflow_payload) assert validated_overflow_payload is not None + assert isinstance(validated_overflow_payload, BlockActionsPayload) def test_block_action_in_modal(): - validated_in_modal_payload = BlockActionsPayload.model_validate(in_modal_payload) + validated_in_modal_payload = InteractivePayload.validate_python(in_modal_payload) assert validated_in_modal_payload is not None + assert isinstance(validated_in_modal_payload, BlockActionsPayload) diff --git a/tests/models/test_views.py b/tests/models/test_views.py new file mode 100644 index 00000000..3b0d1e18 --- /dev/null +++ b/tests/models/test_views.py @@ -0,0 +1,15 @@ +from machine.models.interactive import InteractivePayload, ViewClosedPayload, ViewSubmissionPayload +from tests.models.example_payloads.view_closed import payload as view_closed_payload +from tests.models.example_payloads.view_submission import payload as view_submission_payload + + +def test_view_submission(): + validated_view_submission_payload = InteractivePayload.validate_python(view_submission_payload) + assert validated_view_submission_payload is not None + assert isinstance(validated_view_submission_payload, ViewSubmissionPayload) + + +def test_view_closed(): + validated_view_closed_payload = InteractivePayload.validate_python(view_closed_payload) + assert validated_view_closed_payload is not None + assert isinstance(validated_view_closed_payload, ViewClosedPayload) diff --git a/tests/plugins/test_decorators.py b/tests/plugins/test_decorators.py index c6430aba..3c070bf2 100644 --- a/tests/plugins/test_decorators.py +++ b/tests/plugins/test_decorators.py @@ -5,18 +5,18 @@ from machine.plugins import ee from machine.plugins.decorators import ( - ActionConfig, - CommandConfig, - MatcherConfig, action, command, listen_to, + modal, + modal_closed, on, process, required_settings, respond_to, schedule, ) +from machine.plugins.metadata import ActionConfig, CommandConfig, MatcherConfig, ModalConfig @pytest.fixture(scope="module") @@ -118,6 +118,51 @@ def f(action_paylaod): return f +@pytest.fixture(scope="module") +def modal_f(): + @modal("modal_1") + def f(modal_payload): + pass + + return f + + +@pytest.fixture(scope="module") +def modal_f_regex(): + @modal(re.compile(r"modal_\d", re.IGNORECASE)) + def f(modal_payload): + pass + + return f + + +@pytest.fixture(scope="module") +def modal_generator_f(): + @modal("modal_1") + async def f(modal_payload): + yield "hello" + + return f + + +@pytest.fixture(scope="module") +def modal_closed_f(): + @modal_closed("modal_1") + def f(modal_closed_payload): + pass + + return f + + +@pytest.fixture(scope="module") +def modal_closed_f_regex(): + @modal_closed(re.compile(r"modal_\d", re.IGNORECASE)) + def f(modal_closed_payload): + pass + + return f + + @pytest.fixture(scope="module") def multi_decorator_f(): @respond_to(r"hello-respond", re.IGNORECASE) @@ -276,6 +321,55 @@ def f(action_paylaod): pass +def test_modal(modal_f): + assert hasattr(modal_f, "metadata") + assert hasattr(modal_f.metadata, "plugin_actions") + assert hasattr(modal_f.metadata.plugin_actions, "modal_submissions") + assert modal_f.metadata.plugin_actions.modal_submissions == [ModalConfig(callback_id="modal_1")] + + +def test_modal_regex(modal_f_regex): + assert hasattr(modal_f_regex, "metadata") + assert hasattr(modal_f_regex.metadata, "plugin_actions") + assert hasattr(modal_f_regex.metadata.plugin_actions, "modal_submissions") + assert modal_f_regex.metadata.plugin_actions.modal_submissions == [ + ModalConfig(callback_id=re.compile(r"modal_\d", re.IGNORECASE)) + ] + + +def test_modal_generator(modal_generator_f): + assert hasattr(modal_generator_f, "metadata") + assert hasattr(modal_generator_f.metadata, "plugin_actions") + assert hasattr(modal_generator_f.metadata.plugin_actions, "modal_submissions") + assert modal_generator_f.metadata.plugin_actions.modal_submissions == [ + ModalConfig(callback_id="modal_1", is_generator=True) + ] + + +def test_modal_closed(modal_closed_f): + assert hasattr(modal_closed_f, "metadata") + assert hasattr(modal_closed_f.metadata, "plugin_actions") + assert hasattr(modal_closed_f.metadata.plugin_actions, "modal_closures") + assert modal_closed_f.metadata.plugin_actions.modal_closures == [ModalConfig(callback_id="modal_1")] + + +def test_modal_closed_regex(modal_closed_f_regex): + assert hasattr(modal_closed_f_regex, "metadata") + assert hasattr(modal_closed_f_regex.metadata, "plugin_actions") + assert hasattr(modal_closed_f_regex.metadata.plugin_actions, "modal_closures") + assert modal_closed_f_regex.metadata.plugin_actions.modal_closures == [ + ModalConfig(callback_id=re.compile(r"modal_\d", re.IGNORECASE)) + ] + + +def test_modal_closed_generator(): + with pytest.raises(ValueError, match="Modal closed handlers cannot be async generators"): + + @modal_closed("modal_1") + async def f(modal_payload): + yield "hello" + + def test_required_settings_list(required_settings_list_f): assert hasattr(required_settings_list_f, "metadata") assert hasattr(required_settings_list_f.metadata, "required_settings") diff --git a/tests/test_plugin_registration.py b/tests/test_plugin_registration.py index e30277a4..039e9af1 100644 --- a/tests/test_plugin_registration.py +++ b/tests/test_plugin_registration.py @@ -4,7 +4,7 @@ from machine import Machine from machine.clients.slack import SlackClient -from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, RegisteredActions +from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, ModalHandler, RegisteredActions from machine.plugins.decorators import required_settings from machine.utils.collections import CaseInsensitiveDict from machine.utils.logging import configure_logging @@ -97,6 +97,31 @@ async def test_load_and_register_plugins(settings, slack_client): assert isinstance(actions.block_actions[block_action_key].block_id_matcher, str) assert actions.block_actions[block_action_key].block_id_matcher == "my_block" + # Test registration of modal actions + modal_key = "tests.fake_plugins.FakePlugin.modal_function-my_modal.*" + assert modal_key in actions.modal + assert isinstance(actions.modal[modal_key], ModalHandler) + assert actions.modal[modal_key].class_name == "tests.fake_plugins.FakePlugin" + assert isinstance(actions.modal[modal_key].callback_id_matcher, re.Pattern) + assert actions.modal[modal_key].callback_id_matcher == re.compile("my_modal.*", re.IGNORECASE) + assert not actions.modal[modal_key].is_generator + + # Test registration of generator modal actions + generator_modal_key = "tests.fake_plugins.FakePlugin.generator_modal_function-my_generator_modal" + assert generator_modal_key in actions.modal + assert isinstance(actions.modal[generator_modal_key], ModalHandler) + assert actions.modal[generator_modal_key].class_name == "tests.fake_plugins.FakePlugin" + assert actions.modal[generator_modal_key].callback_id_matcher == "my_generator_modal" + assert actions.modal[generator_modal_key].is_generator + + # Test registration of modal_closed actions + modal_closed_key = "tests.fake_plugins.FakePlugin.modal_closed_function-my_modal_2" + assert modal_closed_key in actions.modal_closed + assert isinstance(actions.modal_closed[modal_closed_key], ModalHandler) + assert actions.modal_closed[modal_closed_key].class_name == "tests.fake_plugins.FakePlugin" + assert actions.modal_closed[modal_closed_key].callback_id_matcher == "my_modal_2" + assert not actions.modal_closed[modal_closed_key].is_generator + @pytest.mark.asyncio async def test_plugin_storage_fq_plugin_name(settings, slack_client): From ad502473aecd80652661b4948a7999a7d62de465 Mon Sep 17 00:00:00 2001 From: Daan Debie Date: Fri, 22 Nov 2024 15:50:21 +0100 Subject: [PATCH 3/4] chore: update ruff, update minimum version for ruff to Python 3.9 and fix linting errors --- .github/workflows/ci.yml | 4 +-- .pre-commit-config.yaml | 2 +- machine/clients/slack.py | 5 ++-- machine/core.py | 5 ++-- machine/handlers/command_handler.py | 3 +- machine/handlers/event_handler.py | 3 +- machine/handlers/interactive_handler.py | 3 +- machine/handlers/message_handler.py | 3 +- machine/models/channel.py | 4 +-- machine/models/core.py | 3 +- machine/models/interactive.py | 37 +++++++++++------------ machine/plugins/base.py | 3 +- machine/plugins/block_action.py | 3 +- machine/plugins/command.py | 3 +- machine/plugins/decorators.py | 3 +- machine/plugins/message.py | 3 +- machine/plugins/modals.py | 3 +- machine/settings.py | 3 +- machine/storage/backends/base.py | 3 +- machine/storage/backends/dynamodb.py | 3 +- machine/storage/backends/memory.py | 3 +- machine/storage/backends/redis.py | 3 +- machine/storage/backends/sqlite.py | 3 +- machine/utils/collections.py | 3 +- machine/utils/logging.py | 3 +- machine/utils/module_loading.py | 3 +- machine/utils/redis.py | 3 +- poetry.lock | 40 ++++++++++++------------- pyproject.toml | 3 +- tests/clients/test_slack_client.py | 2 +- tests/utils/test_datetime.py | 6 +--- 31 files changed, 93 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4879f783..5a52ce7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.13" - - uses: chartboost/ruff-action@v1 - - uses: chartboost/ruff-action@v1 + - uses: astral-sh/ruff-action@v1 + - uses: astral-sh/ruff-action@v1 with: args: "format --check" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 884ce6bd..6abdd23c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: - id: check-added-large-files - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.7.4 + rev: v0.8.0 hooks: # Run the linter. - id: ruff diff --git a/machine/clients/slack.py b/machine/clients/slack.py index c4563bb3..9e1220a7 100644 --- a/machine/clients/slack.py +++ b/machine/clients/slack.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncGenerator, Awaitable from datetime import datetime -from typing import Any, AsyncGenerator, Awaitable, Callable +from typing import Any, Callable +from zoneinfo import ZoneInfo from slack_sdk.errors import SlackApiError from slack_sdk.models.views import View @@ -13,7 +15,6 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse from structlog.stdlib import get_logger -from zoneinfo import ZoneInfo from machine.models import Channel, User from machine.utils.datetime import calculate_epoch diff --git a/machine/core.py b/machine/core.py index eb36f688..c9da1493 100644 --- a/machine/core.py +++ b/machine/core.py @@ -4,15 +4,16 @@ import inspect import os import sys +from collections.abc import Awaitable from inspect import Signature -from typing import Awaitable, Callable, Literal, cast +from typing import Callable, Literal, cast +from zoneinfo import ZoneInfo import dill from apscheduler.schedulers.asyncio import AsyncIOScheduler from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.web.async_client import AsyncWebClient from structlog.stdlib import get_logger -from zoneinfo import ZoneInfo from machine.clients.slack import SlackClient from machine.handlers import ( diff --git a/machine/handlers/command_handler.py b/machine/handlers/command_handler.py index f6ca86a9..8e7a10d7 100644 --- a/machine/handlers/command_handler.py +++ b/machine/handlers/command_handler.py @@ -1,7 +1,8 @@ from __future__ import annotations import contextlib -from typing import Any, AsyncGenerator, Awaitable, Callable, Union, cast +from collections.abc import AsyncGenerator, Awaitable +from typing import Any, Callable, Union, cast from slack_sdk.models import JsonObject from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient diff --git a/machine/handlers/event_handler.py b/machine/handlers/event_handler.py index 1f125670..b3015ff4 100644 --- a/machine/handlers/event_handler.py +++ b/machine/handlers/event_handler.py @@ -1,7 +1,8 @@ from __future__ import annotations import asyncio -from typing import Any, Awaitable, Callable +from collections.abc import Awaitable +from typing import Any, Callable from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest diff --git a/machine/handlers/interactive_handler.py b/machine/handlers/interactive_handler.py index 7d2a1c10..26c1b26a 100644 --- a/machine/handlers/interactive_handler.py +++ b/machine/handlers/interactive_handler.py @@ -3,7 +3,8 @@ import asyncio import contextlib import re -from typing import AsyncGenerator, Awaitable, Callable, Union, cast +from collections.abc import AsyncGenerator, Awaitable +from typing import Callable, Union, cast from slack_sdk.models.views import View from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient diff --git a/machine/handlers/message_handler.py b/machine/handlers/message_handler.py index de1535ae..f5e6aff7 100644 --- a/machine/handlers/message_handler.py +++ b/machine/handlers/message_handler.py @@ -2,7 +2,8 @@ import asyncio import re -from typing import Any, Awaitable, Callable, Mapping +from collections.abc import Awaitable, Mapping +from typing import Any, Callable from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest diff --git a/machine/models/channel.py b/machine/models/channel.py index 57f5b697..87207c68 100644 --- a/machine/models/channel.py +++ b/machine/models/channel.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, ConfigDict @@ -36,7 +36,7 @@ class Channel(BaseModel): user: Optional[str] = None topic: Optional[PurposeTopic] = None purpose: Optional[PurposeTopic] = None - previous_names: Optional[List[str]] = None + previous_names: Optional[list[str]] = None @property def identifier(self) -> str: diff --git a/machine/models/core.py b/machine/models/core.py index ee141821..4b59ad35 100644 --- a/machine/models/core.py +++ b/machine/models/core.py @@ -1,9 +1,10 @@ from __future__ import annotations import re +from collections.abc import AsyncGenerator, Awaitable from dataclasses import dataclass, field from inspect import Signature -from typing import Any, AsyncGenerator, Awaitable, Callable, Union +from typing import Any, Callable, Union from slack_sdk.models import JsonObject diff --git a/machine/models/interactive.py b/machine/models/interactive.py index d8934c8e..52b121fb 100644 --- a/machine/models/interactive.py +++ b/machine/models/interactive.py @@ -1,13 +1,12 @@ from __future__ import annotations from datetime import date, time -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Annotated, Any, Literal, Optional, Union from pydantic import BaseModel, Field, TypeAdapter from pydantic.functional_validators import PlainValidator, model_validator from pydantic_core.core_schema import ValidationInfo from slack_sdk.models.blocks import Block as SlackSDKBlock -from typing_extensions import Annotated class TypedModel(BaseModel): @@ -77,7 +76,7 @@ class Option(BaseModel): class CheckboxValues(TypedModel): type: Literal["checkboxes"] - selected_options: List[Option] + selected_options: list[Option] class DatepickerValue(TypedModel): @@ -117,27 +116,27 @@ class ExternalSelectValue(TypedModel): class MultiStaticSelectValues(TypedModel): type: Literal["multi_static_select"] - selected_options: List[Option] + selected_options: list[Option] class MultiChannelSelectValues(TypedModel): type: Literal["multi_channels_select"] - selected_channels: List[str] + selected_channels: list[str] class MultiConversationSelectValues(TypedModel): type: Literal["multi_conversations_select"] - selected_conversations: List[str] + selected_conversations: list[str] class MultiUserSelectValues(TypedModel): type: Literal["multi_users_select"] - selected_users: List[str] + selected_users: list[str] class MultiExternalSelectValues(TypedModel): type: Literal["multi_external_select"] - selected_options: List[str] + selected_options: list[str] class NumberValue(TypedModel): @@ -197,7 +196,7 @@ class UrlValue(TypedModel): class State(BaseModel): - values: Dict[str, Dict[str, Values]] + values: dict[str, dict[str, Values]] class BaseAction(TypedModel): @@ -221,7 +220,7 @@ class ButtonAction(BaseAction): class CheckboxAction(BaseAction): type: Literal["checkboxes"] - selected_options: List[Option] + selected_options: list[Option] class DatepickerAction(BaseAction): @@ -256,27 +255,27 @@ class ExternalSelectAction(BaseAction): class MultiStaticSelectAction(BaseAction): type: Literal["multi_static_select"] - selected_options: List[Option] + selected_options: list[Option] class MultiChannelSelectAction(BaseAction): type: Literal["multi_channels_select"] - selected_channels: List[str] + selected_channels: list[str] class MultiConversationSelectAction(BaseAction): type: Literal["multi_conversations_select"] - selected_conversations: List[str] + selected_conversations: list[str] class MultiUserSelectAction(BaseAction): type: Literal["multi_users_select"] - selected_users: List[str] + selected_users: list[str] class MultiExternalSelectAction(BaseAction): type: Literal["multi_external_select"] - selected_options: List[str] + selected_options: list[str] class TimepickerAction(BaseAction): @@ -348,14 +347,14 @@ class Message(BaseModel): app_id: str text: str team: str - blocks: List[Block] + blocks: list[Block] class View(BaseModel): id: str team_id: str type: Literal["modal", "home"] - blocks: List[Block] + blocks: list[Block] private_metadata: str callback_id: str state: State @@ -395,7 +394,7 @@ class BlockActionsPayload(TypedModel): view: Optional[View] = None state: Optional[State] = None response_url: Optional[str] = None - actions: List[Action] + actions: list[Action] @model_validator(mode="after") def validate_view_or_message(self) -> BlockActionsPayload: @@ -420,7 +419,7 @@ class ViewSubmissionPayload(TypedModel): api_app_id: str token: str trigger_id: str - response_urls: List[ResponseUrlForView] + response_urls: list[ResponseUrlForView] is_enterprise_install: bool diff --git a/machine/plugins/base.py b/machine/plugins/base.py index d46bea38..e338545e 100644 --- a/machine/plugins/base.py +++ b/machine/plugins/base.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime -from typing import Any, Sequence +from typing import Any from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block diff --git a/machine/plugins/block_action.py b/machine/plugins/block_action.py index eab4bdec..680e8a1d 100644 --- a/machine/plugins/block_action.py +++ b/machine/plugins/block_action.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Optional, Sequence, Union +from collections.abc import Sequence +from typing import Any, Optional, Union from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block diff --git a/machine/plugins/command.py b/machine/plugins/command.py index 55be366b..4e426f10 100644 --- a/machine/plugins/command.py +++ b/machine/plugins/command.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Sequence +from collections.abc import Sequence +from typing import Any from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block diff --git a/machine/plugins/decorators.py b/machine/plugins/decorators.py index 4545fc42..b8a07efa 100644 --- a/machine/plugins/decorators.py +++ b/machine/plugins/decorators.py @@ -2,8 +2,9 @@ import inspect import re +from collections.abc import Awaitable from datetime import datetime, tzinfo -from typing import Any, Awaitable, Callable, Protocol, TypeVar, Union, cast +from typing import Any, Callable, Protocol, TypeVar, Union, cast from structlog.stdlib import get_logger from typing_extensions import ParamSpec diff --git a/machine/plugins/message.py b/machine/plugins/message.py index c1159b6f..0f03cebb 100644 --- a/machine/plugins/message.py +++ b/machine/plugins/message.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime -from typing import Any, Sequence, cast +from typing import Any, cast from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block diff --git a/machine/plugins/modals.py b/machine/plugins/modals.py index 4985a098..8b88d33c 100644 --- a/machine/plugins/modals.py +++ b/machine/plugins/modals.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Sequence +from collections.abc import Sequence +from typing import Any from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block diff --git a/machine/settings.py b/machine/settings.py index efd6fcb3..8082a185 100644 --- a/machine/settings.py +++ b/machine/settings.py @@ -1,6 +1,5 @@ import os from importlib import import_module -from typing import Tuple from structlog.stdlib import get_logger @@ -9,7 +8,7 @@ logger = get_logger(__name__) -def import_settings(settings_module: str = "local_settings") -> Tuple[CaseInsensitiveDict, bool]: +def import_settings(settings_module: str = "local_settings") -> tuple[CaseInsensitiveDict, bool]: default_settings = { "PLUGINS": [ "machine.plugins.builtin.general.PingPongPlugin", diff --git a/machine/storage/backends/base.py b/machine/storage/backends/base.py index 466f6dd2..4ff72ecc 100644 --- a/machine/storage/backends/base.py +++ b/machine/storage/backends/base.py @@ -1,7 +1,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any class MachineBaseStorage(ABC): diff --git a/machine/storage/backends/dynamodb.py b/machine/storage/backends/dynamodb.py index 3fb3e05a..7b135519 100644 --- a/machine/storage/backends/dynamodb.py +++ b/machine/storage/backends/dynamodb.py @@ -4,8 +4,9 @@ import calendar import datetime import typing +from collections.abc import Mapping from contextlib import AsyncExitStack -from typing import Any, Mapping, cast +from typing import Any, cast import aioboto3 from botocore.exceptions import ClientError diff --git a/machine/storage/backends/memory.py b/machine/storage/backends/memory.py index 0ef3ae45..ee208ea0 100644 --- a/machine/storage/backends/memory.py +++ b/machine/storage/backends/memory.py @@ -1,8 +1,9 @@ from __future__ import annotations import sys +from collections.abc import Mapping from datetime import datetime, timedelta -from typing import Any, Mapping +from typing import Any from machine.storage.backends.base import MachineBaseStorage diff --git a/machine/storage/backends/redis.py b/machine/storage/backends/redis.py index b0634917..9da44fc6 100644 --- a/machine/storage/backends/redis.py +++ b/machine/storage/backends/redis.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from redis.asyncio import Redis diff --git a/machine/storage/backends/sqlite.py b/machine/storage/backends/sqlite.py index 5f95c82a..3b0fa685 100644 --- a/machine/storage/backends/sqlite.py +++ b/machine/storage/backends/sqlite.py @@ -1,7 +1,8 @@ from __future__ import annotations import time -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any import aiosqlite diff --git a/machine/utils/collections.py b/machine/utils/collections.py index 564886cf..40ca226c 100644 --- a/machine/utils/collections.py +++ b/machine/utils/collections.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Iterable, Iterator, Mapping, MutableMapping, TypeVar, cast +from collections.abc import Iterable, Iterator, Mapping, MutableMapping +from typing import TypeVar, cast KT = TypeVar("KT", bound=str) VT = TypeVar("VT") diff --git a/machine/utils/logging.py b/machine/utils/logging.py index 64b02343..609e31e1 100644 --- a/machine/utils/logging.py +++ b/machine/utils/logging.py @@ -2,7 +2,8 @@ import logging import sys -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any import structlog from structlog.processors import CallsiteParameter diff --git a/machine/utils/module_loading.py b/machine/utils/module_loading.py index 8adcff46..c84aa893 100644 --- a/machine/utils/module_loading.py +++ b/machine/utils/module_loading.py @@ -1,9 +1,8 @@ import inspect from importlib import import_module -from typing import List, Tuple, Type -def import_string(dotted_path: str) -> List[Tuple[str, Type]]: +def import_string(dotted_path: str) -> list[tuple[str, type]]: """ Import all Classes from the module specified by the dotted_path. If dotted_path is not a module, try diff --git a/machine/utils/redis.py b/machine/utils/redis.py index 79dc34d3..7d11449d 100644 --- a/machine/utils/redis.py +++ b/machine/utils/redis.py @@ -1,4 +1,5 @@ -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from urllib.parse import urlparse diff --git a/poetry.lock b/poetry.lock index d0d470ef..53cee4ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2359,29 +2359,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.7.4" +version = "0.8.0" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"}, - {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"}, - {file = "ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06"}, - {file = "ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc"}, - {file = "ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172"}, - {file = "ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a"}, - {file = "ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd"}, - {file = "ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a"}, - {file = "ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac"}, - {file = "ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6"}, - {file = "ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f"}, - {file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"}, + {file = "ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"}, + {file = "ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b"}, + {file = "ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426"}, + {file = "ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468"}, + {file = "ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f"}, + {file = "ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6"}, + {file = "ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44"}, ] [[package]] @@ -3426,4 +3426,4 @@ sqlite = ["aiosqlite"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "e2db14016c676808832b37056ffe1f96fe66d0f2fc6c5f358d4163b5d9ece7c6" +content-hash = "347abcd547496740cfd26a6c7ed6bd181b341f2d24d943100efdf60fcd552472" diff --git a/pyproject.toml b/pyproject.toml index 4361b3c3..a8b98f77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ types-redis = "^4.3.21" aioboto3 = ">=10,<14" types-aiobotocore = {extras = ["essential"], version = "^2.4.0"} aiosqlite = ">=0.18,<0.21" -ruff = ">=0.4.3,<0.8.0" +ruff = ">=0.8.0,<0.9.0" [tool.poetry.group.docs] optional = true @@ -88,6 +88,7 @@ slack-machine = 'machine.bin.run:main' [tool.ruff] line-length = 120 +target-version = "py39" [tool.ruff.lint] exclude = [ diff --git a/tests/clients/test_slack_client.py b/tests/clients/test_slack_client.py index 03f3cf30..8353f1aa 100644 --- a/tests/clients/test_slack_client.py +++ b/tests/clients/test_slack_client.py @@ -1,12 +1,12 @@ from __future__ import annotations from typing import Any +from zoneinfo import ZoneInfo import pytest from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.web.async_client import AsyncWebClient -from zoneinfo import ZoneInfo from machine.clients.slack import SlackClient, id_for_channel, id_for_user from machine.models.channel import Channel diff --git a/tests/utils/test_datetime.py b/tests/utils/test_datetime.py index 133426b1..a420ea9a 100644 --- a/tests/utils/test_datetime.py +++ b/tests/utils/test_datetime.py @@ -1,10 +1,6 @@ -import sys from datetime import datetime +from zoneinfo import ZoneInfo -if sys.version_info >= (3, 9): - from zoneinfo import ZoneInfo -else: - from backports.zoneinfo import ZoneInfo from machine.utils.datetime import calculate_epoch From 2cfba315f8a69ef1a6226841bd0d17e608071a5a Mon Sep 17 00:00:00 2001 From: Daan Debie Date: Fri, 22 Nov 2024 16:41:43 +0100 Subject: [PATCH 4/4] fix: ignore mypy for specific file to circumvent Pydantic not supporting __replace__ yet See: https://github.com/pydantic/pydantic/issues/10699 --- machine/models/interactive.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/machine/models/interactive.py b/machine/models/interactive.py index 52b121fb..0c910b37 100644 --- a/machine/models/interactive.py +++ b/machine/models/interactive.py @@ -1,3 +1,7 @@ +# mypy: ignore-errors +# MyPy is disabled because Pydantic doesn't support __replace__ that was introduced in Python 3.13 yet, which causes +# MyPy to throw an error. This will be fixed once Pydantic supports __replace__ in v2.10 + from __future__ import annotations from datetime import date, time